From f432878c3aa612d878ca280b7e1ae7140e3c0aaa Mon Sep 17 00:00:00 2001 From: Rami Date: Sun, 18 Oct 2026 16:49:07 +0100 Subject: [PATCH 1/2] feat(connections): a record says type, a header names its scheme and the page takes the client id A connection's record named its server's transport kind, beside the kind of its credential and of a conflict, while a server's settings and search say type. The record, the input of add_connection and every answer that carries the record now say type, ConnectionTypeSchema, which picks mcp and openapi from the type of a tool server entry; the command and connection_added say server_type, since a fact's own type names the fact, as a run's command carries definition_type. A secret or a reference sent in a header may name its scheme, which the runtime writes and a space before the value it places, so a person pastes a token alone and an operator's variable holds the token alone. A scheme is an HTTP token, checked with headerNamePattern of tool-servers, without $, which the reading of the entry takes for a reference or an escape, and only in a header of a kind that places a value; each refusal has words of its own. give_credential refuses a value that starts with the scheme and a space. The scrubber holds the value whole, as a reference's value. The client id of client credentials is the org's, so add_connection takes client credentials without one, read with a client id standing in where the reading builds the auth block, and the person gives it on the page beside the client secret or the private key. give_credential requires it where the credential holds none, refuses it where it holds one, and reads it as the entry is read at add time, so no connection is connected with a client id its use would refuse. One that looks like a credential is refused in words of its own, at /client_id on the page and at /credential/client_id for the agent, not in the words the settings file gets. The issuer, the client id, the scope and the algorithm of a private key are placed with $$ for $, as the entry is, so each is sent as written. connection_connected records a client id given on the page and the fold sets it on the credential. read_link answers the record with link.expires_at, from the issued link's fact, so the page can say how long the person has; approve and give_credential answer the record alone. The server's tests add their connections with type and read the link's expiry over HTTP, and the OpenAPI document is regenerated, since its test holds it to what the server serves. The scheme and the optional client id add 147 bytes to the input of add_connection: /mcp lists its tools in 43,310 bytes of 48,000 and the org endpoint in 12,767 of 13,000. Co-Authored-By: Claude Fable 5.1 --- docs/assets/openapi.json | 113 +++++++++--- packages/connections/README.md | 64 +++---- .../src/entries/credential-checks.ts | 12 +- .../connections/src/entries/entry-checks.ts | 62 ++++++- .../src/operations/add-connection.test.ts | 4 +- .../src/operations/add-connection.ts | 11 +- .../src/operations/added-credentials.test.ts | 90 ++++++++- .../src/operations/connection-fields.ts | 8 +- .../src/operations/registry-access.ts | 11 +- .../src/page/give-credential.test.ts | 174 ++++++++++++++++++ .../connections/src/page/give-credential.ts | 88 ++------- packages/connections/src/page/given-fields.ts | 148 +++++++++++++++ .../src/page/link-operations.test.ts | 23 ++- .../connections/src/page/link-operations.ts | 16 +- .../connections/src/policy/policy-rules.ts | 8 +- .../src/registry/connection-commands.ts | 5 +- .../src/registry/connection-entry.ts | 6 +- .../src/registry/connection-events.ts | 5 +- .../connections/src/registry/connection.ts | 4 +- .../connections/src/registry/credential.ts | 56 +++++- .../src/registry/registry-decider.test.ts | 6 +- .../src/registry/registry-decisions.ts | 13 +- .../src/registry/registry-lookup.ts | 13 +- .../connections/src/registry/registry.test.ts | 35 +++- packages/connections/src/registry/registry.ts | 13 +- .../src/serving/connection-source.ts | 4 +- .../src/serving/placed-credentials.ts | 32 ++-- .../src/serving/served-behaviour.test.ts | 52 +++++- packages/connections/src/testing/additions.ts | 6 +- .../connections/connections-over-http.test.ts | 15 +- .../connections/connections-over-mcp.test.ts | 2 +- .../connections/connections-signed-in.test.ts | 2 +- .../server/src/mcp/served-schemas.test.ts | 4 +- .../src/testing/servers/connected-servers.ts | 2 +- .../src/testing/servers/tool-server-calls.ts | 2 +- 35 files changed, 871 insertions(+), 238 deletions(-) create mode 100644 packages/connections/src/page/given-fields.ts diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index e529d8afb..08412dcd8 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -1282,7 +1282,7 @@ "type": "string", "description": "The name a function gives the server, in lowercase and hyphens" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -1376,6 +1376,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -1383,7 +1388,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -1399,7 +1404,7 @@ }, "required": [ "name", - "kind", + "type", "entry", "credential" ] @@ -1425,7 +1430,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -1519,6 +1524,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -1526,7 +1536,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -1623,7 +1633,7 @@ "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", @@ -1740,7 +1750,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -1834,6 +1844,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -1841,7 +1856,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -1920,7 +1935,7 @@ "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", @@ -2023,7 +2038,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -2117,6 +2132,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -2124,7 +2144,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -2203,7 +2223,7 @@ "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", @@ -2302,7 +2322,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -2396,6 +2416,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -2403,7 +2428,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -2482,7 +2507,7 @@ "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", @@ -2549,7 +2574,7 @@ "get": { "operationId": "read_link", "summary": "Read link", - "description": "Reads the connection an open link names, so Studio's page can show what the person is asked to approve. A link is open for ten minutes from when add_connection gave it, until the connection is approved. A token that is unknown, used up or expired is answered as not found.", + "description": "Reads the connection an open link names, and when the link expires, so Studio's page can show what the person is asked to approve and for how long. A link is open for ten minutes from when add_connection gave it, until the connection is approved. A token that is unknown, used up or expired is answered as not found.", "tags": [ "connections" ], @@ -2597,7 +2622,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -2691,6 +2716,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -2698,7 +2728,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -2772,12 +2802,25 @@ } ], "description": "Why it is broken, while it is" + }, + "link": { + "type": "object", + "properties": { + "expires_at": { + "type": "string", + "description": "When the link expires, in ISO 8601 UTC" + } + }, + "required": [ + "expires_at" + ], + "additionalProperties": false } }, "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", @@ -2786,10 +2829,11 @@ "connected_at", "connected_by", "credential_ends_with", - "broken" + "broken", + "link" ], "additionalProperties": false, - "description": "A tool server the org added, shared by its playbooks" + "description": "The connection an open link names, and when the link expires" } } } @@ -2917,7 +2961,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -3011,6 +3055,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -3018,7 +3067,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -3097,7 +3146,7 @@ "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", @@ -3219,6 +3268,11 @@ "minLength": 1, "description": "The secret, for a credential of the kind secret" }, + "client_id": { + "type": "string", + "minLength": 1, + "description": "The id of the client of client credentials, where the connection's credential holds none" + }, "client_secret": { "type": "string", "minLength": 1, @@ -3259,7 +3313,7 @@ "type": "string", "description": "The name a function gives the connection in server/tool" }, - "kind": { + "type": { "type": "string", "enum": [ "mcp", @@ -3353,6 +3407,11 @@ ], "description": "For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document" }, + "scheme": { + "type": "string", + "minLength": 1, + "description": "For a header, the scheme written before the value, such as Bearer" + }, "issuer": { "type": "string", "description": "For client_credentials, the https URL of the authorization server" @@ -3360,7 +3419,7 @@ "client_id": { "type": "string", "minLength": 1, - "description": "For client_credentials, the id of the client" + "description": "For client_credentials, the id of the client, else given on the page" }, "scope": { "type": "string", @@ -3439,7 +3498,7 @@ "required": [ "id", "name", - "kind", + "type", "entry", "credential", "status", diff --git a/packages/connections/README.md b/packages/connections/README.md index 1f2212a2c..aa86055cc 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -4,39 +4,39 @@ The connections of an org: tool servers a person adds to the org through their a ## A connection -| Field | What it holds | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | A UUIDv7, `randomUUIDv7` of `@on.auto/operations`, chosen when it is added | -| `name` | The name a function gives it, checked by `serverNameProblems` of `@on.auto/tool-servers` as a tool server's name is; unique among the connections of the org that are not removed, and never the name of an entry of `TOOL_SERVERS` | -| `kind` | `mcp` or `openapi` | -| `entry` | `url`, `document`, `base_url`, `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id`, the fields of `ServerEntrySchema` of `@on.auto/tool-servers` that hold no credential, with descriptions of their own | -| `credential` | One flat object, `CredentialSchema`: `kind`, `none`, `reference`, `secret` or `client_credentials`, and the fields its kind takes: `name` and `in`, `header`, `query` or `security`, where its value is sent, for `secret`, and with them `variable`, the variable of the runtime that holds the value, for `reference`; `issuer`, `client_id` and an optional `scope` for `client_credentials`; none for `none` | -| `status` | `pending`, `connected`, `broken` or `removed` | -| `added_by` | The id of the caller who added it, such as `person:` | -| `added_at` | When it was added | -| `connected_at` | When it was connected, or `null` | -| `connected_by` | The id of the caller who connected it, approving it or giving its credential, from the context of `connection_connected`, or `null` | -| `credential_ends_with` | The ending the secret store keeps of its secret, as the ending of a credential below says, or `null` | -| `broken` | The sentence of why it is broken while it is, else `null` | +| Field | What it holds | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | A UUIDv7, `randomUUIDv7` of `@on.auto/operations`, chosen when it is added | +| `name` | The name a function gives it, checked by `serverNameProblems` of `@on.auto/tool-servers` as a tool server's name is; unique among the connections of the org that are not removed, and never the name of an entry of `TOOL_SERVERS` | +| `type` | `mcp` or `openapi`, `ConnectionTypeSchema`, the two types of the `type` of `ServerEntrySchema` of `@on.auto/tool-servers` that a connection may be | +| `entry` | `url`, `document`, `base_url`, `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id`, the fields of `ServerEntrySchema` of `@on.auto/tool-servers` that hold no credential, with descriptions of their own | +| `credential` | One flat object, `CredentialSchema`: `kind`, `none`, `reference`, `secret` or `client_credentials`, and the fields its kind takes: `name` and `in`, `header`, `query` or `security`, where its value is sent, and for `in: header` an optional `scheme`, which the runtime writes before the value, for `secret`, and with them `variable`, the variable of the runtime that holds the value, for `reference`; `issuer`, an optional `client_id`, which the person gives on the page with the secret where it is left out, and an optional `scope` for `client_credentials`; none for `none` | +| `status` | `pending`, `connected`, `broken` or `removed` | +| `added_by` | The id of the caller who added it, such as `person:` | +| `added_at` | When it was added | +| `connected_at` | When it was connected, or `null` | +| `connected_by` | The id of the caller who connected it, approving it or giving its credential, from the context of `connection_connected`, or `null` | +| `credential_ends_with` | The ending the secret store keeps of its secret, as the ending of a credential below says, or `null` | +| `broken` | The sentence of why it is broken while it is, else `null` | `ConnectionSchema` is the schema of the record. Times are ISO 8601 UTC strings read from Effect's `Clock`. -The credential is one shape wherever it appears: what `add_connection` takes, what every operation answers in the record, and what `connection_added` keeps. Its fields are optional in the schema, each described with the kind it belongs to, and a check on the schema holds each kind to exactly its fields, so a credential that lacks one or gives another is `invalid_input` at the field, in words that name it, as `Expected name, which a reference credential takes` or `Expected no in: a none credential takes no other field`. The same check refuses a `reference` whose `variable` is no variable name (`variableNamePattern` of `@on.auto/config`), a `secret` or a `reference` sent as a header whose `name` is no header name (`headerNamePattern` of `@on.auto/tool-servers`), and an `issuer` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`). +The credential is one shape wherever it appears: what `add_connection` takes, what every operation answers in the record, and what `connection_added` keeps, with the client id the person gives on the page set on it once the connection is connected. Its fields are optional in the schema, each described with the kind it belongs to, and a check on the schema holds each kind to exactly its fields, so a credential that lacks one or gives another is `invalid_input` at the field, in words that name it, as `Expected name, which a reference credential takes` or `Expected no in: a none credential takes no other field`. The same check refuses a `reference` whose `variable` is no variable name (`variableNamePattern` of `@on.auto/config`), a `secret` or a `reference` sent as a header whose `name` is no header name (`headerNamePattern` of `@on.auto/tool-servers`), and an `issuer` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`). A `scheme` is refused in words of its own: of a kind that places no value, `Expected no scheme: a none credential places no value in a header for the runtime to write a scheme before`; beside an `in` other than `header`, `Expected no scheme, which goes before a value sent in a header: this credential is sent in a query parameter`; holding `$`, which the reading of the placed entry takes for a reference or an escape, `Expected a scheme without $, which the reading of the entry takes for a reference or an escape`; and holding a character that is no HTTP token's, the characters of a header name (`headerNamePattern`), `Expected a scheme of the characters a header name holds, such as Bearer`. ## The facts and the fold The connections of an org live in one stream, `connections` relative to the org, `org//connections` (`connectionsStreamOfOrg`). Every fact carries `by` and `at` in its metadata, the `ActedContext` of `@on.auto/operations`, and none holds a secret, a token or a value derived from either but the link's digest: -| Fact | Data | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `connection_added` | `connection`, `name`, `kind`, `entry` and `credential`, as given | -| `connection_link_issued` | `connection`, `token_sha256`, the SHA-256 of the link's token in hex, and `expires_at`, ten minutes on; appended with `connection_added`, in one decision | -| `connection_approved` | `connection` | -| `connection_connected` | `connection`, and for a credential of the kind `secret` or `client_credentials` `secret_id`, the id the secret store keeps the value under, and `algorithm` for a private key | -| `connection_broken` | `connection` and `reason`, the sentence of why | -| `connection_removed` | `connection` | +| Fact | Data | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `connection_added` | `connection`, `name`, `server_type`, the record's `type`, named so since the fact's own `type` names the fact, `entry` and `credential`, as given | +| `connection_link_issued` | `connection`, `token_sha256`, the SHA-256 of the link's token in hex, and `expires_at`, ten minutes on; appended with `connection_added`, in one decision | +| `connection_approved` | `connection` | +| `connection_connected` | `connection`, and for a credential of the kind `secret` or `client_credentials` `secret_id`, the id the secret store keeps the value under, `algorithm` for a private key, and `client_id` where the person gave it on the page | +| `connection_broken` | `connection` and `reason`, the sentence of why | +| `connection_removed` | `connection` | -There is no read model: each call folds the stream (`connectionsFold`, the fold of `registryDecider`). The fold keeps, by id, the record, the open link, the id of its secret, which it keeps after a removal so the store can forget it, and the algorithm of a private key. It answers the connections newest first. `connection_connected` closes the link, and so does `connection_removed`. A pure decider holds the rules, and the handlers pass it who and when in the command: +There is no read model: each call folds the stream (`connectionsFold`, the fold of `registryDecider`). The fold keeps, by id, the record, the open link, the id of its secret, which it keeps after a removal so the store can forget it, and the algorithm of a private key. It sets the `client_id` of `connection_connected` on the record's credential, so a connection connected holds one shape of credential, its client id given with the credential or on the page, and a pending one whose credential leaves its client id out is answered without it. It answers the connections newest first. `connection_connected` closes the link, and so does `connection_removed`. A pure decider holds the rules, and the handlers pass it who and when in the command: - `add` refuses a name a connection of the org holds that is not removed, `conflict` of the kind `taken`, and a connection past the 50 an org may hold that are not removed (`mostConnectionsOfAnOrg`), `conflict` of the kind `connections_full`, `The org holds 50 connections, the most it may; remove one before adding another`, which bounds the links the tool access keeps for the org, and records the connection and its link. - `approve` and `give` look the connection up, `not_found`, then the link: open while the connection is pending, its digest the token's and `expires_at` later than now, else `not_found`, `There is no open link of this token: it is unknown, used up or expired`. Where people sign in, the caller must be `added_by`, else `forbidden`; `makeConnectionOperations` hands the two whether a link binds a person, which it does where `access` is `people`. `approve` connects a connection of the credential `none` or `reference`, recording `connection_approved` and `connection_connected`, and refuses one that takes a value with `conflict`; `give` records the two with the secret's id, and refuses a connection that takes none with `conflict`. @@ -53,19 +53,19 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold | `list_connections` | query | `GET /connections` | `org:read` | `{ connections }`, newest first | none | | `get_connection` | query | `GET /connections/{id}` | `org:read` | the record | `not_found` | | `remove_connection` | command | `DELETE /connections/{id}` | `org:write` | the record, removed | `not_found`, `conflict` | -| `read_link` | query | `GET /connections/{id}/link/{token}` | any | the record | `not_found` | +| `read_link` | query | `GET /connections/{id}/link/{token}` | any | the record and `link` | `not_found` | | `approve` | command | `POST /connections/{id}/approve` | any | the record | `not_found`, `forbidden`, `conflict` | | `give_credential` | command | `POST /connections/{id}/credential` | any | the record | `not_found`, `forbidden`, `conflict`, `invalid_input` | -- `add_connection` first refuses a caller who cannot add a connection, `forbidden` (`adderRefusalOf`): where the runtime signs people in, a caller that is not a signed-in person (`isSignedInPerson` of `@on.auto/identity`), since a key's connection would wait for a person who never comes, and, where it takes keys alone, every caller, with the sentence that this runtime signs nobody in, so no person can approve a connection. In local mode the local developer adds any. It then checks the entry, every problem at its pointer in one `invalid_input` (`additionIssuesOf` in `src/entries/entry-checks.ts`): the name with `serverNameProblems`; an `mcp` connection's `url` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and a `document` that is not a URL, which would be a file on the runtime's disk; the entry with `credentialProblems` of `@on.auto/config`, so a value that looks like a credential is refused, and with `substituted` of the same package against an empty environment, so any `${…}` is refused as a reference; and the credential: a header the runtime sets itself (`headersTheRuntimeSets` of `@on.auto/tool-servers`, which the headers of an entry are checked against too) or the entry sends already, a query parameter or a security scheme for an `mcp` connection, and a query parameter the entry sends already. Where all of these pass (`entryIssuesOf`), it reads the entry with its credential placed, a value standing in for the one the person gives (`readAddition`), as the source reads it to serve it with the real value (see [The calls](#the-calls)), so what the file's reading refuses is refused here, at its pointer under `/entry`, and no connection reaches the ledger that its use would refuse for its entry. A secret may go in `Authorization`, which the runtime sets itself only for client credentials and an entry never sends, since its name names a credential. It then applies the policy, `forbidden` with the sentence of the rule, refuses the name of an entry of the file, `conflict` of the kind `taken`, and records the connection with a link: a token of 32 random bytes in base64url, of which the fact keeps the SHA-256 alone. `connect.url` is `/connect?server=&org=&connection=&token=` (`connectPageOf`). Its definition gives `linkToOpen`, the page and a message saying what to do there, which the MCP tool asks a client to open (`@on.auto/api`). -- `read_link` matches the token as `approve` does, never binding a person, and answers the record so the page can show it. -- `give_credential` checks the link and the fields the credential's kind takes, `value` alone for a `secret`, `client_secret` alone or `private_key` with `algorithm` for `client_credentials`, else `invalid_input`; keeps exactly the value in the secret store, a value the store refuses being `invalid_input` at its pointer with the store's sentence; and records the credential's id. A decision that fails after the value was kept, as when the connection was removed meanwhile, forgets it. +- `add_connection` first refuses a caller who cannot add a connection, `forbidden` (`adderRefusalOf`): where the runtime signs people in, a caller that is not a signed-in person (`isSignedInPerson` of `@on.auto/identity`), since a key's connection would wait for a person who never comes, and, where it takes keys alone, every caller, with the sentence that this runtime signs nobody in, so no person can approve a connection. In local mode the local developer adds any. It then checks the entry, every problem at its pointer in one `invalid_input` (`additionIssuesOf` in `src/entries/entry-checks.ts`): the name with `serverNameProblems`; an `mcp` connection's `url` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and a `document` that is not a URL, which would be a file on the runtime's disk; the entry with `credentialProblems` of `@on.auto/config`, so a value that looks like a credential is refused, and with `substituted` of the same package against an empty environment, so any `${…}` is refused as a reference; and the credential: a header the runtime sets itself (`headersTheRuntimeSets` of `@on.auto/tool-servers`, which the headers of an entry are checked against too) or the entry sends already, a query parameter or a security scheme for an `mcp` connection, and a query parameter the entry sends already. Where all of these pass (`entryIssuesOf`), it reads the entry with its credential placed, a value standing in for the one the person gives (`readAddition`), as the source reads it to serve it with the real value (see [The calls](#the-calls)), so what the file's reading refuses is refused here, at its pointer under `/entry`, and no connection reaches the ledger that its use would refuse for its entry. Client credentials that leave their `client_id` to the page are read with a client id standing in for it too, since the reading builds the `auth` block, which takes one; every other check runs on what was given. What the reading refuses in a client id, one that looks like a credential, is refused at `/credential/client_id`, the field the agent wrote, in words of its own, `Looks like a credential, which a client id never is: leave the secret out, and the person gives it on the page the link opens`, which replace the reading's (`heldCredentialAsClientId`, keyed on the reading's `/entry/auth/client_id`). A secret may go in `Authorization`, which the runtime sets itself only for client credentials and an entry never sends, since its name names a credential. It then applies the policy, `forbidden` with the sentence of the rule, refuses the name of an entry of the file, `conflict` of the kind `taken`, and records the connection with a link: a token of 32 random bytes in base64url, of which the fact keeps the SHA-256 alone. `connect.url` is `/connect?server=&org=&connection=&token=` (`connectPageOf`). Its definition gives `linkToOpen`, the page and a message saying what to do there, which the MCP tool asks a client to open (`@on.auto/api`). +- `read_link` matches the token as `approve` does, never binding a person, and answers the record and `link`, `{ expires_at }`, the time `connection_link_issued` holds and the match checks, so the page can show the connection and how long the person has. `approve` and `give_credential` answer the record alone, since they use the link up. +- `give_credential` checks the link and the fields the credential's kind takes, `value` alone for a `secret`, `client_secret` alone or `private_key` with `algorithm` for `client_credentials`, else `invalid_input`. Client credentials take `client_id` beside them where the credential holds none, and refuse it where it holds one, each at `/client_id`: `Expected client_id: the connection's credential holds no client id, so it is given here with the secret or the key` and `Expected no client_id: the connection's credential holds its client id already`. A client id given here is read as `add_connection` reads the entry, with the value standing in for the secret (`clientIdReadingIssuesOf`), and one that looks like a credential is refused at `/client_id` before anything is kept, so no connection is connected with a client id its use would refuse: `Looks like a credential, which a client id never is: the connection's record keeps the client id in the open, so the secret or the key goes in its own field, where it is sealed`. A `secret` whose credential names a `scheme` refuses a value that starts with the scheme and a space, in any case, at `/value`, `Paste the token alone, without Bearer before it: the runtime writes Bearer itself`. It keeps exactly the value in the secret store, a value the store refuses being `invalid_input` at its pointer with the store's sentence, and records the credential's id, and the client id given on the page. A decision that fails after the value was kept, as when the connection was removed meanwhile, forgets it. - `remove_connection` forgets the connection's secret once its removal is appended, and forgetting twice is forgetting once. - The three operations of the page are permitted by every permission, since the token binds them and, where people sign in, the person: their caller must still belong to the org of the route. ## The ending of a credential -`give_credential` hands the secret store, with a secret or a client secret of at least twelve characters, its last four, `keep(value, endsWith)`, and no ending with a shorter value or a private key. The store keeps the ending in the value's row and forgets it with the value, so `remove_connection` forgets both; no fact holds it and the fold records nothing of it. Every operation answers the connection with `credential_ends_with` beside `connected_at`, never inside `credential`, which is exactly what `add_connection` took: `list_connections` and `get_connection` read it with `endingsOf` for each connection that has a secret, `give_credential` answers the ending it kept, and the others, whose connection holds none, answer `null`. +`give_credential` hands the secret store, with a secret or a client secret of at least twelve characters, its last four, `keep(value, endsWith)`, and no ending with a shorter value or a private key. The store keeps the ending in the value's row and forgets it with the value, so `remove_connection` forgets both; no fact holds it and the fold records nothing of it. Every operation answers the connection with `credential_ends_with` beside `connected_at`, never inside `credential`, which is what `add_connection` took and the client id given on the page: `list_connections` and `get_connection` read it with `endingsOf` for each connection that has a secret, `give_credential` answers the ending it kept, and the others, whose connection holds none, answer `null`. ## The policy @@ -75,12 +75,12 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold `makeConnectionSource({ registry, secrets, policy, variables, modelProviders })` (`src/serving/connection-source.ts`) is the `ConnectionSource` of `@on.auto/tool-servers`, which the server gives its tool access, so a connected connection is a tool server of its org for every function and every operation over tool servers, through the same code as an entry of `TOOL_SERVERS`. `registry` is the ledger, read and written by the full stream name, `secrets` the `read` of the secret store, `policy` the policy read at start, `variables` the values of the variables `policy.variables` names that the runtime's environment sets, read at start, and `modelProviders` the names of the model providers and gateways a connection's entry is checked against. `takesConnections` is `policy.peopleMayAdd`. -`connectionsOf(org)` folds the org's stream, from its first fact on every reading, as the playbook registry folds the org's playbooks for every status it answers, and keeps nothing between readings, so the tool access reads it once for each operation over tool servers; it answers each connection that is not removed, newest first, with its id, how it serves, its name, `type` its kind, `orgs` the org, and the `playbooks`, `allowed`, `record_content` and `request_id` of its entry, each as `servedFieldsOf` of `@on.auto/tool-servers` gives it, the default where the entry leaves it out, as for a file's entry, and its state: a pending one unready, `connection_pending`, `Nobody has approved the connection crm yet; the person who added it opens its link, or removes it and adds it again for a new link`; a broken one unready, `connection_broken`, with its `broken`; and a connected one with `server`, which the tool access runs once for each connection a process holds, as it makes its link: +`connectionsOf(org)` folds the org's stream, from its first fact on every reading, as the playbook registry folds the org's playbooks for every status it answers, and keeps nothing between readings, so the tool access reads it once for each operation over tool servers; it answers each connection that is not removed, newest first, with its id, how it serves, its name, its `type`, `orgs` the org, and the `playbooks`, `allowed`, `record_content` and `request_id` of its entry, each as `servedFieldsOf` of `@on.auto/tool-servers` gives it, the default where the entry leaves it out, as for a file's entry, and its state: a pending one unready, `connection_pending`, `Nobody has approved the connection crm yet; the person who added it opens its link, or removes it and adds it again for a new link`; a broken one unready, `connection_broken`, with its `broken`; and a connected one with `server`, which the tool access runs once for each connection a process holds, as it makes its link: 1. The policy of the moment is checked with the rules `add_connection` applies (`useRefusalOf`), and a connection a rule no longer takes is `connection_not_allowed` with the rule's sentence, its credential unread: `credential_kinds`, `variables` for a `reference`, and `allowed_hosts` over the entry's `url`, `document` and `base_url` and the credential's `issuer`. With `people_may_add` `false` the runtime uses no connection, in words that speak of using rather than adding, `This runtime uses no connection: its operator sets up its tool servers itself`, while `list_connections` still answers each as it is. 2. The connection is checked with `entryIssuesOf`, the checks of `add_connection` that need no value, against the model providers of the moment, so an entry the runtime no longer takes, as one named as a model provider set up since, is `connection_not_allowed`, `The entry of the connection is not one this runtime takes any more: `, its credential unread. 3. The credential is read (`src/serving/given-credentials.ts`): none for `none`; for a `reference`, the value of its `variable`, and a variable the environment leaves unset is `connection_not_allowed`, saying whoever runs the runtime sets it and starts it again; for a `secret` or `client_credentials`, the store's row of its secret's id. A row the store cannot read, `SecretKeyUnknown`, or one that is gone breaks the connection: the source issues `break`, records `connection_broken` with the sentence of why, `The credential of the connection crm cannot be read, so the person who added it removes it and adds it again: ` or `… is no longer in the secret store, so …`, and answers it `connection_broken` in it. Nothing un-breaks a connection; the person removes it and adds it again. -4. The credential is placed as the file places a reference (`src/serving/placed-credentials.ts`): the entry, every `$` in it written `$$` so that it is sent as it is written, with a `secret` or a `reference` as `${…}` in the header, the query parameter or the security scheme its `in` and `name` say, or `client_credentials` as the `auth` block with the client secret, or the private key, as `${…}` beside the algorithm; a `reference` names its own variable, and a value given on the page stands under a variable of its own, in an environment of that one entry. `readEntry` of `@on.auto/tool-servers`, the reading of an entry of the file, then resolves it and makes it `ServerSettings`, its type the connection's kind and its org the org, with `substituted`, `credentialProblems`, `unreferencedSecurity`, `misplacedReferences` and `secretsOfEntry` as they are for the file: every value the reference resolves to is a secret, the whole value of a security scheme too, and a value in a header that is a scheme and a credential, as `Bearer `, a secret whole and its credential alone, so the scrubber catches it in every form `@on.auto/tool-servers` lists. This is `readAddition`, the reading `add_connection` makes with a value standing in for the person's, made once here with the real value; what it refuses is `connection_not_allowed` in the words of step 2, so a check that a value can fail refuses in its own words, as an empty value of a reference does. +4. The credential is placed as the file places a reference (`src/serving/placed-credentials.ts`): the entry, every `$` in it written `$$` so that it is sent as it is written, with a `secret` or a `reference` as `${…}` in the header, the query parameter or the security scheme its `in` and `name` say, after its `scheme` and a space where it names one, as in `Bearer ${…}`, or `client_credentials` as the `auth` block with its issuer, client id and scope, and the client secret, or the private key, as `${…}` beside the algorithm, the issuer, the client id, the scope and the algorithm each written with `$$` for `$` as the entry is; a `reference` names its own variable, and a value given on the page stands under a variable of its own, in an environment of that one entry. `readEntry` of `@on.auto/tool-servers`, the reading of an entry of the file, then resolves it and makes it `ServerSettings`, its type the connection's and its org the org, with `substituted`, `credentialProblems`, `unreferencedSecurity`, `misplacedReferences` and `secretsOfEntry` as they are for the file: every value the reference resolves to is a secret, the whole value of a security scheme too, and a value in a header that is a scheme and a credential, as `Bearer `, a secret whole and its credential alone, so the scrubber catches it in every form `@on.auto/tool-servers` lists. This is `readAddition`, the reading `add_connection` makes with a value standing in for the person's, made once here with the real value; what it refuses is `connection_not_allowed` in the words of step 2, so a check that a value can fail refuses in its own words, as an empty value of a reference does. The answer carries `reaches`, which answers a URL whose host `allowed_hosts` does not take with the sentence of the rule, `hostRefusalOf`; the tool access checks every request of the connection with it, the server its document names where `base_url` is left out among them, and the request of a redirect. @@ -88,4 +88,4 @@ The policy is read at start, so the operator's change reaches a running runtime ## Source -`src/index.ts` is the only entry point. `src/registry` holds the record, its entry and credential, the facts and commands of the stream, the fold, the decider and its rules, and the lookups; `src/links` the token and Studio's page; `src/policy` the policy and its rules; `src/entries` the checks of an entry and a credential; `src/serving` the source of the connections as tool servers, the reading and the placement of their credentials and their words; `src/operations` the four operations of the connections, all but `get_connection` tools as well, with the fields, words and services the seven share, how they load and record the stream, and `makeConnectionOperations`; `src/page` the three operations of Studio's page; `src/testing` what the tests share, and `src/testing/index.ts`, the entry `@on.auto/connections/testing`, the fold and the name of the stream, with which a test of the server reads the id of a connection's secret from the ledger. `operations`, `page` and `serving` depend on the others, and `registry` on none of them. +`src/index.ts` is the only entry point. `src/registry` holds the record, its entry and credential, the facts and commands of the stream, the fold, the decider and its rules, and the lookups; `src/links` the token and Studio's page; `src/policy` the policy and its rules; `src/entries` the checks of an entry and a credential; `src/serving` the source of the connections as tool servers, the reading and the placement of their credentials and their words; `src/operations` the four operations of the connections, all but `get_connection` tools as well, with the fields, words and services the seven share, how they load and record the stream, and `makeConnectionOperations`; `src/page` the three operations of Studio's page and the fields and checks of what the page gives; `src/testing` what the tests share, and `src/testing/index.ts`, the entry `@on.auto/connections/testing`, the fold and the name of the stream, with which a test of the server reads the id of a connection's secret from the ledger. `operations`, `page` and `serving` depend on the others, and `registry` on none of them. diff --git a/packages/connections/src/entries/credential-checks.ts b/packages/connections/src/entries/credential-checks.ts index 5554f7135..0965b840b 100644 --- a/packages/connections/src/entries/credential-checks.ts +++ b/packages/connections/src/entries/credential-checks.ts @@ -1,7 +1,7 @@ import type { Issue } from '@on.auto/operations'; import { headersTheRuntimeSets } from '@on.auto/tool-servers'; -import type { ConnectionEntry, ConnectionKind } from '../registry/connection-entry.ts'; +import type { ConnectionEntry, ConnectionType } from '../registry/connection-entry.ts'; import type { Credential } from '../registry/credential.ts'; function issue(pointer: string, detail: string): readonly Issue[] { @@ -16,8 +16,8 @@ function sentAlready(names: Readonly> | undefined, name: return among(Object.keys(names ?? {}), name); } -function headerIssuesOf(name: string | undefined, kind: ConnectionKind, entry: ConnectionEntry): readonly Issue[] { - const reserved = headersTheRuntimeSets(kind); +function headerIssuesOf(name: string | undefined, type: ConnectionType, entry: ConnectionEntry): readonly Issue[] { + const reserved = headersTheRuntimeSets(type); if (among(reserved.names, name)) { return issue('/credential/name', `A header ${reserved.setBy} sets itself, so the credential cannot go in it`); } @@ -28,13 +28,13 @@ function headerIssuesOf(name: string | undefined, kind: ConnectionKind, entry: C export function credentialIssuesOf( credential: Credential, - kind: ConnectionKind, + type: ConnectionType, entry: ConnectionEntry, ): readonly Issue[] { if (credential.in === 'header') { - return headerIssuesOf(credential.name, kind, entry); + return headerIssuesOf(credential.name, type, entry); } - if (credential.in !== undefined && kind === 'mcp') { + if (credential.in !== undefined && type === 'mcp') { return issue( '/credential/in', 'Expected header for an mcp connection: a query parameter or a security scheme is an HTTP API’s', diff --git a/packages/connections/src/entries/entry-checks.ts b/packages/connections/src/entries/entry-checks.ts index fc17bdb8e..3a086735e 100644 --- a/packages/connections/src/entries/entry-checks.ts +++ b/packages/connections/src/entries/entry-checks.ts @@ -9,14 +9,14 @@ import { } from '@on.auto/tool-servers'; import { JsonPointer, Redacted, Result, Schema } from 'effect'; -import type { ConnectionEntry, ConnectionKind } from '../registry/connection-entry.ts'; +import type { ConnectionEntry, ConnectionType } from '../registry/connection-entry.ts'; import type { Credential } from '../registry/credential.ts'; import { placedCredential, readPlaced, type GivenValue } from '../serving/placed-credentials.ts'; import { credentialIssuesOf } from './credential-checks.ts'; export interface AdditionInput { readonly name: string; - readonly kind: ConnectionKind; + readonly type: ConnectionType; readonly entry: ConnectionEntry; readonly credential: Credential; } @@ -29,6 +29,26 @@ export interface EntryChecking { const heldCredential = "Looks like a credential, which a connection's entry never holds: the person gives it on the page the link opens"; +const clientIdOfTheReading = '/entry/auth/client_id'; + +interface ClientIdWords { + readonly onThePage: Issue; + readonly inTheAddition: Issue; +} + +const heldCredentialAsClientId: ClientIdWords = { + onThePage: { + pointer: '/client_id', + detail: + "Looks like a credential, which a client id never is: the connection's record keeps the client id in the open, so the secret or the key goes in its own field, where it is sealed", + }, + inTheAddition: { + pointer: '/credential/client_id', + detail: + 'Looks like a credential, which a client id never is: leave the secret out, and the person gives it on the page the link opens', + }, +}; + const heldReference = "Holds a reference to an environment variable, which a connection's entry never holds: a reference credential names the variable"; @@ -36,6 +56,8 @@ const fileOnly = `${pinnedUrlShape}: a connection reads its document from the ne const anyValue: GivenValue = { value: Redacted.make('a value the person gives'), algorithm: undefined }; +const anyClientId = 'a client id the person gives'; + const asJson = Schema.decodeUnknownSync(Schema.Json); function issuesOf(problems: readonly SettingProblem[], from: string, to: string): readonly Issue[] { @@ -61,8 +83,8 @@ function writtenIssues(name: string, entry: ConnectionEntry): readonly Issue[] { ]; } -function addressIssues({ kind, entry }: AdditionInput): readonly Issue[] { - if (kind === 'mcp') { +function addressIssues({ type, entry }: AdditionInput): readonly Issue[] { + if (type === 'mcp') { return entry.url === undefined || isPinnableUrl(entry.url) ? [] : [{ pointer: '/entry/url', detail: pinnedUrlShape }]; @@ -82,7 +104,7 @@ export function entryIssuesOf(input: AdditionInput, checking: EntryChecking): re ...named, ...addressIssues(input), ...writtenIssues(input.name, input.entry), - ...credentialIssuesOf(input.credential, input.kind, input.entry), + ...credentialIssuesOf(input.credential, input.type, input.entry), ]; } @@ -95,13 +117,39 @@ export function readAddition( return Result.mapError(read, (problems) => issuesOf(problems, `/${JsonPointer.escapeToken(input.name)}`, '/entry')); } +function withAnyClientId(credential: Credential): Credential { + return credential.kind === 'client_credentials' && credential.client_id === undefined + ? { ...credential, client_id: anyClientId } + : credential; +} + +function readingIssuesOf(input: AdditionInput, checking: EntryChecking): readonly Issue[] { + const given = input.credential.kind === 'none' ? undefined : anyValue; + return Result.match(readAddition(input, checking, given), { onSuccess: () => [], onFailure: (read) => read }); +} + +function isOfTheClientId({ pointer }: Issue): boolean { + return pointer === clientIdOfTheReading; +} + export function additionIssuesOf(input: AdditionInput, checking: EntryChecking): readonly Issue[] { const issues = entryIssuesOf(input, checking); if (issues.length > 0) { return issues; } - const given = input.credential.kind === 'none' ? undefined : anyValue; - return Result.match(readAddition(input, checking, given), { onSuccess: () => [], onFailure: (read) => read }); + const read = readingIssuesOf({ ...input, credential: withAnyClientId(input.credential) }, checking); + return read.map((issue) => (isOfTheClientId(issue) ? heldCredentialAsClientId.inTheAddition : issue)); +} + +export function clientIdReadingIssuesOf( + input: AdditionInput, + clientId: string, + checking: EntryChecking, +): readonly Issue[] { + const withTheClientId = { ...input, credential: { ...input.credential, client_id: clientId } }; + return readingIssuesOf(withTheClientId, checking) + .filter((issue) => isOfTheClientId(issue)) + .map(() => heldCredentialAsClientId.onThePage); } export function invalidAddition(issues: readonly Issue[]): InvalidInput { diff --git a/packages/connections/src/operations/add-connection.test.ts b/packages/connections/src/operations/add-connection.test.ts index e21e43517..ba7562cd0 100644 --- a/packages/connections/src/operations/add-connection.test.ts +++ b/packages/connections/src/operations/add-connection.test.ts @@ -12,7 +12,7 @@ describe('the answer to adding a connection', () => { status: 'succeeded', output: { name: 'crm', - kind: 'mcp', + type: 'mcp', entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' }, @@ -69,7 +69,7 @@ describe('the facts of adding a connection', () => { await adding(harness(), { ...billing, entry: { ...billing.entry, playbooks: ['billing-runs'] } }), ).toMatchObject({ status: 'succeeded', - output: { kind: 'openapi', entry: { playbooks: ['billing-runs'] }, credential: { kind: 'client_credentials' } }, + output: { type: 'openapi', entry: { playbooks: ['billing-runs'] }, credential: { kind: 'client_credentials' } }, }); }); }); diff --git a/packages/connections/src/operations/add-connection.ts b/packages/connections/src/operations/add-connection.ts index 614dd7e7f..59e13446f 100644 --- a/packages/connections/src/operations/add-connection.ts +++ b/packages/connections/src/operations/add-connection.ts @@ -1,4 +1,4 @@ -import { Caller, OrgContext, defineCommand, quoted, randomUUIDv7 } from '@on.auto/operations'; +import { Caller, OrgContext, defineCommand, quoted } from '@on.auto/operations'; import { Effect } from 'effect'; import { additionIssuesOf, conflictAsTakenByTheFile, invalidAddition } from '../entries/entry-checks.ts'; @@ -8,7 +8,7 @@ import { adderRefusalOf, policyRefusalOf } from '../policy/policy-rules.ts'; import { AddedSchema, AdditionSchema } from './connection-fields.ts'; import type { ConnectionServices } from './connection-services.ts'; import { connectionNamed, linkWords } from './connection-words.ts'; -import { recordInRegistry } from './registry-access.ts'; +import { additionOf, recordInRegistry } from './registry-access.ts'; const description = [ "Adds a tool server to the org as a connection, which its playbooks may use, and answers it pending with a link to Studio's page.", @@ -44,12 +44,11 @@ export function defineAddConnection({ policy, fileEntries, modelProviders, pages return yield* Effect.fail(conflictAsTakenByTheFile(input.name)); } const { token, ...link } = yield* issuedLink; - const { name, kind, entry, credential } = input; - const connection = randomUUIDv7(); - const kept = yield* recordInRegistry({ type: 'add', connection, name, kind, entry, credential, link }).pipe( + const addition = additionOf(input, link); + const kept = yield* recordInRegistry(addition).pipe( Effect.catchTags({ not_found: Effect.die, forbidden: Effect.die }), ); - const url = connectPageOf(pages, { org, connection, token }); + const url = connectPageOf(pages, { org, connection: addition.connection, token }); return { ...kept.connection, credential_ends_with: null, connect: { url, expires_at: link.expires_at } }; }), plainLanguage: { diff --git a/packages/connections/src/operations/added-credentials.test.ts b/packages/connections/src/operations/added-credentials.test.ts index ad6a626fd..4ad7bc39a 100644 --- a/packages/connections/src/operations/added-credentials.test.ts +++ b/packages/connections/src/operations/added-credentials.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { connectionPolicyOf } from '../policy/connection-policy.ts'; import { adding, billing, crm, issuesIn, statusesOf } from '../testing/additions.ts'; import { harness } from '../testing/harness.ts'; @@ -26,9 +27,9 @@ const refusedCredentials = [ ['a secret in a header the entry sends', { kind: 'secret', name: 'x-tenant', in: 'header' }, '/credential/name'], ['a secret in no header', { kind: 'secret', name: 'a header', in: 'header' }, '/credential/name'], [ - 'client credentials without their client', - { kind: 'client_credentials', issuer: 'https://auth.example.com' }, - '/credential/client_id', + 'a scheme that holds a space', + { kind: 'secret', name: 'Authorization', in: 'header', scheme: 'Bearer token' }, + '/credential/scheme', ], [ 'client credentials of an issuer that is not pinned', @@ -51,6 +52,7 @@ describe('a credential that cannot be taken', () => { { kind: 'reference', name: 'Authorization', in: 'header' }, { kind: 'none', in: 'header' }, { kind: 'client_credentials', issuer: 'https://auth.example.com', client_id: 'acme', name: 'CRM_API_KEY' }, + { kind: 'secret', name: 'Authorization', in: 'header', issuer: 'https://auth.example.com' }, ]; const refused = await Promise.all( @@ -66,6 +68,88 @@ describe('a credential that cannot be taken', () => { detail: 'Expected no name: a client_credentials credential takes issuer, client_id, and scope', }, ], + [{ pointer: '/credential/issuer', detail: 'Expected no issuer: a secret credential takes name, in, and scheme' }], + ]); + }); +}); + +function withScheme(scheme: string, credential: Readonly> = {}) { + return { kind: 'secret', name: 'Authorization', in: 'header', scheme, ...credential }; +} + +describe('the scheme of a credential sent in a header', () => { + it('is taken by a secret and a reference, and answered with the credential', async () => { + const h = harness({ policy: connectionPolicyOf({ variables: ['CRM_API_KEY'] }) }); + const reference = withScheme('Token', { kind: 'reference', variable: 'CRM_API_KEY' }); + + const answers = [ + await adding(h, { ...crm, credential: withScheme('Bearer') }), + await adding(h, { ...crm, name: 'crm-key', credential: reference }), + ]; + + expect(answers).toMatchObject([ + { status: 'succeeded', output: { credential: withScheme('Bearer') } }, + { status: 'succeeded', output: { credential: reference } }, + ]); + }); + + it('is refused in its own words where nothing is sent in a header, and where it holds what a scheme cannot', async () => { + const api = { ...billing, credential: withScheme('Bearer', { name: 'key', in: 'query' }) }; + const additions: readonly unknown[] = [ + { ...crm, credential: { kind: 'none', scheme: 'Bearer' } }, + { ...billing, credential: { ...billing.credential, scheme: 'Bearer' } }, + api, + { ...api, credential: withScheme('Bearer', { name: 'apiKeyAuth', in: 'security' }) }, + { ...crm, credential: withScheme('Bearer$') }, + { ...crm, credential: withScheme('Bearer:') }, + ]; + + const refused = await Promise.all(additions.map(async (addition) => issuesIn(await adding(harness(), addition)))); + + expect(refused).toEqual( + [ + 'Expected no scheme: a none credential places no value in a header for the runtime to write a scheme before', + 'Expected no scheme: a client_credentials credential places no value in a header for the runtime to write a scheme before', + 'Expected no scheme, which goes before a value sent in a header: this credential is sent in a query parameter', + 'Expected no scheme, which goes before a value sent in a header: this credential is sent in a security scheme of the document, which says how the value is sent', + 'Expected a scheme without $, which the reading of the entry takes for a reference or an escape', + 'Expected a scheme of the characters a header name holds, such as Bearer', + ].map((detail) => [{ pointer: '/credential/scheme', detail }]), + ); + }); +}); + +const withoutTheClient = { kind: 'client_credentials', issuer: billing.credential.issuer }; + +describe('client credentials without their client id', () => { + it('are taken, pending, and answered without it, since the person gives it on the page with the secret', async () => { + expect(await adding(harness(), { ...billing, credential: withoutTheClient })).toMatchObject({ + status: 'succeeded', + output: { status: 'pending', credential: withoutTheClient }, + }); + }); + + it('are read as the entry will be read once it is given, so an entry its reading refuses is refused', async () => { + const unaddressed = { name: 'billing', type: 'mcp', entry: {}, credential: withoutTheClient }; + + expect(await adding(harness(), unaddressed)).toMatchObject({ + status: 'rejected', + reason: 'invalid_input', + issues: [{ pointer: '/entry' }], + }); + }); +}); + +describe('a client id that looks like a credential', () => { + it('is refused at the field the agent wrote, saying that the person gives the secret on the page', async () => { + const credential = { ...billing.credential, client_id: 'sk-live-0123456789abcdef' }; + + expect(issuesIn(await adding(harness(), { ...billing, credential }))).toEqual([ + { + pointer: '/credential/client_id', + detail: + 'Looks like a credential, which a client id never is: leave the secret out, and the person gives it on the page the link opens', + }, ]); }); }); diff --git a/packages/connections/src/operations/connection-fields.ts b/packages/connections/src/operations/connection-fields.ts index a02d413ef..0a3753b84 100644 --- a/packages/connections/src/operations/connection-fields.ts +++ b/packages/connections/src/operations/connection-fields.ts @@ -1,20 +1,22 @@ import { Schema } from 'effect'; -import { ConnectionEntrySchema, ConnectionKindSchema } from '../registry/connection-entry.ts'; +import { ConnectionEntrySchema, ConnectionTypeSchema } from '../registry/connection-entry.ts'; import { ConnectionSchema } from '../registry/connection.ts'; import { CredentialSchema } from '../registry/credential.ts'; +export const ExpiresAtField = Schema.String.annotate({ description: 'When the link expires, in ISO 8601 UTC' }); + export const AddedSchema = Schema.Struct({ ...ConnectionSchema.fields, connect: Schema.Struct({ url: Schema.String.annotate({ description: "Studio's page, where the person approves the connection" }), - expires_at: Schema.String.annotate({ description: 'When the link expires, in ISO 8601 UTC' }), + expires_at: ExpiresAtField, }), }).annotate({ identifier: 'AddedConnection', description: 'The connection, pending, and the link that approves it' }); export const AdditionSchema = Schema.Struct({ name: Schema.String.annotate({ description: 'The name a function gives the server, in lowercase and hyphens' }), - kind: ConnectionKindSchema, + type: ConnectionTypeSchema, entry: ConnectionEntrySchema, credential: CredentialSchema, }); diff --git a/packages/connections/src/operations/registry-access.ts b/packages/connections/src/operations/registry-access.ts index be6784146..93a34a9b9 100644 --- a/packages/connections/src/operations/registry-access.ts +++ b/packages/connections/src/operations/registry-access.ts @@ -1,10 +1,11 @@ -import { Caller, OrgReader, OrgWriter, type NotFound } from '@on.auto/operations'; +import { Caller, OrgReader, OrgWriter, randomUUIDv7, type NotFound } from '@on.auto/operations'; import { DateTime, Effect } from 'effect'; -import type { ConnectionCommandData } from '../registry/connection-commands.ts'; +import type { AdditionInput } from '../entries/entry-checks.ts'; +import type { ConnectionAddition, ConnectionCommandData } from '../registry/connection-commands.ts'; import { connectionsStream, registryDecider } from '../registry/registry-decider.ts'; import { findConnection } from '../registry/registry-lookup.ts'; -import type { KeptConnection, Registry } from '../registry/registry.ts'; +import type { KeptConnection, OpenLink, Registry } from '../registry/registry.ts'; export const loadRegistry: Effect.Effect = Effect.gen(function* () { const { state } = yield* (yield* OrgReader).load(connectionsStream, registryDecider); @@ -20,6 +21,10 @@ export const actedNow = Effect.gen(function* () { return { by, at: DateTime.formatIso(yield* DateTime.now) }; }); +export function additionOf({ name, type, entry, credential }: AdditionInput, link: OpenLink): ConnectionAddition { + return { type: 'add', connection: randomUUIDv7(), name, server_type: type, entry, credential, link }; +} + export const recordInRegistry = Effect.fnUntraced(function* (data: ConnectionCommandData) { const acted = yield* actedNow; const { state } = yield* (yield* OrgWriter).execute(connectionsStream, registryDecider, { ...data, ...acted }); diff --git a/packages/connections/src/page/give-credential.test.ts b/packages/connections/src/page/give-credential.test.ts index 832086db7..ca36343b3 100644 --- a/packages/connections/src/page/give-credential.test.ts +++ b/packages/connections/src/page/give-credential.test.ts @@ -1,5 +1,8 @@ +import { Effect, Option } from 'effect'; import { describe, expect, it } from 'vitest'; +import { connectionPolicyOf } from '../policy/connection-policy.ts'; +import { makeConnectionSource } from '../serving/connection-source.ts'; import { added, billing, crm, issuesIn, notes } from '../testing/additions.ts'; import { ada, grace, harness, operationNamed, type Harness } from '../testing/harness.ts'; @@ -15,6 +18,7 @@ describe('giving the credential of a connection', () => { const given = await giving(h, { id, token, value: 'Bearer crm-0f3c' }); expect(given).toMatchObject({ status: 'succeeded', output: { id, status: 'connected' } }); + expect(given).not.toHaveProperty('output.link'); expect([...h.secrets.values()]).toEqual([['secret-1', 'Bearer crm-0f3c']]); const facts = await h.facts(); expect(facts.slice(2).map(({ type, data }) => [type, data])).toEqual([ @@ -121,3 +125,173 @@ describe('a credential given in the wrong fields', () => { expect(h.secrets.values().size).toBe(0); }); }); + +const crmOfItsScheme = { ...crm, credential: { ...crm.credential, scheme: 'Bearer' } }; + +describe('the secret of a header that names its scheme', () => { + it('is the token alone, kept alone, which the runtime writes after the scheme', async () => { + const h = harness(); + const { id, token } = await added(h, crmOfItsScheme); + + expect(await giving(h, { id, token, value: 'crm-0f3c' })).toMatchObject({ + status: 'succeeded', + output: { status: 'connected', credential: crmOfItsScheme.credential }, + }); + expect([...h.secrets.values()]).toEqual([['secret-1', 'crm-0f3c']]); + }); + + it('is refused at its place when it starts with the scheme and a space, in any case, and nothing is kept', async () => { + const h = harness(); + const { id, token } = await added(h, crmOfItsScheme); + + expect(issuesIn(await giving(h, { id, token, value: 'bearer crm-0f3c' }))).toEqual([ + { + pointer: '/value', + detail: 'Paste the token alone, without Bearer before it: the runtime writes Bearer itself', + }, + ]); + expect(h.secrets.values().size).toBe(0); + }); +}); + +const billingOfNoClient = { ...billing, credential: { kind: 'client_credentials', issuer: billing.credential.issuer } }; + +const clientIdLacking = { + pointer: '/client_id', + detail: + "Expected client_id: the connection's credential holds no client id, so it is given here with the secret or the key", +}; + +describe('client credentials whose client id the connection does not hold', () => { + it('take it beside the client secret, recorded with the connection and held in its credential from then on', async () => { + const h = harness(); + const { id, token } = await added(h, billingOfNoClient); + + const given = await giving(h, { id, token, client_id: 'acme', client_secret: 'billing-client-secret' }); + + expect(given).toMatchObject({ status: 'succeeded', output: { credential: billing.credential } }); + expect((await h.facts()).at(-1)?.data).toEqual({ connection: id, secret_id: 'secret-1', client_id: 'acme' }); + expect(await h.call(operationNamed(h.operations, 'get_connection'), ada, { id })).toMatchObject({ + output: { status: 'connected', credential: billing.credential }, + }); + }); + + it('take it beside a private key and its algorithm', async () => { + const h = harness(); + const { id, token } = await added(h, billingOfNoClient); + + await giving(h, { id, token, client_id: 'acme', private_key: '-----BEGIN PRIVATE KEY-----', algorithm: 'ES256' }); + + expect((await h.facts()).at(-1)?.data).toEqual({ + connection: id, + secret_id: 'secret-1', + algorithm: 'ES256', + client_id: 'acme', + }); + }); + + it('are refused without it, beside whatever else is missing, and nothing is kept', async () => { + const h = harness(); + const { id, token } = await added(h, billingOfNoClient); + + const refusals = [ + issuesIn(await giving(h, { id, token, client_secret: 'billing-client-secret' })), + issuesIn(await giving(h, { id, token })), + ]; + + expect(refusals).toEqual([ + [clientIdLacking], + [ + { pointer: '', detail: 'Expected client_secret, or private_key with its algorithm, one of the two' }, + clientIdLacking, + ], + ]); + expect(h.secrets.values().size).toBe(0); + }); +}); + +describe('a client id given where the connection holds one or takes none', () => { + it('is refused beside a secret, and beside client credentials that hold theirs, and nothing is kept', async () => { + const h = harness(); + const secret = await added(h, crm); + const client = await added(h, billing); + + const refusals = [ + issuesIn(await giving(h, { id: secret.id, token: secret.token, value: 'crm-0f3c', client_id: 'acme' })), + issuesIn(await giving(h, { id: client.id, token: client.token, client_secret: 'x', client_id: 'acme' })), + ]; + + expect(refusals).toEqual([ + [{ pointer: '', detail: 'Expected value alone, the secret of a credential of the kind secret' }], + [ + { + pointer: '/client_id', + detail: "Expected no client_id: the connection's credential holds its client id already", + }, + ], + ]); + expect(h.secrets.values().size).toBe(0); + }); +}); + +describe('a client id given on the page that looks like a credential', () => { + it('is refused at its place, saying where the secret goes, nothing is kept, and the link stays open', async () => { + const h = harness(); + const { id, token } = await added(h, billingOfNoClient); + const withTheSecret = { id, token, client_secret: 'billing-client-secret' }; + + expect(issuesIn(await giving(h, { ...withTheSecret, client_id: 'sk-live-0123456789abcdef' }))).toEqual([ + { + pointer: '/client_id', + detail: + "Looks like a credential, which a client id never is: the connection's record keeps the client id in the open, so the secret or the key goes in its own field, where it is sealed", + }, + ]); + expect(h.secrets.values().size).toBe(0); + expect(await giving(h, { ...withTheSecret, client_id: 'acme' })).toMatchObject({ + status: 'succeeded', + output: { status: 'connected', credential: billing.credential }, + }); + }); +}); + +async function authSentBy(h: Harness, id: string) { + const source = makeConnectionSource({ + registry: h.ledger.service, + secrets: h.secrets, + policy: connectionPolicyOf({}), + variables: new Map(), + modelProviders: ['openai'], + }); + const listed = await Effect.runPromise(source.connectionsOf('acme')); + const { state } = Option.getOrThrow(Option.fromNullishOr(listed.find((each) => each.id === id))); + return state.status === 'connected' ? (await Effect.runPromise(state.server)).settings.auth : null; +} + +describe('the client credentials a connection sends', () => { + it('are its issuer, its scope and the client id given on the page as they are written, a ${…} in them too', async () => { + const h = harness(); + const issuer = 'https://auth.billing.example.com/${NOPE:-acme}'; + const credential = { kind: 'client_credentials', issuer, scope: '${CONNECTION_CREDENTIAL}' }; + const { id, token } = await added(h, { ...billing, credential }); + + await giving(h, { id, token, client_id: '${BILLING_CLIENT_ID}', client_secret: 'billing-client-secret' }); + + expect(await authSentBy(h, id)).toMatchObject({ + issuer, + client_id: '${BILLING_CLIENT_ID}', + scope: '${CONNECTION_CREDENTIAL}', + }); + }); + + it('are the private key and the algorithm given beside it, the algorithm as it is written', async () => { + const h = harness(); + const { id, token } = await added(h, billing); + + await giving(h, { id, token, private_key: '-----BEGIN PRIVATE KEY-----', algorithm: '${NOPE:-ES256}' }); + + expect(await authSentBy(h, id)).toMatchObject({ + credential: { kind: 'private_key', algorithm: '${NOPE:-ES256}' }, + }); + }); +}); diff --git a/packages/connections/src/page/give-credential.ts b/packages/connections/src/page/give-credential.ts index 7a5a091c5..8bb515694 100644 --- a/packages/connections/src/page/give-credential.ts +++ b/packages/connections/src/page/give-credential.ts @@ -1,80 +1,18 @@ -import { allPermissions, defineHttpOperation, InvalidInput, type Conflict } from '@on.auto/operations'; -import { Effect, Redacted, Result, Schema } from 'effect'; +import { allPermissions, defineHttpOperation, OrgContext } from '@on.auto/operations'; +import { Effect, Redacted } from 'effect'; import { tokenSha256Of } from '../links/link-token.ts'; -import { ConnectionIdField, TokenField } from '../operations/connection-fields.ts'; import type { ConnectionServices } from '../operations/connection-services.ts'; import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; import { ConnectionSchema, connectionShown } from '../registry/connection.ts'; -import type { Credential } from '../registry/credential.ts'; -import { takesNoCredential } from '../registry/registry-decisions.ts'; import { linkedConnectionOf } from '../registry/registry-lookup.ts'; - -const SecretValue = Schema.String.check(Schema.isMinLength(1)); - -const GivingSchema = Schema.Struct({ - id: ConnectionIdField, - token: TokenField, - value: Schema.optionalKey(SecretValue.annotate({ description: 'The secret, for a credential of the kind secret' })), - client_secret: Schema.optionalKey(SecretValue.annotate({ description: 'The client secret of client credentials' })), - private_key: Schema.optionalKey( - SecretValue.annotate({ - description: 'The private key in PEM that signs the client assertion of client credentials', - }), - ), - algorithm: Schema.optionalKey( - Schema.String.annotate({ description: 'The signing algorithm of private_key, such as RS256 or ES256' }), - ), -}); - -type Giving = typeof GivingSchema.Type; - -interface Given { - readonly pointer: string; - readonly value: string; - readonly endsWith?: string; - readonly algorithm?: string; -} - -const charactersShown = 4; - -const fewestCharactersToShowAny = 12; - -function shownEndingOf(value: string): { readonly endsWith?: string } { - return value.length < fewestCharactersToShowAny ? {} : { endsWith: value.slice(-charactersShown) }; -} - -function refused(pointer: string, detail: string): InvalidInput { - return new InvalidInput({ detail: 'The credential cannot be taken as it is given', issues: [{ pointer, detail }] }); -} - -function secretOf({ value, client_secret, private_key, algorithm }: Giving): Result.Result { - return value !== undefined && [client_secret, private_key, algorithm].every((field) => field === undefined) - ? Result.succeed({ pointer: '/value', value, ...shownEndingOf(value) }) - : Result.fail(refused('', 'Expected value alone, the secret of a credential of the kind secret')); -} - -function clientCredentialsOf(giving: Giving): Result.Result { - const { value, client_secret: clientSecret, private_key: privateKey, algorithm } = giving; - if (value === undefined && clientSecret !== undefined && privateKey === undefined && algorithm === undefined) { - return Result.succeed({ pointer: '/client_secret', value: clientSecret, ...shownEndingOf(clientSecret) }); - } - return value === undefined && clientSecret === undefined && privateKey !== undefined && algorithm !== undefined - ? Result.succeed({ pointer: '/private_key', value: privateKey, algorithm }) - : Result.fail(refused('', 'Expected client_secret, or private_key with its algorithm, one of the two')); -} - -function givenOf(name: string, credential: Credential, giving: Giving): Result.Result { - if (credential.kind === 'secret') { - return secretOf(giving); - } - return credential.kind === 'client_credentials' ? clientCredentialsOf(giving) : Result.fail(takesNoCredential(name)); -} +import { GivingSchema, givenOf, refused } from './given-fields.ts'; export function defineGiveCredential({ secrets, + modelProviders, bindsPerson, -}: Pick & { readonly bindsPerson: boolean }) { +}: Pick & { readonly bindsPerson: boolean }) { return defineHttpOperation('org', 'command', { name: 'give_credential', title: 'Give credential', @@ -92,14 +30,20 @@ export function defineGiveCredential({ handle: Effect.fnUntraced(function* (giving) { const use = { connection: giving.id, token_sha256: tokenSha256Of(giving.token), bindsPerson }; const kept = yield* Effect.fromResult(linkedConnectionOf(yield* loadRegistry, use, yield* actedNow)); - const given = yield* Effect.fromResult(givenOf(kept.connection.name, kept.connection.credential, giving)); + const { org } = yield* OrgContext; + const given = yield* Effect.fromResult(givenOf(kept.connection, giving, { org, modelProviders })); const secretId = yield* secrets .keep(Redacted.make(given.value), given.endsWith) - .pipe(Effect.mapError(({ detail }) => refused(given.pointer, detail))); + .pipe(Effect.mapError(({ detail }) => refused([{ pointer: given.pointer, detail }]))); const algorithm = given.algorithm === undefined ? {} : { algorithm: given.algorithm }; - const connected = yield* recordInRegistry({ type: 'give', ...use, secret_id: secretId, ...algorithm }).pipe( - Effect.tapError(() => secrets.forget(secretId)), - ); + const client = given.clientId === undefined ? {} : { client_id: given.clientId }; + const connected = yield* recordInRegistry({ + type: 'give', + ...use, + secret_id: secretId, + ...algorithm, + ...client, + }).pipe(Effect.tapError(() => secrets.forget(secretId))); return connectionShown(connected.connection, given.endsWith ?? null); }), }); diff --git a/packages/connections/src/page/given-fields.ts b/packages/connections/src/page/given-fields.ts new file mode 100644 index 000000000..d834ae66e --- /dev/null +++ b/packages/connections/src/page/given-fields.ts @@ -0,0 +1,148 @@ +import { InvalidInput, type Conflict, type Issue } from '@on.auto/operations'; +import { Result, Schema } from 'effect'; + +import { clientIdReadingIssuesOf, type EntryChecking } from '../entries/entry-checks.ts'; +import { ConnectionIdField, TokenField } from '../operations/connection-fields.ts'; +import type { RecordedConnection } from '../registry/connection.ts'; +import type { Credential } from '../registry/credential.ts'; +import { takesNoCredential } from '../registry/registry-decisions.ts'; + +const GivenField = Schema.String.check(Schema.isMinLength(1)); + +export const GivingSchema = Schema.Struct({ + id: ConnectionIdField, + token: TokenField, + value: Schema.optionalKey(GivenField.annotate({ description: 'The secret, for a credential of the kind secret' })), + client_id: Schema.optionalKey( + GivenField.annotate({ + description: "The id of the client of client credentials, where the connection's credential holds none", + }), + ), + client_secret: Schema.optionalKey(GivenField.annotate({ description: 'The client secret of client credentials' })), + private_key: Schema.optionalKey( + GivenField.annotate({ + description: 'The private key in PEM that signs the client assertion of client credentials', + }), + ), + algorithm: Schema.optionalKey( + Schema.String.annotate({ description: 'The signing algorithm of private_key, such as RS256 or ES256' }), + ), +}); + +type Giving = typeof GivingSchema.Type; + +interface Given { + readonly pointer: string; + readonly value: string; + readonly endsWith?: string; + readonly algorithm?: string; + readonly clientId?: string; +} + +const charactersShown = 4; + +const fewestCharactersToShowAny = 12; + +function shownEndingOf(value: string): { readonly endsWith?: string } { + return value.length < fewestCharactersToShowAny ? {} : { endsWith: value.slice(-charactersShown) }; +} + +export function refused(issues: readonly Issue[]): InvalidInput { + return new InvalidInput({ detail: 'The credential cannot be taken as it is given', issues }); +} + +const valueAlone: Issue = { + pointer: '', + detail: 'Expected value alone, the secret of a credential of the kind secret', +}; + +function startsWithItsScheme(value: string, scheme: string | undefined): scheme is string { + return scheme !== undefined && value.toLowerCase().startsWith(`${scheme.toLowerCase()} `); +} + +function schemeWrittenAlready(scheme: string): Issue { + return { + pointer: '/value', + detail: `Paste the token alone, without ${scheme} before it: the runtime writes ${scheme} itself`, + }; +} + +function secretOf(giving: Giving, scheme: string | undefined): Result.Result { + const { value, client_id: clientId, client_secret: clientSecret, private_key: privateKey, algorithm } = giving; + if (value === undefined || [clientId, clientSecret, privateKey, algorithm].some((field) => field !== undefined)) { + return Result.fail(refused([valueAlone])); + } + return startsWithItsScheme(value, scheme) + ? Result.fail(refused([schemeWrittenAlready(scheme)])) + : Result.succeed({ pointer: '/value', value, ...shownEndingOf(value) }); +} + +function clientSecretOf(giving: Giving): Given | undefined { + const { value, client_secret: clientSecret, private_key: privateKey, algorithm } = giving; + if (value === undefined && clientSecret !== undefined && privateKey === undefined && algorithm === undefined) { + return { pointer: '/client_secret', value: clientSecret, ...shownEndingOf(clientSecret) }; + } + return value === undefined && clientSecret === undefined && privateKey !== undefined && algorithm !== undefined + ? { pointer: '/private_key', value: privateKey, algorithm } + : undefined; +} + +const clientIdLacking: Issue = { + pointer: '/client_id', + detail: + "Expected client_id: the connection's credential holds no client id, so it is given here with the secret or the key", +}; + +const clientIdHeldAlready: Issue = { + pointer: '/client_id', + detail: "Expected no client_id: the connection's credential holds its client id already", +}; + +function clientIdPresenceIssuesOf({ client_id: held }: Credential, { client_id: given }: Giving): readonly Issue[] { + if (held === undefined && given === undefined) { + return [clientIdLacking]; + } + return held !== undefined && given !== undefined ? [clientIdHeldAlready] : []; +} + +const neitherSecretNorKey: Issue = { + pointer: '', + detail: 'Expected client_secret, or private_key with its algorithm, one of the two', +}; + +function clientCredentialsOf(credential: Credential, giving: Giving): Result.Result { + const secret = clientSecretOf(giving); + const clientIdIssues = clientIdPresenceIssuesOf(credential, giving); + if (secret === undefined) { + return Result.fail(refused([neitherSecretNorKey, ...clientIdIssues])); + } + if (clientIdIssues.length > 0) { + return Result.fail(refused(clientIdIssues)); + } + return Result.succeed(giving.client_id === undefined ? secret : { ...secret, clientId: giving.client_id }); +} + +function withAReadableClientId( + connection: RecordedConnection, + given: Given, + checking: EntryChecking, +): Result.Result { + const issues = given.clientId === undefined ? [] : clientIdReadingIssuesOf(connection, given.clientId, checking); + return issues.length === 0 ? Result.succeed(given) : Result.fail(refused(issues)); +} + +export function givenOf( + connection: RecordedConnection, + giving: Giving, + checking: EntryChecking, +): Result.Result { + const { name, credential } = connection; + if (credential.kind === 'secret') { + return secretOf(giving, credential.scheme); + } + return credential.kind === 'client_credentials' + ? Result.flatMap(clientCredentialsOf(credential, giving), (given) => + withAReadableClientId(connection, given, checking), + ) + : Result.fail(takesNoCredential(name)); +} diff --git a/packages/connections/src/page/link-operations.test.ts b/packages/connections/src/page/link-operations.test.ts index bad656da7..6760d314a 100644 --- a/packages/connections/src/page/link-operations.test.ts +++ b/packages/connections/src/page/link-operations.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { connectionPolicyOf } from '../policy/connection-policy.ts'; -import { added, crm, notes } from '../testing/additions.ts'; +import { added, billing, crm, notes } from '../testing/additions.ts'; import { acmeViewer, ada, grace, harness, operationNamed, type Harness } from '../testing/harness.ts'; const elevenMinutesLater = '2026-10-15T14:11:00.000Z'; @@ -21,11 +21,25 @@ const noOpenLink = { }; describe('reading a link', () => { - it('answers the connection it names to any caller of the org who holds its token', async () => { + it('answers the connection it names, and when the link expires, to any caller of the org who holds its token', async () => { const h = harness(); - const { id, token } = await added(h, crm); + const { id, token, expiresAt } = await added(h, crm); + + expect(await reading(h, id, token, '2026-10-15T14:03:00.000Z')).toMatchObject({ + status: 'succeeded', + output: { id, status: 'pending', link: { expires_at: expiresAt } }, + }); + }); + + it('answers the credential of client credentials without a client id where the person gives it on the page', async () => { + const h = harness(); + const credential = { kind: 'client_credentials', issuer: 'https://auth.billing.example.com' }; + const { id, token } = await added(h, { ...billing, credential }); + + const read = await reading(h, id, token); - expect(await reading(h, id, token)).toMatchObject({ status: 'succeeded', output: { id, status: 'pending' } }); + expect(read).toMatchObject({ output: { credential } }); + expect(read).not.toHaveProperty('output.credential.client_id'); }); it('answers not found for a token that is unknown, of another connection or expired', async () => { @@ -57,6 +71,7 @@ describe('approving a connection', () => { output: { id, status: 'connected', connected_at: '2026-10-15T14:00:00.000Z' }, }); expect(await approving(h, reference.id, reference.token)).toMatchObject({ output: { status: 'connected' } }); + expect(approved).not.toHaveProperty('output.link'); expect(await approving(h, id, token)).toEqual(noOpenLink); expect((await h.facts()).slice(4, 6).map(({ type, data }) => [type, data])).toEqual([ ['connection_approved', { connection: id }], diff --git a/packages/connections/src/page/link-operations.ts b/packages/connections/src/page/link-operations.ts index d2241067b..8ff34270a 100644 --- a/packages/connections/src/page/link-operations.ts +++ b/packages/connections/src/page/link-operations.ts @@ -2,7 +2,7 @@ import { allPermissions, defineHttpOperation } from '@on.auto/operations'; import { Effect, Schema } from 'effect'; import { tokenSha256Of } from '../links/link-token.ts'; -import { ConnectionIdField, TokenField } from '../operations/connection-fields.ts'; +import { ConnectionIdField, ExpiresAtField, TokenField } from '../operations/connection-fields.ts'; import { actedNow, loadRegistry, recordInRegistry } from '../operations/registry-access.ts'; import { ConnectionSchema, connectionShown } from '../registry/connection.ts'; import { linkedConnectionOf } from '../registry/registry-lookup.ts'; @@ -11,11 +11,19 @@ const LinkSchema = Schema.Struct({ id: ConnectionIdField, token: TokenField }); const answeringTheRecord = { kind: 'json', outputSchema: ConnectionSchema } as const; +const LinkedSchema = Schema.Struct({ + ...ConnectionSchema.fields, + link: Schema.Struct({ expires_at: ExpiresAtField }), +}).annotate({ + identifier: 'LinkedConnection', + description: 'The connection an open link names, and when the link expires', +}); + export const readLink = defineHttpOperation('org', 'query', { name: 'read_link', title: 'Read link', description: [ - "Reads the connection an open link names, so Studio's page can show what the person is asked to approve.", + "Reads the connection an open link names, and when the link expires, so Studio's page can show what the person is asked to approve and for how long.", 'A link is open for ten minutes from when add_connection gave it, until the connection is approved.', 'A token that is unknown, used up or expired is answered as not found.', ].join(' '), @@ -23,7 +31,7 @@ export const readLink = defineHttpOperation('org', 'query', { body: { kind: 'none' }, permittedBy: allPermissions, inputSchema: LinkSchema, - answer: answeringTheRecord, + answer: { kind: 'json', outputSchema: LinkedSchema }, reasons: ['not_found'], handle: Effect.fnUntraced(function* ({ id, token }) { const registry = yield* loadRegistry; @@ -31,7 +39,7 @@ export const readLink = defineHttpOperation('org', 'query', { const kept = yield* Effect.fromResult(linkedConnectionOf(registry, use, yield* actedNow)).pipe( Effect.catchTag('forbidden', Effect.die), ); - return connectionShown(kept.connection, null); + return { ...connectionShown(kept.connection, null), link: { expires_at: kept.link.expires_at } }; }), }); diff --git a/packages/connections/src/policy/policy-rules.ts b/packages/connections/src/policy/policy-rules.ts index e52d627f7..ce40f0753 100644 --- a/packages/connections/src/policy/policy-rules.ts +++ b/packages/connections/src/policy/policy-rules.ts @@ -1,14 +1,14 @@ import { isSignedInPerson } from '@on.auto/identity'; import { Forbidden, listed, type CallerIdentity } from '@on.auto/operations'; -import type { ConnectionEntry, ConnectionKind } from '../registry/connection-entry.ts'; +import type { ConnectionEntry, ConnectionType } from '../registry/connection-entry.ts'; import type { Credential } from '../registry/credential.ts'; import type { ConnectionPolicy } from './connection-policy.ts'; export type RuntimeAccess = 'people' | 'keys' | 'local'; export interface Addition { - readonly kind: ConnectionKind; + readonly type: ConnectionType; readonly entry: ConnectionEntry; readonly credential: Credential; } @@ -22,8 +22,8 @@ function matches(pattern: string, host: string): boolean { return lowered.startsWith('*.') ? host.endsWith(lowered.slice(1)) : host === lowered; } -function hostsOf({ kind, entry, credential }: Addition): readonly string[] { - const addresses = [...(kind === 'mcp' ? [entry.url] : [entry.document, entry.base_url]), credential.issuer]; +function hostsOf({ type, entry, credential }: Addition): readonly string[] { + const addresses = [...(type === 'mcp' ? [entry.url] : [entry.document, entry.base_url]), credential.issuer]; return addresses.filter((address) => address !== undefined).map((address) => new URL(address).hostname); } diff --git a/packages/connections/src/registry/connection-commands.ts b/packages/connections/src/registry/connection-commands.ts index 9470a6609..42fc2cbc1 100644 --- a/packages/connections/src/registry/connection-commands.ts +++ b/packages/connections/src/registry/connection-commands.ts @@ -1,4 +1,4 @@ -import type { ConnectionEntry, ConnectionKind } from './connection-entry.ts'; +import type { ConnectionEntry, ConnectionType } from './connection-entry.ts'; import type { Credential } from './credential.ts'; import type { OpenLink } from './registry.ts'; @@ -6,7 +6,7 @@ export interface ConnectionAddition { readonly type: 'add'; readonly connection: string; readonly name: string; - readonly kind: ConnectionKind; + readonly server_type: ConnectionType; readonly entry: ConnectionEntry; readonly credential: Credential; readonly link: OpenLink; @@ -26,6 +26,7 @@ export interface CredentialGiving extends LinkUse { readonly type: 'give'; readonly secret_id: string; readonly algorithm?: string; + readonly client_id?: string; } export interface ConnectionRemoval { diff --git a/packages/connections/src/registry/connection-entry.ts b/packages/connections/src/registry/connection-entry.ts index ec17ebb92..56645d88e 100644 --- a/packages/connections/src/registry/connection-entry.ts +++ b/packages/connections/src/registry/connection-entry.ts @@ -1,16 +1,16 @@ import { ServerEntrySchema } from '@on.auto/tool-servers'; import { Schema } from 'effect'; -const { url, document, base_url, headers, query_params, allowed, playbooks, record_content, request_id } = +const { type, url, document, base_url, headers, query_params, allowed, playbooks, record_content, request_id } = ServerEntrySchema.fields; const neverASecret = 'never a secret or a reference'; -export const ConnectionKindSchema = Schema.Literals(['mcp', 'openapi']).annotate({ +export const ConnectionTypeSchema = type.schema.pick(['mcp', 'openapi']).annotate({ description: 'mcp for a remote MCP server, given url; openapi for an HTTP API, given document', }); -export type ConnectionKind = typeof ConnectionKindSchema.Type; +export type ConnectionType = typeof ConnectionTypeSchema.Type; export const ConnectionEntrySchema = Schema.Struct({ url: url.annotate({ description: 'The https URL of the MCP server' }), diff --git a/packages/connections/src/registry/connection-events.ts b/packages/connections/src/registry/connection-events.ts index 9abf44708..028046356 100644 --- a/packages/connections/src/registry/connection-events.ts +++ b/packages/connections/src/registry/connection-events.ts @@ -1,7 +1,7 @@ import { ActedContextSchema, factOf, type Decided } from '@on.auto/operations'; import { Schema } from 'effect'; -import { ConnectionEntrySchema, ConnectionKindSchema } from './connection-entry.ts'; +import { ConnectionEntrySchema, ConnectionTypeSchema } from './connection-entry.ts'; import { CredentialSchema } from './credential.ts'; const ofTheConnection = { connection: Schema.String }; @@ -12,7 +12,7 @@ export const ConnectionEventSchema = Schema.Union([ Schema.Struct({ ...ofTheConnection, name: Schema.String, - kind: ConnectionKindSchema, + server_type: ConnectionTypeSchema, entry: ConnectionEntrySchema, credential: CredentialSchema, }), @@ -30,6 +30,7 @@ export const ConnectionEventSchema = Schema.Union([ ...ofTheConnection, secret_id: Schema.optionalKey(Schema.String), algorithm: Schema.optionalKey(Schema.String), + client_id: Schema.optionalKey(Schema.String), }), ActedContextSchema, ), diff --git a/packages/connections/src/registry/connection.ts b/packages/connections/src/registry/connection.ts index 481f08abb..9db88f381 100644 --- a/packages/connections/src/registry/connection.ts +++ b/packages/connections/src/registry/connection.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -import { ConnectionEntrySchema, ConnectionKindSchema } from './connection-entry.ts'; +import { ConnectionEntrySchema, ConnectionTypeSchema } from './connection-entry.ts'; import { CredentialSchema } from './credential.ts'; const ConnectionStatusSchema = Schema.Literals(['pending', 'connected', 'broken', 'removed']); @@ -8,7 +8,7 @@ const ConnectionStatusSchema = Schema.Literals(['pending', 'connected', 'broken' export const ConnectionSchema = Schema.Struct({ id: Schema.String.annotate({ description: 'The id of the connection, a UUIDv7' }), name: Schema.String.annotate({ description: 'The name a function gives the connection in server/tool' }), - kind: ConnectionKindSchema, + type: ConnectionTypeSchema, entry: ConnectionEntrySchema, credential: CredentialSchema, status: ConnectionStatusSchema.annotate({ diff --git a/packages/connections/src/registry/credential.ts b/packages/connections/src/registry/credential.ts index 218b2bf3e..99ad17e9b 100644 --- a/packages/connections/src/registry/credential.ts +++ b/packages/connections/src/registry/credential.ts @@ -29,10 +29,19 @@ const CredentialFieldsSchema = Schema.Struct({ 'For secret and reference, what name is: header, or for an HTTP API query, a query parameter, or security, a security scheme of its document', }), ), + scheme: Schema.optionalKey( + NamedSchema.annotate({ + description: 'For a header, the scheme written before the value, such as Bearer', + }), + ), issuer: Schema.optionalKey( Schema.String.annotate({ description: 'For client_credentials, the https URL of the authorization server' }), ), - client_id: Schema.optionalKey(NamedSchema.annotate({ description: 'For client_credentials, the id of the client' })), + client_id: Schema.optionalKey( + NamedSchema.annotate({ + description: 'For client_credentials, the id of the client, else given on the page', + }), + ), scope: Schema.optionalKey( Schema.String.annotate({ description: 'For client_credentials, the scopes, separated by spaces; none when left out', @@ -44,7 +53,16 @@ type CredentialFields = typeof CredentialFieldsSchema.Type; type Field = Exclude; -const everyField: readonly Field[] = ['variable', 'name', 'in', 'issuer', 'client_id', 'scope']; +type Placement = NonNullable; + +const everyFieldButTheScheme: readonly Exclude[] = [ + 'variable', + 'name', + 'in', + 'issuer', + 'client_id', + 'scope', +]; interface TakenFields { readonly required: readonly Field[]; @@ -53,9 +71,14 @@ interface TakenFields { const fieldsOfEachKind: Readonly> = { none: { required: [], optional: [] }, - reference: { required: ['variable', 'name', 'in'], optional: [] }, - secret: { required: ['name', 'in'], optional: [] }, - client_credentials: { required: ['issuer', 'client_id'], optional: ['scope'] }, + reference: { required: ['variable', 'name', 'in'], optional: ['scheme'] }, + secret: { required: ['name', 'in'], optional: ['scheme'] }, + client_credentials: { required: ['issuer'], optional: ['client_id', 'scope'] }, +}; + +const placesOtherThanAHeader: Readonly, string>> = { + query: 'a query parameter', + security: 'a security scheme of the document, which says how the value is sent', }; interface FieldIssue { @@ -71,7 +94,7 @@ function fieldsTakenInWords({ required, optional }: TakenFields): string { function presenceIssuesOf(credential: CredentialFields): readonly FieldIssue[] { const taken = fieldsOfEachKind[credential.kind]; const { required, optional } = taken; - return everyField.flatMap((field): readonly FieldIssue[] => { + return everyFieldButTheScheme.flatMap((field): readonly FieldIssue[] => { const given = credential[field] !== undefined; if (!given && required.includes(field)) { return [{ path: [field], issue: `Expected ${field}, which a ${credential.kind} credential takes` }]; @@ -99,6 +122,26 @@ function nameIssuesOf({ name, in: placement }: CredentialFields): readonly Field : []; } +function schemeIssueOf({ kind, in: placement }: CredentialFields, scheme: string): string | undefined { + if (!fieldsOfEachKind[kind].optional.includes('scheme')) { + return `Expected no scheme: a ${kind} credential places no value in a header for the runtime to write a scheme before`; + } + if (placement !== undefined && placement !== 'header') { + return `Expected no scheme, which goes before a value sent in a header: this credential is sent in ${placesOtherThanAHeader[placement]}`; + } + if (scheme.includes('$')) { + return 'Expected a scheme without $, which the reading of the entry takes for a reference or an escape'; + } + return headerNamePattern.test(scheme) + ? undefined + : 'Expected a scheme of the characters a header name holds, such as Bearer'; +} + +function schemeIssuesOf(credential: CredentialFields): readonly FieldIssue[] { + const issue = credential.scheme === undefined ? undefined : schemeIssueOf(credential, credential.scheme); + return issue === undefined ? [] : [{ path: ['scheme'], issue }]; +} + function issuerIssuesOf({ kind, issuer }: CredentialFields): readonly FieldIssue[] { return kind !== 'client_credentials' || issuer === undefined || isPinnableUrl(issuer) ? [] @@ -112,6 +155,7 @@ export const CredentialSchema = CredentialFieldsSchema.annotate({ ...presenceIssuesOf(credential), ...variableIssuesOf(credential), ...nameIssuesOf(credential), + ...schemeIssuesOf(credential), ...issuerIssuesOf(credential), ]), ); diff --git a/packages/connections/src/registry/registry-decider.test.ts b/packages/connections/src/registry/registry-decider.test.ts index 9ee1e626d..e9092dba8 100644 --- a/packages/connections/src/registry/registry-decider.test.ts +++ b/packages/connections/src/registry/registry-decider.test.ts @@ -16,7 +16,7 @@ const crmAdded: ConnectionEvent = { data: { connection: 'crm-id', name: 'crm', - kind: 'mcp', + server_type: 'mcp', entry: { url: 'https://crm.example.com/mcp' }, credential: { kind: 'none' }, }, @@ -94,7 +94,7 @@ function addedAs(index: number): ConnectionEvent { data: { connection: `id-${index}`, name: `crm-${index}`, - kind: 'mcp', + server_type: 'mcp', entry: { url: 'https://crm.example.com/mcp' }, credential: { kind: 'none' }, }, @@ -108,7 +108,7 @@ describe('adding a connection to an org that holds the most it may', () => { type: 'add', connection: 'wiki-id', name: 'wiki', - kind: 'mcp', + server_type: 'mcp', entry: { url: 'https://wiki.example.com/mcp' }, credential: { kind: 'none' }, link: { token_sha256: 'c3d4', expires_at: '2026-10-15T14:10:00.000Z' }, diff --git a/packages/connections/src/registry/registry-decisions.ts b/packages/connections/src/registry/registry-decisions.ts index c4c22f694..e69f7b4ec 100644 --- a/packages/connections/src/registry/registry-decisions.ts +++ b/packages/connections/src/registry/registry-decisions.ts @@ -31,7 +31,7 @@ export function takesNoCredential(name: string): Conflict { } function decideAddition( - { connection, name, kind, entry, credential, link }: ConnectionAddition, + { connection, name, server_type: serverType, entry, credential, link }: ConnectionAddition, registry: Registry, ): Decision { const kept = [...registry.values()].filter((each) => each.connection.status !== 'removed'); @@ -46,7 +46,7 @@ function decideAddition( }), ) : Result.succeed([ - { type: 'connection_added', data: { connection, name, kind, entry, credential } }, + { type: 'connection_added', data: { connection, name, server_type: serverType, entry, credential } }, { type: 'connection_link_issued', data: { connection, ...link } }, ]); } @@ -67,15 +67,14 @@ function decideApproval(command: ConnectionApproval & CommandMetadata, registry: } function decideGiving(command: CredentialGiving & CommandMetadata, registry: Registry): Decision { - const { connection, secret_id: secretId, algorithm } = command; + const { connection, secret_id: secretId, algorithm, client_id: clientId } = command; + const signing = algorithm === undefined ? {} : { algorithm }; + const client = clientId === undefined ? {} : { client_id: clientId }; return Result.flatMap(linkedConnectionOf(registry, command, command), (kept) => takesAValue(kept) ? Result.succeed([ { type: 'connection_approved', data: { connection } }, - { - type: 'connection_connected', - data: { connection, secret_id: secretId, ...(algorithm === undefined ? {} : { algorithm }) }, - }, + { type: 'connection_connected', data: { connection, secret_id: secretId, ...signing, ...client } }, ]) : Result.fail(takesNoCredential(kept.connection.name)), ); diff --git a/packages/connections/src/registry/registry-lookup.ts b/packages/connections/src/registry/registry-lookup.ts index 7f92e1c3c..05a3e41ac 100644 --- a/packages/connections/src/registry/registry-lookup.ts +++ b/packages/connections/src/registry/registry-lookup.ts @@ -2,7 +2,11 @@ import { Forbidden, NotFound } from '@on.auto/operations'; import { Effect, Result } from 'effect'; import type { LinkUse } from './connection-commands.ts'; -import type { KeptConnection, Registry } from './registry.ts'; +import type { KeptConnection, OpenLink, Registry } from './registry.ts'; + +export interface KeptWithLink extends KeptConnection { + readonly link: OpenLink; +} function connectionNotFound(id: string): NotFound { return new NotFound({ detail: `There is no connection ${id} in this org` }); @@ -19,7 +23,8 @@ export function findConnection(registry: Registry, id: string): Effect.Effect at ); @@ -29,10 +34,10 @@ export function linkedConnectionOf( registry: Registry, { connection, token_sha256: tokenSha256, bindsPerson }: LinkUse, { by, at }: { readonly by: string; readonly at: string }, -): Result.Result { +): Result.Result { return Result.flatMap( keptConnectionOf(registry, connection), - (kept): Result.Result => { + (kept): Result.Result => { if (!isOpen(kept, tokenSha256, at)) { return Result.fail(noOpenLink); } diff --git a/packages/connections/src/registry/registry.test.ts b/packages/connections/src/registry/registry.test.ts index 88d424ca6..2a81c8601 100644 --- a/packages/connections/src/registry/registry.test.ts +++ b/packages/connections/src/registry/registry.test.ts @@ -14,7 +14,7 @@ const later: ActedContext = { by: 'person:grace', at: '2026-10-15T14:05:00.000Z' const crm = { connection: '0199-crm', name: 'crm', - kind: 'mcp', + server_type: 'mcp', entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' } }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, } as const; @@ -51,7 +51,7 @@ describe('an added connection, as the stream folds it', () => { connection: { id: '0199-crm', name: 'crm', - kind: 'mcp', + type: 'mcp', entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' } }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, status: 'pending', @@ -113,3 +113,34 @@ describe('the registry of connections', () => { expect(registry.has('nowhere')).toBe(false); }); }); + +const issuer = 'https://auth.billing.example.com'; + +function credentialAfter(...events: readonly ConnectionEvent[]) { + const registry = events.reduce((state, event) => evolvedWith(state, event, later), registryDecider.initialState); + return registry.get('0199-billing')?.connection.credential; +} + +describe('a connection of client credentials whose client id is given on the page, as the stream folds it', () => { + it('holds no client id while it is pending, and the one its connection names once it is connected', () => { + const billing: ConnectionEvent = { + type: 'connection_added', + data: { + connection: '0199-billing', + name: 'billing', + server_type: 'openapi', + entry: { document: 'https://billing.example.com/openapi.json' }, + credential: { kind: 'client_credentials', issuer }, + }, + }; + const connectedWithItsClient: ConnectionEvent = { + type: 'connection_connected', + data: { connection: '0199-billing', secret_id: 'secret-2', client_id: 'acme' }, + }; + + expect([credentialAfter(billing), credentialAfter(billing, connectedWithItsClient)]).toEqual([ + { kind: 'client_credentials', issuer }, + { kind: 'client_credentials', issuer, client_id: 'acme' }, + ]); + }); +}); diff --git a/packages/connections/src/registry/registry.ts b/packages/connections/src/registry/registry.ts index c92ea8cb3..9ab924070 100644 --- a/packages/connections/src/registry/registry.ts +++ b/packages/connections/src/registry/registry.ts @@ -6,6 +6,7 @@ import type { RecordedConnectionEvent, } from './connection-events.ts'; import type { RecordedConnection } from './connection.ts'; +import type { Credential } from './credential.ts'; export interface OpenLink { readonly token_sha256: string; @@ -24,12 +25,12 @@ export type Registry = ReadonlyMap; export const initialRegistry: Registry = new Map(); function addedConnection({ data, context }: ConnectionAdded): KeptConnection { - const { connection: id, name, kind, entry, credential } = data; + const { connection: id, name, server_type: type, entry, credential } = data; return { connection: { id, name, - kind, + type, entry, credential, status: 'pending', @@ -49,10 +50,16 @@ function withLink(kept: KeptConnection, { data }: LinkIssued): KeptConnection { return { ...kept, link: { token_sha256: data.token_sha256, expires_at: data.expires_at } }; } -function connected(kept: KeptConnection, { data, context }: ConnectionConnected): KeptConnection { +function credentialOf({ connection }: KeptConnection, { data }: ConnectionConnected): Credential { + return data.client_id === undefined ? connection.credential : { ...connection.credential, client_id: data.client_id }; +} + +function connected(kept: KeptConnection, event: ConnectionConnected): KeptConnection { + const { data, context } = event; return { connection: { ...kept.connection, + credential: credentialOf(kept, event), status: 'connected', connected_at: context.at, connected_by: context.by, diff --git a/packages/connections/src/serving/connection-source.ts b/packages/connections/src/serving/connection-source.ts index 7eab0a157..1b0f0ea6d 100644 --- a/packages/connections/src/serving/connection-source.ts +++ b/packages/connections/src/serving/connection-source.ts @@ -17,8 +17,8 @@ import { newestFirst, type KeptConnection } from '../registry/registry.ts'; import { givenOf, notAllowed, type Serving, type ServingServices } from './given-credentials.ts'; import { servingWords } from './serving-words.ts'; -function servingOf(org: string, { name, kind, entry }: KeptConnection['connection']): ConnectionServing { - return { name, type: kind, orgs: [org], ...servedFieldsOf(entry) }; +function servingOf(org: string, { name, type, entry }: KeptConnection['connection']): ConnectionServing { + return { name, type, orgs: [org], ...servedFieldsOf(entry) }; } function unchecked(name: string, issues: readonly Issue[]): ConnectionUnready { diff --git a/packages/connections/src/serving/placed-credentials.ts b/packages/connections/src/serving/placed-credentials.ts index 80f79aa99..afedf888e 100644 --- a/packages/connections/src/serving/placed-credentials.ts +++ b/packages/connections/src/serving/placed-credentials.ts @@ -1,7 +1,7 @@ import { readEntry, type Environment, type ServerEntryFields } from '@on.auto/tool-servers'; import { Option, Redacted, Schema } from 'effect'; -import { ConnectionEntrySchema, type ConnectionEntry, type ConnectionKind } from '../registry/connection-entry.ts'; +import { ConnectionEntrySchema, type ConnectionEntry, type ConnectionType } from '../registry/connection-entry.ts'; import type { Credential } from '../registry/credential.ts'; export interface GivenValue { @@ -32,9 +32,13 @@ const asJson = Schema.decodeUnknownSync(Schema.Json); const asEntry = Schema.decodeUnknownSync(ConnectionEntrySchema); +function literalText(text: string): string { + return text.replaceAll('$', () => '$$'); +} + function literal(value: Schema.Json): Schema.Json { if (typeof value === 'string') { - return value.replaceAll('$', () => '$$'); + return literalText(value); } if (Array.isArray(value)) { return value.map((item: Schema.Json) => literal(item)); @@ -56,12 +60,13 @@ function variableOf(credential: Credential): string { return credential.kind === 'reference' ? required(credential.variable) : givenVariable; } +function writtenValueOf(credential: Credential): string { + const reference = referenceTo(variableOf(credential)); + return credential.scheme === undefined ? reference : `${credential.scheme} ${reference}`; +} + function placedValue(entry: ConnectionEntry, credential: Credential): ServerEntryFields { - const fields = placedIn[required(credential.in)]( - entry, - required(credential.name), - referenceTo(variableOf(credential)), - ); + const fields = placedIn[required(credential.in)](entry, required(credential.name), writtenValueOf(credential)); return { ...entry, ...fields }; } @@ -70,9 +75,10 @@ function placedClientCredentials(entry: ConnectionEntry, credential: Credential, const secret = given.algorithm === undefined ? { client_secret: reference } - : { private_key: reference, algorithm: given.algorithm }; - const scope = credential.scope === undefined ? {} : { scope: credential.scope }; - const auth = { issuer: required(credential.issuer), client_id: required(credential.client_id), ...scope, ...secret }; + : { private_key: reference, algorithm: literalText(given.algorithm) }; + const scope = credential.scope === undefined ? {} : { scope: literalText(credential.scope) }; + const issuer = literalText(required(credential.issuer)); + const auth = { issuer, client_id: literalText(required(credential.client_id)), ...scope, ...secret }; return { ...entry, auth }; } @@ -93,7 +99,7 @@ export function placedCredential( interface Named { readonly name: string; - readonly kind: ConnectionKind; + readonly type: ConnectionType; } interface Reading { @@ -101,8 +107,8 @@ interface Reading { readonly modelProviders: readonly string[]; } -export function readPlaced({ name, kind }: Named, { written, environment }: Placed, { org, modelProviders }: Reading) { - return readEntry(name, { ...written, type: kind, org }, environment, { +export function readPlaced({ name, type }: Named, { written, environment }: Placed, { org, modelProviders }: Reading) { + return readEntry(name, { ...written, type, org }, environment, { modelProviders, documentsDirectory: '.', }); diff --git a/packages/connections/src/serving/served-behaviour.test.ts b/packages/connections/src/serving/served-behaviour.test.ts index 7ff02d4c4..e00406e80 100644 --- a/packages/connections/src/serving/served-behaviour.test.ts +++ b/packages/connections/src/serving/served-behaviour.test.ts @@ -1,4 +1,5 @@ import { + behaviourKey, describedAccess, serversUnderTest, toolAccessBehaviour, @@ -42,6 +43,37 @@ function givenOnThePage(credential: CredentialUnderTest): Placing { return { credential: { kind: 'secret', name, in: placement }, giving: { value }, variables }; } +function isByClientCredentials(credential: CredentialUnderTest): credential is Exclude { + return credential.in === 'auth'; +} + +function givenAfterItsScheme(credential: CredentialUnderTest): Placing { + const { name } = Option.getOrThrow(Option.liftPredicate(credential, isSentIn)); + return { + credential: { kind: 'secret', name, in: 'header', scheme: 'Bearer' }, + giving: { value: behaviourKey }, + variables: new Map(), + }; +} + +function referencedAfterItsScheme(credential: CredentialUnderTest): Placing { + const { name } = Option.getOrThrow(Option.liftPredicate(credential, isSentIn)); + return { + credential: { kind: 'reference', variable, name, in: 'header', scheme: 'Bearer' }, + giving: undefined, + variables: new Map([[variable, Redacted.make(behaviourKey)]]), + }; +} + +function givenWithItsClientId(credential: CredentialUnderTest): Placing { + const { issuer, clientId, value } = Option.getOrThrow(Option.liftPredicate(credential, isByClientCredentials)); + return { + credential: { kind: 'client_credentials', issuer }, + giving: { client_id: clientId, client_secret: value }, + variables: new Map(), + }; +} + function byReference(credential: CredentialUnderTest): Placing { const { name, in: placement, value } = Option.getOrThrow(Option.liftPredicate(credential, isSentIn)); return { @@ -57,7 +89,7 @@ async function connectedThrough( ) { const h = harness({ policy }); const placing = placingOf(credential); - const { id, token } = await added(h, { name: 'graph', kind: type, entry: bare, credential: placing.credential }); + const { id, token } = await added(h, { name: 'graph', type, entry: bare, credential: placing.credential }); const giving = placing.giving === undefined ? 'approve' : 'give_credential'; await h.call(operationNamed(h.operations, giving), ada, { id, token, ...placing.giving }); return makeConnectionSource({ @@ -89,3 +121,21 @@ for (const underTest of serversUnderTest) { for (const underTest of serversUnderTest.filter(({ kind }) => !kind.includes('client credentials'))) { toolAccessBehaviour(underTest, referenced); } + +const afterItsScheme = aConnection('a connection whose header names its scheme', givenAfterItsScheme); + +const referencedAfterIt = aConnection( + 'a connection of a reference whose header names its scheme', + referencedAfterItsScheme, +); + +for (const underTest of serversUnderTest.filter(({ kind }) => kind === 'an MCP server')) { + toolAccessBehaviour(underTest, afterItsScheme); + toolAccessBehaviour(underTest, referencedAfterIt); +} + +const withItsClientId = aConnection('a connection given its client id on the page', givenWithItsClientId); + +for (const underTest of serversUnderTest.filter(({ kind }) => kind.includes('client credentials'))) { + toolAccessBehaviour(underTest, withItsClientId); +} diff --git a/packages/connections/src/testing/additions.ts b/packages/connections/src/testing/additions.ts index 1b9522aee..01c42f0b0 100644 --- a/packages/connections/src/testing/additions.ts +++ b/packages/connections/src/testing/additions.ts @@ -5,7 +5,7 @@ import { ada, operationNamed, type Harness } from './harness.ts'; export const crm = { name: 'crm', - kind: 'mcp', + type: 'mcp', entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' }, @@ -17,14 +17,14 @@ export const crm = { export const billing = { name: 'billing', - kind: 'openapi', + type: 'openapi', entry: { document: 'https://billing.example.com/openapi.json', base_url: 'https://api.billing.example.com/v2' }, credential: { kind: 'client_credentials', issuer: 'https://auth.billing.example.com', client_id: 'acme' }, } as const; export const notes = { name: 'notes', - kind: 'mcp', + type: 'mcp', entry: { url: 'https://notes.example.com/mcp' }, credential: { kind: 'none' }, } as const; diff --git a/packages/server/src/connections/connections-over-http.test.ts b/packages/server/src/connections/connections-over-http.test.ts index 9006ca564..95348d570 100644 --- a/packages/server/src/connections/connections-over-http.test.ts +++ b/packages/server/src/connections/connections-over-http.test.ts @@ -9,7 +9,7 @@ const connections = '/v1/orgs/local/connections'; const crm = { name: 'crm', - kind: 'mcp', + type: 'mcp', entry: { url: 'https://crm.example.com/mcp', headers: { 'X-Tenant': 'acme-eu' } }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, }; @@ -44,7 +44,7 @@ async function added(served: ReasoningServer, body: unknown = crm) { const { token } = Schema.decodeUnknownSync(Schema.Struct({ token: Schema.String }))( Object.fromEntries(page.searchParams), ); - return { answer, id, page, token }; + return { answer, id, page, token, expiresAt: connect.expires_at }; } describe('adding a connection over HTTP in local mode', () => { @@ -62,9 +62,9 @@ describe('adding a connection over HTTP in local mode', () => { expect(`${page.origin}${page.pathname}`).toBe('https://studio.on.auto/connect'); }); - it('lists the connection and reads it, and reads it through its link', async () => { + it('lists the connection and reads it, and reads it through its link with when the link expires', async () => { const served = await serving(); - const { id, token } = await added(served); + const { id, token, expiresAt } = await added(served); const [listed, read, linked] = [ await served.call('GET', connections), @@ -72,8 +72,11 @@ describe('adding a connection over HTTP in local mode', () => { await served.call('GET', `${connections}/${id}/link/${token}`), ]; - expect(listed).toMatchObject({ status: 200, body: { connections: [{ id, name: 'crm' }] } }); - expect([read.body, linked.body]).toMatchObject([{ id }, { id, status: 'pending' }]); + expect(listed).toMatchObject({ status: 200, body: { connections: [{ id, name: 'crm', type: 'mcp' }] } }); + expect([read.body, linked.body]).toMatchObject([ + { id }, + { id, status: 'pending', link: { expires_at: expiresAt } }, + ]); expect((await served.call('GET', `${connections}/${id}/link/unknown`)).status).toBe(404); }); }); diff --git a/packages/server/src/connections/connections-over-mcp.test.ts b/packages/server/src/connections/connections-over-mcp.test.ts index 11ce35cc9..87b47daa4 100644 --- a/packages/server/src/connections/connections-over-mcp.test.ts +++ b/packages/server/src/connections/connections-over-mcp.test.ts @@ -22,7 +22,7 @@ afterAll(async () => { await server.stop(); }); -const notes = { kind: 'mcp', entry: { url: 'https://notes.example.com/mcp' }, credential: { kind: 'none' } }; +const notes = { type: 'mcp', entry: { url: 'https://notes.example.com/mcp' }, credential: { kind: 'none' } }; const AddedSchema = Schema.Struct({ connect: Schema.Struct({ url: Schema.String }) }); diff --git a/packages/server/src/connections/connections-signed-in.test.ts b/packages/server/src/connections/connections-signed-in.test.ts index 104a31198..99a9802e5 100644 --- a/packages/server/src/connections/connections-signed-in.test.ts +++ b/packages/server/src/connections/connections-signed-in.test.ts @@ -15,7 +15,7 @@ const builder = createApiKey({ id: 'acme-builder', org: 'acme', permissions: all const notes = { name: 'notes', - kind: 'mcp', + type: 'mcp', entry: { url: 'https://notes.example.com/mcp' }, credential: { kind: 'none' }, }; diff --git a/packages/server/src/mcp/served-schemas.test.ts b/packages/server/src/mcp/served-schemas.test.ts index 3c3bf490d..408e3d316 100644 --- a/packages/server/src/mcp/served-schemas.test.ts +++ b/packages/server/src/mcp/served-schemas.test.ts @@ -87,8 +87,8 @@ interface Endpoint { } const endpoints: readonly Endpoint[] = [ - { path: '/mcp', tools: 28, bytes: 43_163, mostBytes: mostListingBytes['own org'] }, - { path: '/orgs/local/mcp', tools: 10, bytes: 12_620, mostBytes: mostListingBytes.org }, + { path: '/mcp', tools: 28, bytes: 43_310, mostBytes: mostListingBytes['own org'] }, + { path: '/orgs/local/mcp', tools: 10, bytes: 12_767, mostBytes: mostListingBytes.org }, { path: '/orgs/local/playbooks/alpha/mcp', tools: 20, bytes: 30_767, mostBytes: mostListingBytes.playbook }, ]; diff --git a/packages/server/src/testing/servers/connected-servers.ts b/packages/server/src/testing/servers/connected-servers.ts index 90a4337ea..79bbb4dae 100644 --- a/packages/server/src/testing/servers/connected-servers.ts +++ b/packages/server/src/testing/servers/connected-servers.ts @@ -16,7 +16,7 @@ export interface AddedConnection { export function aSecretHeader(name: string, url: string) { return { name, - kind: 'mcp', + type: 'mcp', entry: { url }, credential: { kind: 'secret', name: 'Authorization', in: 'header' }, }; diff --git a/packages/server/src/testing/servers/tool-server-calls.ts b/packages/server/src/testing/servers/tool-server-calls.ts index 80f78ca6e..b02e627ca 100644 --- a/packages/server/src/testing/servers/tool-server-calls.ts +++ b/packages/server/src/testing/servers/tool-server-calls.ts @@ -32,7 +32,7 @@ export async function toolServerToolsCalled( ): Promise { const asked = { server: 'graph', tool: 'search' }; const searchedFor = { ...asked, arguments: { query: 'acme' } }; - const crm = { name: 'crm', kind: 'mcp', entry: { url: 'https://crm.example.com/mcp' }, credential: { kind: 'none' } }; + const crm = { name: 'crm', type: 'mcp', entry: { url: 'https://crm.example.com/mcp' }, credential: { kind: 'none' } }; const added = await session.callTool('add_connection', crm); const id = String(added.structuredContent?.['id']); return [ From 22c7ae47ac6b3381992aa5cb81fb32b7fa7c19d6 Mon Sep 17 00:00:00 2001 From: Rami Date: Sun, 18 Oct 2026 16:49:09 +0100 Subject: [PATCH 2/2] docs(global): a connection's type, the scheme of a header, the client id and when a link ends The HTTP reference names the record's type, the scheme a secret or a reference sent in a header may name, which the runtime writes with a space before the value, the client id of client credentials left to the page, which give_credential takes and refuses where it looks like a credential, the link and its expires_at that read_link answers, and the refusals of each. The MCP reference gives the input of add_connection and the scheme and the optional client id of its credential. The operator's guide to connections names the type, says that the runtime writes the scheme before the token, so a reference's variable holds the token alone, and that the person gives a client id left out on the page. The engineering MCP reference and the README of the api restate the sizes of the listings the server's test measures, and the README of tool-servers says that its behaviour of a connection runs over both types of server. Co-Authored-By: Claude Fable 5.1 --- docs/engineering/reference/mcp.md | 2 +- docs/reference/http.md | 50 +++++++++++++++++-------------- docs/reference/mcp.md | 3 +- docs/self-host/connections.md | 12 ++++---- packages/api/README.md | 6 ++-- packages/tool-servers/README.md | 2 +- 6 files changed, 41 insertions(+), 34 deletions(-) diff --git a/docs/engineering/reference/mcp.md b/docs/engineering/reference/mcp.md index c5fbfde31..0a9386fa9 100644 --- a/docs/engineering/reference/mcp.md +++ b/docs/engineering/reference/mcp.md @@ -18,7 +18,7 @@ The server is also an [MCP](https://modelcontextprotocol.io) server, so an agent Most MCP clients take an entry of this shape. In Claude Code, `claude mcp add --transport http auto-runtime http://localhost:5555/mcp --header "Authorization: Bearer "` adds the same. In [local mode](../../self-host/security.md#local-mode), leave out the header. Where `PUBLIC_URL` turns sign-in on, a client that runs the MCP authorization flow connects without the header and signs the person in through the Auto identity service, as the [security guide](../../self-host/security.md#what-signed-in-people-can-do) describes. -`/mcp` serves every tool on one connection, so an agent can create a playbook and work in it at once. The org is the key's own, since a key belongs to one org, or, for a signed-in person, the org their token names, and never an argument; in local mode, where no key names one, it is `local`, so the playbooks an agent creates there are the ones `GET /v1/orgs/local/playbooks` lists. The org id `local` is reserved for local mode: the key command refuses it, and an `API_KEYS` entry with it stops the server at start-up, so no key can reach the playbooks made in local mode. The playbook tools (`create_playbook`, `list_playbooks`, `update_playbook` and `retire_playbook`), `list_models` and the connection tools (`add_connection`, `list_connections` and `remove_connection`) are as on HTTP; the three routes of Studio's page that approves a connection are HTTP alone, and so are `get_playbook`, `get_connection`, `get_run_history`, `get_playbook_analytics` and `validate`, since `list_playbooks`, `list_connections`, `list_playbook_events` given a `run_id` and `dry_run` answer what an agent needs of them and Studio reads the rest. Every tool that works inside a playbook (`create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, `run_definition`, `get_run`, `cancel_run`, `list_runs`, `get_event`, `list_playbook_events`, `publish_event`, `introspect`, `dry_run`, `execute`, `list_interactions`, `answer_interaction` and `send_run_event`) takes the playbook's id as a required `playbook` argument, `search` takes it as an optional one, and `get_guide` reads the guides on every endpoint: twenty-eight tools for a key that may call them all. `search` answers the tool servers the playbook may use and their tools, which an agent names in a reasoning function's `tools`, found by the words of `terms` where it gives them, and without `playbook` every server set up for the org, each with the playbooks it serves, so an agent sees what is set up before it makes a playbook; it is the org's operation, `GET /v1/orgs/{org}/tool-servers/search`, which the org endpoint serves too; `introspect` reads one tool whole and `dry_run` answers what `execute` would check, its arguments against the tool's input schema among it, the three reading and calling nothing, and `execute` calls one tool as a run would and answers what the run's model would see, under `playbook:write` whatever the tool does, recorded in the playbook's history and never a run; the [HTTP reference](../../reference/http.md#tool-servers) describes them. `list_runs` and `list_playbook_events` page with `limit` and `cursor`, answer `has_more` and `next_cursor`, and read a retired playbook, and `get_event` reads one event whole, each field the 2 KiB rule of a page names, an input, output, record, answer, arguments, result or event data, whole within 64 KiB and its size beyond, since a tool result carries its output twice; the [HTTP reference](../../reference/http.md#run-history-and-playbook-events) describes their fields. A connection lists the tools its key may call, so a read-only key is offered the queries alone, and a key that may only read inside some playbooks is offered `list_playbooks` too, which lists those playbooks. Every endpoint gives a connecting agent instructions, `instructionsFor` of `@on.auto/api`, built per request for the tools listed, the definition types the server runs and the recipes whose tools are listed: what a playbook is and a sentence for each type it runs, that what the person wants kept goes in a recall function and not in the agent's own notes where the server runs recall functions, what the connection acts on, where the formats and recipes are, how a reasoning function names its model and tools and, where `execute` and `dry_run` are listed, what each does, what `add_connection` adds where it is listed, that `get_run` shows whether a run that finishes later ended or still waits, that an answer to what a run waits on goes to its request through `answer_interaction` and no new run, the paging rule, what to say of what was done and what a refusal says. With every tool they take 1,993 characters on `/mcp`, 1,671 on `/orgs/{org}/mcp`, where `search` and `add_connection` are listed, and 1,881 on `/orgs/{org}/playbooks/{playbook}/mcp`, under a bound of 2,000, the most Claude Code shows of a server's instructions, as `packages/api/src/mcp/tool-calling-instructions.test.ts` pins them. The listing of the tools takes 43,163 bytes on the wire on `/mcp`, 12,620 on the org endpoint and 30,767 on the playbook endpoint, under the 48,000, 13,000 and 40,000 the server checks when it starts, and a connection lists at most 32 tools. `get_guide`, the resources `guide://` and the prompts `first-playbook`, `remember`, `give-tools` and `schedule`, each offered where the connection lists the tools its steps call, serve the terminology, the reference page of each format and the recipes; the [API README](../../../packages/api/README.md#over-mcp) describes them, the annotations of each tool and the bounds. +`/mcp` serves every tool on one connection, so an agent can create a playbook and work in it at once. The org is the key's own, since a key belongs to one org, or, for a signed-in person, the org their token names, and never an argument; in local mode, where no key names one, it is `local`, so the playbooks an agent creates there are the ones `GET /v1/orgs/local/playbooks` lists. The org id `local` is reserved for local mode: the key command refuses it, and an `API_KEYS` entry with it stops the server at start-up, so no key can reach the playbooks made in local mode. The playbook tools (`create_playbook`, `list_playbooks`, `update_playbook` and `retire_playbook`), `list_models` and the connection tools (`add_connection`, `list_connections` and `remove_connection`) are as on HTTP; the three routes of Studio's page that approves a connection are HTTP alone, and so are `get_playbook`, `get_connection`, `get_run_history`, `get_playbook_analytics` and `validate`, since `list_playbooks`, `list_connections`, `list_playbook_events` given a `run_id` and `dry_run` answer what an agent needs of them and Studio reads the rest. Every tool that works inside a playbook (`create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, `run_definition`, `get_run`, `cancel_run`, `list_runs`, `get_event`, `list_playbook_events`, `publish_event`, `introspect`, `dry_run`, `execute`, `list_interactions`, `answer_interaction` and `send_run_event`) takes the playbook's id as a required `playbook` argument, `search` takes it as an optional one, and `get_guide` reads the guides on every endpoint: twenty-eight tools for a key that may call them all. `search` answers the tool servers the playbook may use and their tools, which an agent names in a reasoning function's `tools`, found by the words of `terms` where it gives them, and without `playbook` every server set up for the org, each with the playbooks it serves, so an agent sees what is set up before it makes a playbook; it is the org's operation, `GET /v1/orgs/{org}/tool-servers/search`, which the org endpoint serves too; `introspect` reads one tool whole and `dry_run` answers what `execute` would check, its arguments against the tool's input schema among it, the three reading and calling nothing, and `execute` calls one tool as a run would and answers what the run's model would see, under `playbook:write` whatever the tool does, recorded in the playbook's history and never a run; the [HTTP reference](../../reference/http.md#tool-servers) describes them. `list_runs` and `list_playbook_events` page with `limit` and `cursor`, answer `has_more` and `next_cursor`, and read a retired playbook, and `get_event` reads one event whole, each field the 2 KiB rule of a page names, an input, output, record, answer, arguments, result or event data, whole within 64 KiB and its size beyond, since a tool result carries its output twice; the [HTTP reference](../../reference/http.md#run-history-and-playbook-events) describes their fields. A connection lists the tools its key may call, so a read-only key is offered the queries alone, and a key that may only read inside some playbooks is offered `list_playbooks` too, which lists those playbooks. Every endpoint gives a connecting agent instructions, `instructionsFor` of `@on.auto/api`, built per request for the tools listed, the definition types the server runs and the recipes whose tools are listed: what a playbook is and a sentence for each type it runs, that what the person wants kept goes in a recall function and not in the agent's own notes where the server runs recall functions, what the connection acts on, where the formats and recipes are, how a reasoning function names its model and tools and, where `execute` and `dry_run` are listed, what each does, what `add_connection` adds where it is listed, that `get_run` shows whether a run that finishes later ended or still waits, that an answer to what a run waits on goes to its request through `answer_interaction` and no new run, the paging rule, what to say of what was done and what a refusal says. With every tool they take 1,993 characters on `/mcp`, 1,671 on `/orgs/{org}/mcp`, where `search` and `add_connection` are listed, and 1,881 on `/orgs/{org}/playbooks/{playbook}/mcp`, under a bound of 2,000, the most Claude Code shows of a server's instructions, as `packages/api/src/mcp/tool-calling-instructions.test.ts` pins them. The listing of the tools takes 43,310 bytes on the wire on `/mcp`, 12,767 on the org endpoint and 30,767 on the playbook endpoint, under the 48,000, 13,000 and 40,000 the server checks when it starts, and a connection lists at most 32 tools. `get_guide`, the resources `guide://` and the prompts `first-playbook`, `remember`, `give-tools` and `schedule`, each offered where the connection lists the tools its steps call, serve the terminology, the reference page of each format and the recipes; the [API README](../../../packages/api/README.md#over-mcp) describes them, the annotations of each tool and the bounds. | Endpoint | Tools | Use it | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | diff --git a/docs/reference/http.md b/docs/reference/http.md index 43e7c0a3b..5ab6388a7 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -1051,37 +1051,41 @@ Each call is recorded in the playbook's history: as `tool_call_started` before i A connection is a tool server that a person adds to the org through their agent, which every playbook of the org may use, or the playbooks its entry names. The agent adds it without its credential; the person approves it, and gives its credential where it takes one, on Studio's page, which its link opens. [Let people add connections](../self-host/connections.md) sets out the flow, the kinds of credential and the rules the operator sets. These routes are relative to `/v1/orgs/{org}`: -| Operation | Method and route | Permission | Input | -| ------------------- | ------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `add_connection` | `POST /connections` | `org:write` | `name`, `kind`, `entry`, `credential` | -| `list_connections` | `GET /connections` | `org:read` | Optional `include_removed` | -| `get_connection` | `GET /connections/{id}` | `org:read` | Connection id in path | -| `remove_connection` | `DELETE /connections/{id}` | `org:write` | Connection id in path | -| `read_link` | `GET /connections/{id}/link/{token}` | any, with the link's token | Connection id and token in path | -| `approve` | `POST /connections/{id}/approve` | any, with the link's token and the adder's session | `token` | -| `give_credential` | `POST /connections/{id}/credential` | any, with the link's token and the adder's session | `token`, and `value` for a `secret`, or `client_secret`, or `private_key` with `algorithm`, for `client_credentials` | - -Every operation answers the connection: `id`, a UUIDv7; `name`; `kind`, `mcp` or `openapi`; `entry`; `credential`, how it is credentialled, never its value; `status`, `pending`, `connected`, `broken` or `removed`; `added_by`; `added_at`; `connected_at`; `connected_by`, the id of the caller who connected it, by approving it or giving its credential, or `null`; `credential_ends_with`, the last four characters of a secret or client secret of twelve characters or more, which the secret store keeps beside the value, or `null`; and `broken`, why it is broken, or `null`. `add_connection` answers `201` with the connection and `connect`, `{ url, expires_at }`: `url` is Studio's page, `https://studio.on.auto/connect?server=&org=&connection=&token=`, whose origin is `PUBLIC_URL`, or the server's own address in local mode, and the link expires ten minutes on. `list_connections` answers them newest first in `connections`, the removed ones only with `include_removed`. +| Operation | Method and route | Permission | Input | +| ------------------- | ------------------------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add_connection` | `POST /connections` | `org:write` | `name`, `type`, `entry`, `credential` | +| `list_connections` | `GET /connections` | `org:read` | Optional `include_removed` | +| `get_connection` | `GET /connections/{id}` | `org:read` | Connection id in path | +| `remove_connection` | `DELETE /connections/{id}` | `org:write` | Connection id in path | +| `read_link` | `GET /connections/{id}/link/{token}` | any, with the link's token | Connection id and token in path | +| `approve` | `POST /connections/{id}/approve` | any, with the link's token and the adder's session | `token` | +| `give_credential` | `POST /connections/{id}/credential` | any, with the link's token and the adder's session | `token`, and `value` for a `secret`, or `client_secret`, or `private_key` with `algorithm`, for `client_credentials`, with `client_id` where the connection's credential holds none | + +Every operation answers the connection: `id`, a UUIDv7; `name`; `type`, `mcp` or `openapi`; `entry`; `credential`, how it is credentialled, never its value; `status`, `pending`, `connected`, `broken` or `removed`; `added_by`; `added_at`; `connected_at`; `connected_by`, the id of the caller who connected it, by approving it or giving its credential, or `null`; `credential_ends_with`, the last four characters of a secret or client secret of twelve characters or more, which the secret store keeps beside the value, or `null`; and `broken`, why it is broken, or `null`. `add_connection` answers `201` with the connection and `connect`, `{ url, expires_at }`: `url` is Studio's page, `https://studio.on.auto/connect?server=&org=&connection=&token=`, whose origin is `PUBLIC_URL`, or the server's own address in local mode, and the link expires ten minutes on. `list_connections` answers them newest first in `connections`, the removed ones only with `include_removed`. `read_link` answers the connection with `link`, `{ expires_at }`, when its link expires, so the page can say how long the person has; `approve` and `give_credential`, which use the link up, answer the connection alone. - `entry` holds the server without its credential: `url` for `mcp`; `document` and `base_url` for `openapi`; and `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id`, as an entry of the configuration file has them. `url` and `document` are each an `https` URL, or an `http` URL on a loopback address. -- `credential` is one object, `kind` and the fields that kind takes, each optional in the schema and refused as `invalid_input` at the field where its kind lacks it or does not take it: `none` takes no other field; `secret` takes `name` and `in`, `header`, `query` or `security`, so `name` is the header, the query parameter or the security scheme of the document the value is sent as, and only an `openapi` connection takes `query` or `security`; `reference` takes `variable`, a variable of the runtime such as `CRM_API_KEY` that the operator lists in `connections.variables`, whose value is sent where its `name` and `in` say, as a secret's; and `client_credentials` takes `issuer`, an `https` URL or an `http` URL on a loopback address, `client_id` and an optional `scope`. The same object is what every operation answers and what the org's record keeps. +- `credential` is one object, `kind` and the fields that kind takes, each optional in the schema and refused as `invalid_input` at the field where its kind lacks it or does not take it: `none` takes no other field; `secret` takes `name` and `in`, `header`, `query` or `security`, so `name` is the header, the query parameter or the security scheme of the document the value is sent as, and only an `openapi` connection takes `query` or `security`; `reference` takes `variable`, a variable of the runtime such as `CRM_API_KEY` that the operator lists in `connections.variables`, whose value is sent where its `name` and `in` say, as a secret's; and `client_credentials` takes `issuer`, an `https` URL or an `http` URL on a loopback address, an optional `client_id` and an optional `scope`. The same object is what every operation answers and what the org's record keeps. +- A `secret` or a `reference` sent in a header may name its `scheme`, such as `Bearer`, which the runtime writes, with a space, before the value it sends, so the person gives the token alone, the variable of a reference holds the token alone, and the server receives `Authorization: Bearer `. A `scheme` holds the characters of a header name and no `$`, and a credential that places no value in a header takes none. `give_credential` refuses a value that already starts with the scheme and a space, in any case, at `/value`. +- The `client_id` of client credentials is the org's, so the agent may leave it out and the person gives it on Studio's page with the client secret or the private key: a connection whose credential holds none answers its record without it, `give_credential` takes `client_id`, and the record holds it from then on. `give_credential` refuses a `client_id` at `/client_id` where the credential holds one, refuses one missing where it holds none, and refuses one that looks like a credential, since the record keeps the client id in the open and the secret or the key goes in its own field, where it is sealed. `add_connection` refuses a `client_id` that looks like a credential at `/credential/client_id`. - `get_connection` and the three routes of the link are served over HTTP alone, and no MCP endpoint serves them. `get_connection` reads one connection of the org by its id; an agent sees whether the person approved a connection in the `status` `list_connections` answers for each. The three routes of the link are Studio's page's alone. Each takes the token, which the runtime keeps only as its SHA-256, and matches it to the open link of the connection: one given less than ten minutes ago that nothing has used up. - `approve` connects a connection whose credential is `none` or `reference` and uses its link up. `give_credential` keeps the value sealed in the runtime's secret store, connects the connection and uses its link up: giving the credential is its approval. - A connection is added by a person: on a runtime that signs people in, `add_connection` refuses a key, and a runtime of API keys alone, which signs nobody in, refuses every connection, since no person could approve it. - On a runtime that signs people in, `approve` and `give_credential` need the session of the person who added the connection. In local mode the token alone binds. - `remove_connection` removes a connection for good, and the runtime forgets its credential. Removing a removed connection succeeds and records nothing. Its name may then be given to a new connection. -| Case | Response | -| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| An entry that holds a value that looks like a credential, a reference such as `${NAME}`, or a field another entry would refuse | `invalid_input` | -| A kind of credential there is not, or a field its kind lacks or does not take | `invalid_input` | -| A connection the operator's rules do not take: no one may add one, its host, its kind of credential or the variable its reference names | `forbidden`, with the sentence of the rule | -| A name of an entry of `tool_servers`, or of a connection of the org that is not removed | `conflict` | -| An org that holds 50 connections that are not removed, the most it may | `conflict`, kind `connections_full` | -| A token that is unknown, used up or expired, or an id the org does not have | `not_found` | -| `add_connection` by a key where people sign in, or by anyone on a runtime of API keys alone | `forbidden` | -| `approve` or `give_credential` with the session of another person, where people sign in | `forbidden` | -| `approve` of a connection whose credential is to be given, or `give_credential` of one that takes none | `conflict` | +| Case | Response | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| An entry that holds a value that looks like a credential, a reference such as `${NAME}`, or a field another entry would refuse | `invalid_input` | +| A kind of credential there is not, or a field its kind lacks or does not take | `invalid_input` | +| A `scheme` beside a credential that is not sent in a header, or one that holds a `$` or a character a header name does not | `invalid_input` | +| `give_credential` with a value that starts with the credential's `scheme`, a `client_id` where the credential holds one, none where it holds none, or one that looks like a credential | `invalid_input` | +| A connection the operator's rules do not take: no one may add one, its host, its kind of credential or the variable its reference names | `forbidden`, with the sentence of the rule | +| A name of an entry of `tool_servers`, or of a connection of the org that is not removed | `conflict` | +| An org that holds 50 connections that are not removed, the most it may | `conflict`, kind `connections_full` | +| A token that is unknown, used up or expired, or an id the org does not have | `not_found` | +| `add_connection` by a key where people sign in, or by anyone on a runtime of API keys alone | `forbidden` | +| `approve` or `give_credential` with the session of another person, where people sign in | `forbidden` | +| `approve` of a connection whose credential is to be given, or `give_credential` of one that takes none | `conflict` | ### A connection that is not ready diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index efa0d214f..d3573fdbd 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -190,7 +190,8 @@ A connection is a tool server that a person adds to the org through their agent, - A connection is added by a person: where the runtime signs people in, `add_connection` refuses a key, and a runtime of API keys alone, which signs nobody in, refuses every connection, since no person could approve it. - The agent adds a connection without its credential: an entry that holds a value that looks like a credential, or a reference such as `${CRM_API_KEY}`, is refused. The person approves the connection, and gives its credential where it takes one, on Studio's page, which `connect.url` of the answer opens within ten minutes. - `add_connection` always puts the link in its answer. To a client of the `2026-07-28` revision that declares in its request that it opens links, the `url` form of elicitation, it also asks for the link to be opened, with a message that says what to do. The client opens the page or declines, and retries the call, which answers the connection added once and never adds a second one. A retry by another caller, or in another org, is refused, and the connection stays added. A client of an earlier revision, whose requests carry no capabilities on this stateless server, is not asked; it shows the person the link from the answer. -- The credential is one object: its `kind` and the fields that kind takes, `variable`, `name` and `in` for `reference`, `name` and `in` for `secret`, and `issuer`, `client_id` and an optional `scope` for `client_credentials`. A field its kind lacks or does not take is refused as `invalid_input` at that field. +- `add_connection` takes `name`, the name a function gives the server; `type`, `mcp` for a remote MCP server or `openapi` for an HTTP API; `entry`, the server without its credential; and `credential`. +- The credential is one object: its `kind` and the fields that kind takes, `variable`, `name` and `in` for `reference`, `name` and `in` for `secret`, each with an optional `scheme` such as `Bearer` where `in` is `header`, which the runtime writes before the value, and `issuer`, an optional `client_id` and an optional `scope` for `client_credentials`. A `client_id` left out is given by the person on Studio's page, with the secret or the key. A field its kind lacks or does not take is refused as `invalid_input` at that field. - Once connected, a connection is a tool server of the org: a function names it by its name as `name/tool`, and `search`, `introspect`, `dry_run` and `execute` reach it, as [Tool servers](#tool-servers) describes. - A link is gone after its ten minutes, and nothing gives a pending connection another: remove the connection with `remove_connection` and add it again. - `add_connection` and `remove_connection` need `org:write`, and `list_connections` needs `org:read`. `get_connection`, which reads one connection by its id, is served over HTTP alone; `list_connections` shows whether the person approved a connection, in the `status` of each. diff --git a/docs/self-host/connections.md b/docs/self-host/connections.md index 2b0b66c01..261e71c1c 100644 --- a/docs/self-host/connections.md +++ b/docs/self-host/connections.md @@ -8,7 +8,7 @@ This page is for whoever runs Auto. It says how a connection is added, what it h 1. The person asks their agent to connect a tool server, such as their CRM. The agent calls `add_connection` with the server's address, its options and how it is credentialled, never the credential itself. 2. Auto keeps the connection, `pending`, and answers with a link to Studio's page, `https://studio.on.auto/connect`, which lasts ten minutes. An agent whose client opens links asks the person to open it; any other agent gives the person the link from its answer. -3. On the page, signed in, the person approves the connection. Where it takes a secret or client credentials, the person types the credential there, and giving it approves the connection. The connection is then `connected`. +3. On the page, signed in, the person approves the connection. Where it takes a secret or client credentials, the person types the credential there, with the client id where the agent left it out, and giving it approves the connection. The connection is then `connected`. On a runtime that signs people in, only the person who added a connection may approve it, signed in as themselves. In [local mode](security.md#local-mode), the link alone binds: whoever holds it within its ten minutes approves the connection. A link is gone after its ten minutes, and nothing gives a pending connection another, so the way on is to remove the connection and add it again. @@ -49,12 +49,12 @@ A person approves every connection, so a connection is added by a person, and a ## What a connection holds -A connection has a name, which a function will give it as a tool server's name, a kind, an entry and a credential. +A connection has a name, which a function will give it as a tool server's name, a type, an entry and a credential. | Part | What it holds | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | 1 to 32 lowercase letters, digits and hyphens, starting with a letter. It may not be the name of a tool server of the settings file, nor of another connection of the org that is not removed | -| `kind` | `mcp` for a remote MCP server, `openapi` for an HTTP API described by an OpenAPI document | +| `type` | `mcp` for a remote MCP server, `openapi` for an HTTP API described by an OpenAPI document, as the `type` of an entry of the settings file says | | `entry` | The server without its credential: `url` for `mcp`, or `document` and `base_url` for `openapi`, with `headers`, `query_params`, `allowed`, `playbooks`, `record_content` and `request_id` as an [entry of the settings file](configuration.md#tool-servers) has them | | `credential` | How the server is credentialled, below, never its value: one object, its `kind` and the fields that kind takes | @@ -67,11 +67,13 @@ Auto checks an entry as it checks an entry of the settings file, and refuses one | `none` | Nothing; the server takes no credential | Nowhere | | `reference` | Nothing; `variable` names an environment variable of Auto, such as `CRM_API_KEY`, that you set and list in the rule `variables`, and `name` and `in` say where its value goes, as for a `secret` | In Auto's environment, where you put it | | `secret` | A key or token, on Studio's page. `name` and `in` say where it goes: `in: header` and the header's name, never one Auto sets itself such as `Content-Type`, `Host` or `Mcp-Session-Id`, or for an HTTP API `in: query` and a query parameter's name, or `in: security` and a scheme of its OpenAPI document | Sealed in Auto's secret store | -| `client_credentials` | The client secret, or a private key with its algorithm, on Studio's page. `issuer`, `client_id` and `scope` name the authorization server and the client | Sealed in Auto's secret store | +| `client_credentials` | The client secret, or a private key with its algorithm, on Studio's page, with the client id where `client_id` is left out. `issuer`, `client_id` and `scope` name the authorization server and the client | Sealed in Auto's secret store | + +A `secret` or a `reference` sent in a header may name its `scheme`, such as `Bearer`: Auto writes the scheme and a space before the value, so the person pastes the token alone, the variable you set for a reference holds the token alone, and the server receives `Authorization: Bearer `. Auto refuses a token pasted with the scheme before it, saying to paste the token alone. Auto seals a secret under its secret key, `SECRETS_KEY` or the key file beside a SQLite ledger, as [the configuration reference](configuration.md#storage) sets out. No record of the org, no answer and no history holds the value, and the link's token is kept only as its SHA-256. Of a secret or a client secret of twelve characters or more, Auto also keeps its last four characters beside the sealed value in the secret store, never in the org's record, and `list_connections` and `get_connection` answer them as `credential_ends_with`, so the person can tell which key a connection holds; a private key, or a shorter value, has none. Removing a connection forgets its secret, and the ending with it. -Every value of a credential is a secret Auto scrubs from what it records and answers, in each form it may take, as it scrubs the value of a reference of the settings file. A value in a header that is a scheme and a credential, such as `Bearer `, is scrubbed whole and its credential alone. +Every value of a credential is a secret Auto scrubs from what it records and answers, in each form it may take, as it scrubs the value of a reference of the settings file. A value in a header that is a scheme and a credential, such as `Bearer `, is scrubbed whole and its credential alone; where the credential names its `scheme`, the value is the token, scrubbed whole. A secret is only as recoverable as the key it was sealed under. When the key is lost, Auto can no longer read the credentials sealed under it, so their connections are broken. Auto marks such a connection `broken` when it first needs the credential, with a sentence that says why; the person removes it and adds it again. diff --git a/packages/api/README.md b/packages/api/README.md index 3e1f45410..ac13843d1 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -171,11 +171,11 @@ The bound on a listing is bytes because a listing is what an agent's client load | Endpoint | Tools | Bytes on the wire | Bound | | ------------------------------------------- | ----- | ----------------- | ------ | -| `POST /mcp` | 28 | 43,163 | 48,000 | -| `POST /orgs/{org}/mcp` | 10 | 12,620 | 13,000 | +| `POST /mcp` | 28 | 43,310 | 48,000 | +| `POST /orgs/{org}/mcp` | 10 | 12,767 | 13,000 | | `POST /orgs/{org}/playbooks/{playbook}/mcp` | 20 | 30,767 | 40,000 | -The bound of the org endpoint moved from 12,000 bytes to 13,000 with the tools of connections, the least that holds them, since its listing takes 12,620 with every tool at its shortest. A listing takes 3.9 to 4.2 bytes a token, as two open byte-pair vocabularies of 100,000 and 200,000 count it, so the 48,000 bytes of `/mcp` come to about 11,400 to 12,300 tokens (the rate counted on 2026-10-13 over the `tools/list` answer as it is sent, the server-sent event's framing included). A model's own tokenizer counts differently, by a few percent either way. +The bound of the org endpoint moved from 12,000 bytes to 13,000 with the tools of connections, the least that holds them, since its listing takes 12,767 with every tool at its shortest. A listing takes 3.9 to 4.2 bytes a token, as two open byte-pair vocabularies of 100,000 and 200,000 count it, so the 48,000 bytes of `/mcp` come to about 11,400 to 12,300 tokens (the rate counted on 2026-10-13 over the `tools/list` answer as it is sent, the server-sent event's framing included). A model's own tokenizer counts differently, by a few percent either way. **Which errors look how.** An MCP endpoint answers in three shapes: diff --git a/packages/tool-servers/README.md b/packages/tool-servers/README.md index e9858c602..85c622023 100644 --- a/packages/tool-servers/README.md +++ b/packages/tool-servers/README.md @@ -212,7 +212,7 @@ The arguments, as `JSON.stringify` writes them, and the protocol answer are scru - Its tools answer text (`search`, `echo`, `graph.query.v2`), structured content (`profile`), non-text content (`photo`), a denial marked `isError` (`denied`), an error (`broken`), slowly (`sleep`), at length (`large`), the channels of a workspace (`list_channels`, `fakeChannels`), with its environment (`environment`), or by exiting (`exit`). Their annotations cover every case of a test: `search`, `profile`, `photo`, `denied`, `sleep`, `large`, `broken` and `list_channels` are marked read-only, `graph.query.v2` read-only and destructive at once, `exit` destructive, the long-named tool only additive (`destructiveHint: false`), `echo` and `verbose` have none, `environment` has annotations with a title and no hint, and `verbose` has a title of its own. - `openFakeToolRun`, `reportingAccess`, `noToolServers`, an access with no server, `recordingCallJournal`, `toolRun`, `controlledSignals` and `inTurn` open a run's tools against it, and `playbookToolCalls` dispatches the five operations over a memory ledger, optionally one that refuses every write, as `toolCaller`, a caller that may write the playbook, unless the call names another, such as `toolReader`, which may only read it. - `reportingAccess` and `describedAccess` take `connections`, a source of connections. Inside the package, `fakeConnections()` (`src/testing/fake-connections.ts`) is one: it reads each connection it is given as the file reads an entry, from an entry with references and the environment that resolves them, and answers it connected, pending, broken by `breakOff`, broken when its credential is next read by `loseTheCredentialOf`, refused by a rule by `disallow`, broken without the break recorded by `loseTheCredentialUnrecordedOf`, dying once while it is prepared by `dieOnceWhilePreparing`, or removed, and counts the reads of credentials and of the org's connections. `pendingWords`, the sentence it answers for a pending connection, is exported for `@on.auto/connections`, whose tests hold it to the sentence of its own source. -- `toolAccessBehaviour(serverUnderTest, sourceUnderTest)` is the shared behaviour suite (`src/access-testing`). Over each of `serversUnderTest`, an MCP server keyed by a bearer, an MCP server of client credentials, an HTTP API keyed by a header, one keyed by a security scheme in its query, one whose document declares no key, sent as a query parameter of the entry (`declaresKey: false` of `serveFakeApi`), and one of client credentials, each described by its entry, its entry without the credential and where its credential goes, it proves the recording, the content kept, the credential scrubbed in every form a call carries it, raw, base64, query-encoded, percent-encoded, as JSON and as JSON within JSON, from the answer, from what is kept and from the start of a call recorded before any link is made, the request ids, the denials, the failures and the bounds. `behaviourKey` holds a character each encoding changes, so no form is the raw key again. The package runs it over the file's entries and over `fakeConnections` (`sources-under-test.ts`), and `@on.auto/connections` over its own source, for every kind of credential. `connection-behaviour.ts` proves over both kinds what a connection adds: the listing with its id, the opening and the check of names, one call, its id recorded, the refusals of one that is not ready in their words, a link stopped after removal, a name a new id reuses, and the file's entry winning a name. +- `toolAccessBehaviour(serverUnderTest, sourceUnderTest)` is the shared behaviour suite (`src/access-testing`). Over each of `serversUnderTest`, an MCP server keyed by a bearer, an MCP server of client credentials, an HTTP API keyed by a header, one keyed by a security scheme in its query, one whose document declares no key, sent as a query parameter of the entry (`declaresKey: false` of `serveFakeApi`), and one of client credentials, each described by its entry, its entry without the credential and where its credential goes, it proves the recording, the content kept, the credential scrubbed in every form a call carries it, raw, base64, query-encoded, percent-encoded, as JSON and as JSON within JSON, from the answer, from what is kept and from the start of a call recorded before any link is made, the request ids, the denials, the failures and the bounds. `behaviourKey` holds a character each encoding changes, so no form is the raw key again. The package runs it over the file's entries and over `fakeConnections` (`sources-under-test.ts`), and `@on.auto/connections` over its own source, for every kind of credential. `connection-behaviour.ts` proves over both types what a connection adds: the listing with its id, the opening and the check of names, one call, its id recorded, the refusals of one that is not ready in their words, a link stopped after removal, a name a new id reuses, and the file's entry winning a name. - `recordingTimer` stands in for the timer a link's options take, which bounds the end of a session: it records each wait and whether it was stopped, so a test of that deadline touches no global timer. `fetchWithDeletion` sends a session's `DELETE` to a handler of the test's own. ## A manual run against an agent services gateway