diff --git a/docs/self-host/configuration.md b/docs/self-host/configuration.md index 6cc224d39..dc7f3ee50 100644 --- a/docs/self-host/configuration.md +++ b/docs/self-host/configuration.md @@ -269,6 +269,8 @@ tool_servers: | `record_content` | every type | Whether the arguments and results of calls are kept whole, scrubbed of secrets, in the playbook's ledger, where anyone who may read the playbook reads them. `true` when left out; `false` keeps their sizes alone | | `request_id` | every type | The response header, or for an MCP server the key of a result's metadata, in which the server returns its own id of a request, recorded with each call | +Every request the server makes to an `mcp` or `openapi` server carries `User-Agent: auto-runtime/`, the name of the runtime's command with the version of the release that runs. That covers the read of a document, every call, and the requests for a token and for an authorization server's metadata, for an entry of this file and for a connection alike. A `User-Agent` in an entry's `headers` is sent on that server's calls instead; the read of its document and the requests for its tokens still carry the runtime's. + ### Keep secrets out of the file Only `headers`, `query_params`, `security`, `env` and `auth` may hold a reference. A reference in `url`, `command`, `args`, `document` or `base_url` stops the server at start: an argument shows in the machine's list of processes, and the URL of a server or of a document is no place for a credential. @@ -328,7 +330,7 @@ Which credentials a call sends: - A requirement is a list of alternatives. Each alternative is a set of schemes that must all be given. - An operation is offered as a tool when the entry gives one of its alternatives. -- A call sends the credentials of the first alternative the entry gives, in the document's order, and no other. +- A call sends the credentials of the first alternative the entry gives, in the document's order, and no other. Where the operation needs a header that the entry's `headers` do not name, it sends the first alternative the entry gives whose `apiKey` schemes place that header instead. - An operation whose requirement is empty, or of a document that declares none, sends none. - An operation for which the entry gives none of the alternatives is not offered as a tool. At start, the server logs a warning that names the schemes it needs. - The scopes an `oauth2` requirement names are the operation's. The `auth` block asks for its own `scope`, so set it to cover what the operations require. @@ -370,17 +372,20 @@ Each operation becomes one tool: Some operations are not offered as tools. At start, the server logs a warning for each operation it leaves out, naming the entry and the reason. It leaves out an operation that: -- needs a header or a cookie; +- needs a cookie; +- needs a header that the entry's `headers` do not name and its security scheme does not place. The warning says that `headers` can give it, or, for a header the server sets itself such as `Host`, that it does. A header parameter named `Accept`, `Content-Type` or `Authorization` is ignored, as the OpenAPI specification says; - takes an object in its query; - takes a body that is not JSON; - is a `TRACE`, or a `GET` or `HEAD` that takes a body; - has a path parameter of a `style` other than `simple`; -- has a query parameter of a `style` other than `form`, or with `explode: false`; +- has a query parameter of a `style` other than `form`, such as `spaceDelimited`, `pipeDelimited` or `deepObject`; - has an input schema of more than 64 KiB. +An operation that needs a header the entry's `headers` name, in any case, is offered, such as one that needs an account id on every call. The header is not an input of the tool: every call sends the entry's value. So is an operation that needs the header of an `apiKey` scheme its call sends, which the call fills with the credential the entry gives under `security`. + A call: -- fills the path and the query, with an array sent as repeated keys; +- fills the path and the query, with an array sent as repeated keys, or as one key of comma-joined items when the document writes the parameter with `explode: false`; - refuses a path argument that is empty, holds a `/`, or is `.` or `..`; - sends nothing to an address that would leave the origin or the path of `base_url`; - sends the body as JSON, with the entry's headers and query parameters. diff --git a/packages/outbound/README.md b/packages/outbound/README.md index 835f00052..b6383b340 100644 --- a/packages/outbound/README.md +++ b/packages/outbound/README.md @@ -1,6 +1,6 @@ # @on.auto/outbound -How the server calls systems on the internet. It walks the causes of a failed call, and tells a certificate the server does not trust from any other failure of a call: the reasoning function adapter and the tool-servers package take the classification from here, so a model provider and a tool server whose certificate the server does not trust are named alike; every caller keeps its own client and bounds. And it makes the settings of a client of the AWS SDK, which the Bedrock provider of the reasoning capability and the object store of [`@on.auto/object-storage`](../object-storage/README.md) share, and signs the tokens the server signs in to the ledger's database with when `DATABASE_CREDENTIALS=aws-iam`. +How the server calls systems on the internet. It walks the causes of a failed call, and tells a certificate the server does not trust from any other failure of a call: the reasoning function adapter and the tool-servers package take the classification from here, so a model provider and a tool server whose certificate the server does not trust are named alike; every caller keeps its own client and bounds. It names the runtime in the `User-Agent` of each request a `fetch` it wraps sends, which the server gives its tool servers. And it makes the settings of a client of the AWS SDK, which the Bedrock provider of the reasoning capability and the object store of [`@on.auto/object-storage`](../object-storage/README.md) share, and signs the tokens the server signs in to the ledger's database with when `DATABASE_CREDENTIALS=aws-iam`. ## The causes of a failure @@ -10,6 +10,10 @@ How the server calls systems on the internet. It walks the causes of a failed ca `isUntrustedCertificate(error)` says whether any of the codes `codesOf` names is the code of a certificate the server does not trust. +## The User-Agent of a request + +`withUserAgent(fetch, userAgent)` (`src/user-agent/user-agent-fetch.ts`) sends each request through the `fetch` it wraps with `User-Agent` set to `userAgent` where the request sets none, in whatever case the request writes the name, so a `User-Agent` the caller writes is the one sent. It reads the request's headers as a `Headers`, a list of pairs of strings or an object of strings, and hands headers of any other shape to the `fetch` beneath as they are, which sends its own (`node` for Node's, measured with Node 26.10.0). The server wraps `globalThis.fetch` once with `auto-runtime/`, the name of the runtime's command and the version of the release it announces to an MCP server in `clientInfo`, since a product token holds no `@` or `/` in its name, and gives that fetch to the tool servers alone (`packages/server/src/composition/served-tool-access.ts`); every other client of the server sends what its own client sends, the model providers, the object store, the exporters of telemetry, the requests to the identity issuer and those of prediction functions among them. + ## AWS clients `proxySettingsOf(environment)`, in the main entry, reads `ProxySettings`, `{ enabled, environment }`: the switch, `NODE_USE_ENV_PROXY=1`, and the proxy's variables that are set, `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` in either case. The server reads them once and hands them to every client it makes, the model providers' and the object store's. @@ -20,4 +24,4 @@ How the server calls systems on the internet. It walks the causes of a failed ca ## Source -`src/causes` holds the walk of a failure's causes, `src/certificates` the classification, `src/proxy` the settings of the environment proxy, and `src/aws` the settings of AWS clients. +`src/causes` holds the walk of a failure's causes, `src/certificates` the classification, `src/user-agent` the `User-Agent` of a request, `src/proxy` the settings of the environment proxy, and `src/aws` the settings of AWS clients. diff --git a/packages/outbound/src/index.ts b/packages/outbound/src/index.ts index c017cf0d6..d2d6e6a3f 100644 --- a/packages/outbound/src/index.ts +++ b/packages/outbound/src/index.ts @@ -1,3 +1,4 @@ export { causesOf, codesOf } from './causes/error-causes.ts'; export { isUntrustedCertificate } from './certificates/untrusted-certificates.ts'; export { proxySettingsOf, proxySettingsVariables, type ProxySettings } from './proxy/proxy-settings.ts'; +export { withUserAgent } from './user-agent/user-agent-fetch.ts'; diff --git a/packages/outbound/src/user-agent/user-agent-fetch.test.ts b/packages/outbound/src/user-agent/user-agent-fetch.test.ts new file mode 100644 index 000000000..d3d361b4b --- /dev/null +++ b/packages/outbound/src/user-agent/user-agent-fetch.test.ts @@ -0,0 +1,84 @@ +import { once } from 'node:events'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +import { Schema } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { withUserAgent } from '../index.ts'; + +interface Heard { + readonly method: string; + readonly userAgent: string | undefined; + readonly tenant: string | undefined; +} + +const portOf = Schema.decodeUnknownSync(Schema.Struct({ port: Schema.Number })); + +const runtime = 'auto-runtime/1.2.3'; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +async function listening() { + const heard: Heard[] = []; + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + const tenant = request.headers['x-tenant']; + heard.push({ + method: String(request.method), + userAgent: request.headers['user-agent'], + tenant: typeof tenant === 'string' ? tenant : undefined, + }); + response.writeHead(204).end(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + closing.push(async () => { + server.closeAllConnections(); + server.close(); + await once(server, 'close'); + }); + return { url: new URL(`http://127.0.0.1:${portOf(server.address()).port}/`), heard: () => [...heard] }; +} + +describe('a request sent through the fetch of the runtime', () => { + it('carries the User-Agent of the runtime where it carries none, whatever form its headers take', async () => { + const api = await listening(); + const fetch = withUserAgent(globalThis.fetch, runtime); + + await fetch(api.url); + await fetch(api.url.href, { method: 'POST', headers: { 'X-Tenant': 'acme-eu' }, body: 'note' }); + await fetch(api.url, { headers: new Headers({ 'X-Tenant': 'acme-us' }) }); + await fetch(api.url, { headers: [['X-Tenant', 'acme-ap']] }); + + expect(api.heard()).toEqual([ + { method: 'GET', userAgent: runtime, tenant: undefined }, + { method: 'POST', userAgent: runtime, tenant: 'acme-eu' }, + { method: 'GET', userAgent: runtime, tenant: 'acme-us' }, + { method: 'GET', userAgent: runtime, tenant: 'acme-ap' }, + ]); + }); + + it('keeps a User-Agent it carries already, written in any case and in any form', async () => { + const api = await listening(); + const fetch = withUserAgent(globalThis.fetch, runtime); + + await fetch(api.url, { headers: { 'User-Agent': 'operator/2.0' } }); + await fetch(api.url, { headers: new Headers({ 'user-agent': 'operator/2.1' }) }); + await fetch(api.url, { headers: [['USER-AGENT', 'operator/2.2']] }); + + expect(api.heard().map(({ userAgent }) => userAgent)).toEqual(['operator/2.0', 'operator/2.1', 'operator/2.2']); + }); + + it('leaves headers it cannot read as written to the fetch beneath it, which sends its own User-Agent', async () => { + const api = await listening(); + const fetch = withUserAgent(globalThis.fetch, runtime); + + await fetch(api.url, { headers: { 'X-Tenant': 7 } }); + await expect(fetch(api.url, { headers: [['X-Tenant', 'acme-eu', 'more']] })).rejects.toThrow(TypeError); + + expect(api.heard()).toEqual([{ method: 'GET', userAgent: 'node', tenant: '7' }]); + }); +}); diff --git a/packages/outbound/src/user-agent/user-agent-fetch.ts b/packages/outbound/src/user-agent/user-agent-fetch.ts new file mode 100644 index 000000000..82b4eef98 --- /dev/null +++ b/packages/outbound/src/user-agent/user-agent-fetch.ts @@ -0,0 +1,39 @@ +import { Predicate } from 'effect'; + +type OutboundFetch = (url: unknown, init?: unknown) => Promise; + +type HeadersInit = NonNullable[0]>; + +function isHeaderPair(written: unknown): written is [string, string] { + return Array.isArray(written) && written.length === 2 && written.every((part) => typeof part === 'string'); +} + +function isHeaderRecord(written: unknown): written is Readonly> { + return Predicate.isObject(written) && Object.values(written).every((value) => typeof value === 'string'); +} + +function isHeadersInit(written: unknown): written is HeadersInit { + return ( + written instanceof Headers || + (Array.isArray(written) && written.every((pair) => isHeaderPair(pair))) || + isHeaderRecord(written) + ); +} + +function headersOf(init: unknown): Headers | undefined { + const written: unknown = Predicate.hasProperty(init, 'headers') ? init.headers : undefined; + if (written === undefined) { + return new Headers(); + } + return isHeadersInit(written) ? new Headers(written) : undefined; +} + +export function withUserAgent(fetch: typeof globalThis.fetch, userAgent: string): OutboundFetch { + return (url, init) => { + const headers = headersOf(init); + if (headers !== undefined && !headers.has('user-agent')) { + headers.set('user-agent', userAgent); + } + return fetch(String(url), Object.assign({}, init, headers === undefined ? {} : { headers })); + }; +} diff --git a/packages/server/src/composition/served-tool-access.ts b/packages/server/src/composition/served-tool-access.ts index e7e2809de..419ea81e4 100644 --- a/packages/server/src/composition/served-tool-access.ts +++ b/packages/server/src/composition/served-tool-access.ts @@ -1,5 +1,6 @@ import type { AppRuntime } from '@on.auto/api'; import type { DispatcherServices } from '@on.auto/operations'; +import { withUserAgent } from '@on.auto/outbound'; import { describedToolServers, makeToolAccess, @@ -33,12 +34,14 @@ export async function toolAccessOf( { toolTiming, connections, telemetry }: ToolAccessWiring & ServedConnections, ): Promise { const traceMeta = telemetry?.traceMeta; + const outboundFetch = withUserAgent(globalThis.fetch, `auto-runtime/${release.version}`); const reportLeftOut: Describing['reportLeftOut'] = (server, leftOut) => { void runtime.run(logLeftOutOperation(server, leftOut)); }; - const servers = await Effect.runPromise(describedToolServers(toolServers, { reportLeftOut })); + const servers = await Effect.runPromise(describedToolServers(toolServers, { fetch: outboundFetch, reportLeftOut })); return makeToolAccess(servers, { clientInfo: release, + fetch: outboundFetch, connections, reportLeftOut, ...(toolTiming === undefined ? {} : { timing: toolTiming }), diff --git a/packages/server/src/tool-servers/runtime-user-agent.test.ts b/packages/server/src/tool-servers/runtime-user-agent.test.ts new file mode 100644 index 000000000..ea6c4058e --- /dev/null +++ b/packages/server/src/tool-servers/runtime-user-agent.test.ts @@ -0,0 +1,44 @@ +import { serveFakeApi } from '@on.auto/tool-servers/testing'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { release } from '../composition/release.ts'; +import { alpha, servingReasoning } from '../testing/servers/reasoning-server.ts'; + +const apiKey = 'billing-api-key-5c2e8d1f'; + +const runtime = `auto-runtime/${release.version}`; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +describe('the requests the server makes to its tool servers', () => { + it('name the command of the runtime and its release as their User-Agent, but on the calls of an entry that writes its own', async () => { + const billing = await serveFakeApi({ key: apiKey }); + closing.push(billing.close); + const entry = { document: billing.documentUrl, security: { apiKeyAuth: '${BILLING_KEY}' }, org: 'acme' }; + const own = { ...entry, headers: { 'User-Agent': 'operator-agent/2.0' } }; + const server = await servingReasoning([], { + BILLING_KEY: apiKey, + TOOL_SERVERS: JSON.stringify({ billing: entry, ledger: own }), + }); + closing.push(server.stop); + await server.call('POST', '/v1/orgs/acme/playbooks', { body: { playbook: 'alpha', name: 'Alpha' } }); + const search = { body: { arguments: { query: 'acme' } } }; + + const called = [ + await server.call('POST', `${alpha}/tool-servers/billing/tools/search/execute`, search), + await server.call('POST', `${alpha}/tool-servers/ledger/tools/search/execute`, search), + ]; + + expect(called.map(({ status }) => status)).toEqual([200, 200]); + expect(billing.heard().map(({ path, userAgent }) => [path, userAgent])).toEqual([ + ['/openapi.json', runtime], + ['/openapi.json', runtime], + ['/v1/search?query=acme', runtime], + ['/v1/search?query=acme', 'operator-agent/2.0'], + ]); + }); +}); diff --git a/packages/tool-servers/README.md b/packages/tool-servers/README.md index 85c622023..8aa29bfe0 100644 --- a/packages/tool-servers/README.md +++ b/packages/tool-servers/README.md @@ -110,7 +110,7 @@ A call made by hand is the same happening as a call a run makes, so it records t ## The credentials of an HTTP API -The document's `components.securitySchemes` and the `security` of the document and of each operation decide what a call sends (`src/openapi/security-schemes.ts`). An entry gives a credential by the scheme's name under `security`, a map read at start like `headers`, whose every value holds a reference, one written out refused with the advice a credential written out gets, and is a secret whole as well as its reference's value, so a Basic credential built of a user and a referenced password is scrubbed in its base64. `requirementOf` reads an operation's alternatives, its own `security` or else the document's: the first alternative every scheme of which the entry gives is the one a call sends, as `credentials` of its `ApiOperation`, and no other; an `apiKey` in a header or the query goes in its header or query parameter, an `http` scheme of `bearer` as `Authorization: Bearer`, of `basic`, `user:password`, as `Authorization: Basic` and its base64, and an `oauth2` or `openIdConnect` scheme is given by the `auth` block alone, whose token a call sends as a bearer only when the alternative it sends names such a scheme. An empty requirement, an empty alternative, or a document that requires nothing sends no credential; an operation the entry gives no alternative of is left out, its reason naming the alternatives. `securityProblemsOf` refuses the entry at start, at `//security/`, `//headers/`, `//query_params/` or `//auth`, for a scheme the document does not declare, an `apiKey` in a cookie, an `http` scheme other than `bearer` and `basic`, `mutualTLS`, an `oauth2` or `openIdConnect` scheme given a value, a `basic` value without a colon, a header or query parameter of the entry that a scheme it gives also places, and an `auth` block whose document declares no `oauth2` or `openIdConnect` scheme. The scopes of an `oauth2` requirement are the operation's; the token source asks for `auth.scope` as for an MCP server, so the operator sets it to cover what the operations require. +The document's `components.securitySchemes` and the `security` of the document and of each operation decide what a call sends (`src/openapi/security-schemes.ts`). An entry gives a credential by the scheme's name under `security`, a map read at start like `headers`, whose every value holds a reference, one written out refused with the advice a credential written out gets, and is a secret whole as well as its reference's value, so a Basic credential built of a user and a referenced password is scrubbed in its base64. `requirementOf` reads an operation's alternatives, its own `security` or else the document's: the first alternative every scheme of which the entry gives is the one a call sends, as `credentials` of its `ApiOperation`, and no other, unless the operation needs a header the entry's `headers` do not name, when the first alternative the entry gives whose `apiKey` schemes place every such header is the one sent; an `apiKey` in a header or the query goes in its header or query parameter, an `http` scheme of `bearer` as `Authorization: Bearer`, of `basic`, `user:password`, as `Authorization: Basic` and its base64, and an `oauth2` or `openIdConnect` scheme is given by the `auth` block alone, whose token a call sends as a bearer only when the alternative it sends names such a scheme. An empty requirement, an empty alternative, or a document that requires nothing sends no credential; an operation the entry gives no alternative of is left out, its reason naming the alternatives. `securityProblemsOf` refuses the entry at start, at `//security/`, `//headers/`, `//query_params/` or `//auth`, for a scheme the document does not declare, an `apiKey` in a cookie, an `http` scheme other than `bearer` and `basic`, `mutualTLS`, an `oauth2` or `openIdConnect` scheme given a value, a `basic` value without a colon, a header or query parameter of the entry that a scheme it gives also places, and an `auth` block whose document declares no `oauth2` or `openIdConnect` scheme. The scopes of an `oauth2` requirement are the operation's; the token source asks for `auth.scope` as for an MCP server, so the operator sets it to cover what the operations require. ## An HTTP API as a tool server @@ -125,9 +125,9 @@ At start `describedToolServers` reads the document, from its URL through the ser - a document of 3.0 has its schemas translated to JSON Schema 2020-12, which the model and the compiler read: `nullable: true` adds `null` to the `type` the schema states, once, and to its `enum`, and is left out with no effect where the schema states no `type`, beside a `$ref`, whose siblings 3.0 ignores, and when it is `false`; a boolean `exclusiveMinimum` or `exclusiveMaximum` becomes the number of its `minimum` or `maximum`; `example` becomes the one value of `examples`, or joins those written beside it; and every other keyword, `format: binary` among them, is kept as written; - its annotations are `readOnlyHint` for `GET` and `HEAD`, `destructiveHint` for `DELETE`, `idempotentHint` for `GET`, `HEAD`, `PUT` and `DELETE`, and `openWorldHint` for every operation, each only where it holds, so the operator's `allowed`, `search` and a run's read-only calls read an API's tools by these names and hints as they read an MCP server's. -An operation that a call could only fail or send in another form is offered as no tool: `TRACE`, which `fetch` refuses, a `GET` or a `HEAD` that takes a body, which neither can carry, a path parameter of a `style` other than `simple`, a query parameter of a `style` other than `form` or with `explode: false`, since a call sends a list in a path joined by commas and in a query as repeated keys, and an operation that needs a header or a cookie, takes an object in its query, its schema or the component it refers to of type `object`, takes a body of another media type, or has two inputs of one name, `body` among them, or whose input schema is over its bound: `reportLeftOut` names it, as `createInvoice (POST /invoices)` or `POST /invoices`, and says why (`src/openapi/left-out.ts`). The calls go to `base_url`, refused with a query or a fragment, or to the first server the document names, refused with either too, its variables given their defaults and a URL relative to where the document was read resolved against it, either without the slashes it ends in, which are dropped once where it is read (`withoutTrailingSlashes` of `@on.auto/config`); a document that names none, names one relative to a file, or one that is neither https nor on a loopback address, is refused at `//base_url`. +An operation that a call could only fail or send in another form is offered as no tool: `TRACE`, which `fetch` refuses, a `GET` or a `HEAD` that takes a body, which neither can carry, a path parameter of a `style` other than `simple` and a query parameter of a `style` other than `form`, `spaceDelimited`, `pipeDelimited` and `deepObject` among them, whatever its schema, since a call sends a list in a path joined by commas and in a query as repeated keys, or as one key of items joined by commas where the document writes `explode: false`, as the form style does, and a scalar alike either way; an operation that needs a cookie, or a header that neither the entry's `headers` name nor an `apiKey` scheme of the alternative its call sends places, the names compared without regard to case as header names are, and a header parameter named `Accept`, `Content-Type` or `Authorization` ignored, as the OpenAPI specification says; and one that takes an object in its query, its schema or the component it refers to of type `object`, takes a body of another media type, or has two inputs of one name, `body` among them, or whose input schema is over its bound: `reportLeftOut` names it, as `createInvoice (POST /invoices)` or `POST /invoices`, and says why (`src/openapi/left-out.ts`), a header it needs in words that say the entry's `headers` can give it, or, for a header the runtime sets itself (`headersTheRuntimeSets` of `src/settings/reserved-headers.ts`), that the runtime sets it and neither a function nor the entry can give it; the words of a style it does not send name the style alone. A required header the entry's `headers` name is no input of the tool, and its call sends the entry's value as it sends every header of the entry. `describedApi` (`src/openapi/api-description.ts`), which describes a file's entry and a connection alike, gives the rule the names of the entry's headers once, from the settings it describes, beside the security and the auth the entry presents; the headers the credentials of the alternative a call sends place join them (`headersPlacedBy` of `src/openapi/security-schemes.ts`), and where the entry gives no alternative, the headers the `apiKey` schemes of its alternatives name, so that the operation is left out in the words of its security. The calls go to `base_url`, refused with a query or a fragment, or to the first server the document names, refused with either too, its variables given their defaults and a URL relative to where the document was read resolved against it, either without the slashes it ends in, which are dropped once where it is read (`withoutTrailingSlashes` of `@on.auto/config`); a document that names none, names one relative to a file, or one that is neither https nor on a loopback address, is refused at `//base_url`. -A call (`src/openapi/api-requests.ts`) fills each path parameter, encoded, a list joined by commas, and the query, a list as repeated keys and `null` left out, then the entry's `query_params` and a credential the operation's alternative places in the query; sends the body as JSON in the operation's media type with the entry's headers, the credentials of the alternative it sends and `accept: application/json`, and no `_meta`, which an HTTP API has no place for; and refuses, as `arguments_refused` and without sending, an argument the operation does not take, a path it cannot fill, a path argument that is empty or holds a `/`, or makes a segment `.` or `..`, which would take the call out of the operation's path, an object outside the body and a missing body the operation needs. Before it is sent, the address is read as a URL and must have the origin of `base_url` and a path at or below its path; one that does not is a defect of the bridge, `AddressOutsideBase`, a failing server to the run, and nothing is sent, so the entry's headers and query never reach another host, port or path. The answer (`src/openapi/api-answers.ts`) is read within the call's deadline and `httpAnswerBytes`, counted as an MCP server's answer is (`counted` of `src/clients/observed-requests.ts`, which the observing fetch of the MCP client is built on: the observing fetch itself finds a request's JSON-RPC id in the body it sends, which a call of an HTTP API has none of): JSON, `application/json` or a type ending in `+json`, is the tool's structured content and its text, a `text/*` answer its text, an empty answer no content, and any other media type, or none, one block noted and not shown, `[binary content (image/png), not shown]`, as content of another type from an MCP server is. A 401 is a key the server refused, minted once more with an `auth` block before it is, a 429 waits out a `Retry-After` that fits the longest wait, as an MCP server's does, and a 5xx is a failing server, each counted toward the run's failures (`AnsweredStatus`, which the failure mapping of `src/clients/server-failures.ts` reads as it reads the MCP client's errors); a 3xx is an error result naming the redirect and its status, since the call is sent with `redirect: 'manual'` and nothing goes to the `Location`, which would carry the entry's headers and token to an address its settings do not name (a call of an MCP server goes through the SDK's transport, which this leaves as it is); any other answer, a 404 among them, is an error result carrying the body's text, a problem document where the API answers one, or the status when it has no body, since to an HTTP API a 404 is an answer about the resource and not a lost session. The answer carries no JSON-RPC id, so the facts of a call of an HTTP API have `jsonrpc_id: null`, and `request_id` names a response header. Listing an HTTP API asks it nothing: the document is the list. +A call (`src/openapi/api-requests.ts`) fills each path parameter, encoded, a list joined by commas, and the query, form-encoded, a list as repeated keys, or as one key whose items are joined by commas where the document writes the parameter with `explode: false`, a comma inside an item encoded and the commas between them not, and `null` or an empty list left out, then the entry's `query_params` and a credential the operation's alternative places in the query; sends the body as JSON in the operation's media type with the entry's headers, the credentials of the alternative it sends and `accept: application/json`, and no `_meta`, which an HTTP API has no place for; and refuses, as `arguments_refused` and without sending, an argument the operation does not take, a path it cannot fill, a path argument that is empty or holds a `/`, or makes a segment `.` or `..`, which would take the call out of the operation's path, an object outside the body and a missing body the operation needs. Before it is sent, the address is read as a URL and must have the origin of `base_url` and a path at or below its path; one that does not is a defect of the bridge, `AddressOutsideBase`, a failing server to the run, and nothing is sent, so the entry's headers and query never reach another host, port or path. The answer (`src/openapi/api-answers.ts`) is read within the call's deadline and `httpAnswerBytes`, counted as an MCP server's answer is (`counted` of `src/clients/observed-requests.ts`, which the observing fetch of the MCP client is built on: the observing fetch itself finds a request's JSON-RPC id in the body it sends, which a call of an HTTP API has none of): JSON, `application/json` or a type ending in `+json`, is the tool's structured content and its text, a `text/*` answer its text, an empty answer no content, and any other media type, or none, one block noted and not shown, `[binary content (image/png), not shown]`, as content of another type from an MCP server is. A 401 is a key the server refused, minted once more with an `auth` block before it is, a 429 waits out a `Retry-After` that fits the longest wait, as an MCP server's does, and a 5xx is a failing server, each counted toward the run's failures (`AnsweredStatus`, which the failure mapping of `src/clients/server-failures.ts` reads as it reads the MCP client's errors); a 3xx is an error result naming the redirect and its status, since the call is sent with `redirect: 'manual'` and nothing goes to the `Location`, which would carry the entry's headers and token to an address its settings do not name (a call of an MCP server goes through the SDK's transport, which this leaves as it is); any other answer, a 404 among them, is an error result carrying the body's text, a problem document where the API answers one, or the status when it has no body, since to an HTTP API a 404 is an answer about the resource and not a lost session. The answer carries no JSON-RPC id, so the facts of a call of an HTTP API have `jsonrpc_id: null`, and `request_id` names a response header. Listing an HTTP API asks it nothing: the document is the list. ### The bridge, measured @@ -160,7 +160,7 @@ Each entry has one link. An `mcp` link opens a session when a run or a listing f An `openapi` link holds no session: its client is the document's operations and the entry's headers and query parameters, made when a run, a listing or a call first takes it, and taking it asks the API nothing. -With an `auth` block, tokens come from the MCP client's client-credentials or private-key provider, with the issuer pinned: the client sends the credential to no authorization server whose metadata names another issuer. For an `mcp` entry the authorization server is found through the server's protected-resource metadata, as the MCP client does; for an `openapi` entry the provider is given the issuer as the authorization server it already knows (`discoveryState`, the hook of the MCP client for one), so its metadata is read from the issuer by OAuth authorization server metadata or OpenID Connect discovery, since an HTTP API publishes no protected-resource metadata: the client still asks the API for it, at the base URL's path and at its root, once for each token it mints, and goes on without (`src/clients/token-source.ts`). One token is minted for every concurrent run, renewed a minute before it expires (or halfway through a shorter life), and minted again once when the server answers 401; a second 401 is a failure. Every remote request goes through the `fetch` given, the server's outbound fetch, so `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` apply, and a certificate the server does not trust is a fixed message. +With an `auth` block, tokens come from the MCP client's client-credentials or private-key provider, with the issuer pinned: the client sends the credential to no authorization server whose metadata names another issuer. For an `mcp` entry the authorization server is found through the server's protected-resource metadata, as the MCP client does; for an `openapi` entry the provider is given the issuer as the authorization server it already knows (`discoveryState`, the hook of the MCP client for one), so its metadata is read from the issuer by OAuth authorization server metadata or OpenID Connect discovery, since an HTTP API publishes no protected-resource metadata: the client still asks the API for it, at the base URL's path and at its root, once for each token it mints, and goes on without (`src/clients/token-source.ts`). One token is minted for every concurrent run, renewed a minute before it expires (or halfway through a shorter life), and minted again once when the server answers 401; a second 401 is a failure. Every remote request goes through the `fetch` given, the server's outbound fetch, the read of a document, the reads of metadata, the requests of tokens and the calls alike, so `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` apply, the `User-Agent` the server's fetch sets where a request sets none, `auto-runtime/`, the name of the runtime's command and the version of the release the server announces to an MCP server in `clientInfo` (`withUserAgent` of `@on.auto/outbound`, given to `describedToolServers` and `makeToolAccess` in `packages/server/src/composition/served-tool-access.ts`), reaches each of them, a `User-Agent` among an entry's `headers` taking its place on the entry's calls alone (`src/access/outbound-requests.test.ts`), and a certificate the server does not trust is a fixed message. The MCP client has no logging option. The errors it meets, such as a line from a process that is not JSON-RPC or a stream it could not read, reach the client's `onerror`, which reports them through `reportServerMessage` as the messages of their server, scrubbed. They are bounded as the stderr of a `stdio` process is: at most 100 of 2000 characters for each process started or session opened, then one line saying the rest is not shown. The few warnings it writes to the console about how it was configured are left on stderr: catching them would mean patching the global console of the whole server, which every other library writes to as well. @@ -206,8 +206,8 @@ The arguments, as `JSON.stringify` writes them, and the protocol answer are scru `@on.auto/tool-servers/testing` holds a fake MCP server built with the MCP server package (`src/mcp-testing`), and a fake HTTP API (`src/api-testing`): -- `serveFakeMcp({ bearer?, client?, requestIdHeader?, issuesSessionIds?, annotated?, hints?, chat?, data? })` serves it over Streamable HTTP on a loopback port the system picks, with a bearer check or a fake authorization server, sessions, or none with `issuesSessionIds: false`, its tools listed without their annotations with `annotated: false`, its tools' annotations replaced by `hints`, and ways to answer the next requests with a status (`answerNextWith`, for a 429 with `Retry-After`, or `answerNextOf` for the next of one method alone, such as `tools/call`), answer the next tool call after messages that are not JSON-RPC (`answerNextCallAfterNoise`), forget every session (a 404), revoke tokens (a 401), remove a tool and send `list_changed`. With `chat: true` it also holds a chat, `chat`, whose `post_message` posts a message and answers where it landed and whose `thread_replies` lists the messages of a conversation or of one thread after a point, oldest first, and a test replies as a person with `chat.reply`. With `data: true` it also holds tools whose answers a call reads: `thread`, structured content beside a summary text, the replies of `threadReplies`; `pages`, one page and the cursor of the next; `blocks`, two text blocks of JSON; `strict`, which refuses an argument that is not a whole number with -32602; and `promised`, which declares an output schema of a count and answers `brokenPromise`, a count that is not a number. `holdNextOf` holds the next request of one method unanswered, such as a listing that never comes. A request its handler fails on is answered with a 500 and recorded in `failures()`, beside the requests in `seen()`, instead of rejecting where no test awaits it. -- `serveFakeApi({ key?, client?, version?, requestIdHeader? })` serves a fake HTTP API on a loopback port, with its OpenAPI document, `fakeApiDocument('3.0' | '3.1', serverUrl)`, at `documentUrl` and its operations under `baseUrl`: `search`, `echo`, `get_profiles_id`, `updateProfile`, `removeProfile`, `rows`, `denied`, `photo` and `notes` (`fakeApiToolNames`), and three operations no tool is offered for, one that needs a header, one that takes an object in its query and one whose body is XML. The 3.0 document writes `nullable` and a boolean `exclusiveMinimum` where the 3.1 one writes their 2020-12 forms, so the tools derived from either are the same. It takes a key in `X-Api-Key` or in the query as `api_key`, or with `client` a token from a fake authorization server on another loopback origin, `issuing.issuer`, whose metadata it publishes by OpenID Connect discovery alone with `openIdConnect: true`; `answerNextWith({ status, headers?, body? }, times?)` answers the next calls so, `seen()` holds every request but the document's, and `documentReads()` counts the reads of the document. `describedAccess(servers, options)` is `reportingAccess` for entries of any type, reading their documents first. +- `serveFakeMcp({ bearer?, client?, requestIdHeader?, issuesSessionIds?, annotated?, hints?, chat?, data? })` serves it over Streamable HTTP on a loopback port the system picks, with a bearer check or a fake authorization server, sessions, or none with `issuesSessionIds: false`, its tools listed without their annotations with `annotated: false`, its tools' annotations replaced by `hints`, and ways to answer the next requests with a status (`answerNextWith`, for a 429 with `Retry-After`, or `answerNextOf` for the next of one method alone, such as `tools/call`), answer the next tool call after messages that are not JSON-RPC (`answerNextCallAfterNoise`), forget every session (a 404), revoke tokens (a 401), remove a tool and send `list_changed`. With `chat: true` it also holds a chat, `chat`, whose `post_message` posts a message and answers where it landed and whose `thread_replies` lists the messages of a conversation or of one thread after a point, oldest first, and a test replies as a person with `chat.reply`. With `data: true` it also holds tools whose answers a call reads: `thread`, structured content beside a summary text, the replies of `threadReplies`; `pages`, one page and the cursor of the next; `blocks`, two text blocks of JSON; `strict`, which refuses an argument that is not a whole number with -32602; and `promised`, which declares an output schema of a count and answers `brokenPromise`, a count that is not a number. `holdNextOf` holds the next request of one method unanswered, such as a listing that never comes. A request its handler fails on is answered with a 500 and recorded in `failures()`, beside the requests in `seen()` and the method, the path and the `User-Agent` of every request it heard in `heard()`, instead of rejecting where no test awaits it. +- `serveFakeApi({ key?, client?, version?, requestIdHeader? })` serves a fake HTTP API on a loopback port, with its OpenAPI document, `fakeApiDocument('3.0' | '3.1', serverUrl)`, at `documentUrl` and its operations under `baseUrl`: `search`, `echo`, `get_profiles_id`, `updateProfile`, `removeProfile`, `rows`, `denied`, `photo` and `notes` (`fakeApiToolNames`), and three operations no tool is offered for, `sessionState`, which needs the header `X-Session` and is offered to an entry whose `headers` give it, answering the value it was sent, one that takes an object in its query and one whose body is XML. The 3.0 document writes `nullable` and a boolean `exclusiveMinimum` where the 3.1 one writes their 2020-12 forms, so the tools derived from either are the same. It takes a key in `X-Api-Key` or in the query as `api_key`, or with `client` a token from a fake authorization server on another loopback origin, `issuing.issuer`, whose metadata it publishes by OpenID Connect discovery alone with `openIdConnect: true`; `answerNextWith({ status, headers?, body? }, times?)` answers the next calls so, `seen()` holds every request but the document's, `heard()` the method, the path and the `User-Agent` of every request it and its authorization server heard, the document's among them, and `documentReads()` counts the reads of the document. `describedAccess(servers, options)` is `reportingAccess` for entries of any type, reading their documents first. - `fakeStdioServerPath` runs the same tools over stdio under `node`, with `--chatter`, `--pad`, `--stdout`, `--linger-ms` and `--start-once` to make it talk, misbehave or fail to start again. Start it with `process.execPath` directly, never through a package manager: it imports only the MCP server package and the fake's tools, not `effect`, and answers its first message in about 90 ms on a laptop. A spawn is real time, so a test that starts it takes `stdioTestTimeoutMs`, 30 s, and its client `patientTiming`, and a file shares one process across the tests that only need it running. - 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. diff --git a/packages/tool-servers/src/access/outbound-requests.test.ts b/packages/tool-servers/src/access/outbound-requests.test.ts new file mode 100644 index 000000000..0af801822 --- /dev/null +++ b/packages/tool-servers/src/access/outbound-requests.test.ts @@ -0,0 +1,135 @@ +import { withUserAgent } from '@on.auto/outbound'; +import { Effect, Option } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { fakeConnections, type FakeConnections } from '../testing/fake-connections.ts'; +import { + describedAccess, + patientTiming, + serveFakeApi, + serveFakeMcp, + type FakeApiServer, + type FakeMcpServer, +} from '../testing/index.ts'; + +const runtime = 'auto-runtime/0.0.0-test'; + +const clientSecret = 'graph-client-secret-81c2'; + +const client = { clientId: 'playbook', clientSecret, expiresInSeconds: 3600 }; + +const environment = { GRAPH_CLIENT_SECRET: clientSecret }; + +const closing: (() => Promise)[] = []; + +afterEach(async () => { + await Promise.all(closing.splice(0).map((close) => close())); +}); + +function authOf(issuer: string) { + return { issuer, client_id: 'playbook', client_secret: '${GRAPH_CLIENT_SECRET}' }; +} + +function issuerOf(api: FakeApiServer): string { + return Option.getOrThrow(Option.fromNullishOr(api.issuing)).issuer; +} + +async function fakes(): Promise<{ readonly api: FakeApiServer; readonly mcp: FakeMcpServer }> { + const api = await serveFakeApi({ client }); + const mcp = await serveFakeMcp({ client }); + closing.push(api.close, mcp.close); + return { api, mcp }; +} + +async function accessThrough(entries: Readonly>, connections = fakeConnections()) { + const { access } = await describedAccess(entries, { + environment, + timing: patientTiming, + fetch: withUserAgent(globalThis.fetch, runtime), + connections, + }); + closing.push(access.close); + return access; +} + +function connectedTo(ledger: FakeApiServer): FakeConnections { + const connections = fakeConnections(); + connections.connect('acme', { + id: 'ledger-id', + name: 'ledger', + entry: { document: ledger.documentUrl, auth: authOf(issuerOf(ledger)) }, + environment, + }); + return connections; +} + +function searched(access: Awaited>, server: string) { + return Effect.runPromise( + access.callOnce({ + org: 'acme', + playbook: 'alpha', + reference: { server, tool: 'search' }, + input: { query: 'acme' }, + meta: {}, + }), + ); +} + +describe('every request a tool access makes to a tool server', () => { + it('goes through the fetch it is given: the reads of a document and of metadata, the requests of tokens and the calls', async () => { + const { api, mcp } = await fakes(); + const ledger = await serveFakeApi({ client }); + closing.push(ledger.close); + const entries = { + billing: { org: 'acme', document: api.documentUrl, auth: authOf(issuerOf(api)) }, + graph: { org: 'acme', url: mcp.url, auth: authOf(mcp.origin) }, + }; + const access = await accessThrough(entries, connectedTo(ledger)); + + const answered = [ + await searched(access, 'billing'), + await searched(access, 'graph'), + await searched(access, 'ledger'), + ]; + const heard = [...api.heard(), ...mcp.heard(), ...ledger.heard()]; + + expect(answered).toMatchObject([{ outcome: 'result' }, { outcome: 'result' }, { outcome: 'result' }]); + expect(heard.map(({ method, path }) => `${method} ${path}`)).toEqual( + expect.arrayContaining([ + 'GET /openapi.json', + 'GET /.well-known/oauth-protected-resource/v1', + 'GET /.well-known/oauth-authorization-server', + 'POST /token', + 'GET /v1/search?query=acme', + 'GET /.well-known/oauth-protected-resource/mcp', + 'POST /mcp', + ]), + ); + expect(ledger.heard().map(({ method, path }) => `${method} ${path}`)).toEqual( + expect.arrayContaining(['GET /openapi.json', 'POST /token', 'GET /v1/search?query=acme']), + ); + expect(new Set(heard.map(({ userAgent }) => userAgent))).toEqual(new Set([runtime])); + }); +}); + +describe('the User-Agent of a request a tool access makes to a tool server', () => { + it('is the one among the headers of an entry on its calls, and the one of its fetch elsewhere', async () => { + const { api, mcp } = await fakes(); + const own = { 'User-Agent': 'operator-agent/2.0' }; + const access = await accessThrough({ + billing: { org: 'acme', document: api.documentUrl, auth: authOf(issuerOf(api)), headers: own }, + graph: { org: 'acme', url: mcp.url, auth: authOf(mcp.origin), headers: own }, + }); + + await searched(access, 'billing'); + await searched(access, 'graph'); + const agentOf = (path: string) => [...api.heard(), ...mcp.heard()].find((heard) => heard.path === path)?.userAgent; + + expect(['/v1/search?query=acme', '/mcp', '/openapi.json', '/token'].map((path) => agentOf(path))).toEqual([ + 'operator-agent/2.0', + 'operator-agent/2.0', + runtime, + runtime, + ]); + }); +}); diff --git a/packages/tool-servers/src/api-testing/derived-documents.ts b/packages/tool-servers/src/api-testing/derived-documents.ts index 2c058e531..86f8540e7 100644 --- a/packages/tool-servers/src/api-testing/derived-documents.ts +++ b/packages/tool-servers/src/api-testing/derived-documents.ts @@ -5,9 +5,10 @@ import { decodedDocument, type DocumentProblem } from '../openapi/api-document.t import { derivedTools, type DerivedTools } from '../openapi/api-tools.ts'; import type { Presented } from '../openapi/security-schemes.ts'; -const keyPresented: Presented = { +export const keyPresented: Presented = { security: new Map([['apiKeyAuth', Redacted.make('graph-api-key-4f1d9a7c2b')]]), auth: false, + headers: new Set(), }; type DocumentFields = Readonly>; diff --git a/packages/tool-servers/src/api-testing/fake-api-routes.ts b/packages/tool-servers/src/api-testing/fake-api-routes.ts index 73a8ce59a..dfa787c94 100644 --- a/packages/tool-servers/src/api-testing/fake-api-routes.ts +++ b/packages/tool-servers/src/api-testing/fake-api-routes.ts @@ -49,6 +49,7 @@ const routes: ReadonlyMap = new Map([ ['GET /profiles/ada', () => Response.json({ name: 'Ada', rows: 2 })], ['PUT /profiles/ada', async (request) => Response.json({ updated: bodyOf(await request.text()) })], ['GET /rows', (_request, url) => Response.json({ tags: url.searchParams.getAll('tag') })], + ['GET /sessions', (request) => Response.json({ session: request.headers.get('x-session') })], ['GET /photo', () => new Response(pixel, { headers: { 'content-type': 'image/png' } })], ['GET /notes', () => new Response('', { status: 200 })], ]); diff --git a/packages/tool-servers/src/api-testing/fake-api-server.ts b/packages/tool-servers/src/api-testing/fake-api-server.ts index 5eddf3b4a..2b7099bbe 100644 --- a/packages/tool-servers/src/api-testing/fake-api-server.ts +++ b/packages/tool-servers/src/api-testing/fake-api-server.ts @@ -6,7 +6,7 @@ import { type FakeAuthorization, } from '../mcp-testing/fake-authorization.ts'; import { deniedText } from '../mcp-testing/fake-tools.ts'; -import { serveOnLoopback, type FetchHandler } from '../testing/loopback-server.ts'; +import { serveOnLoopback, type FetchHandler, type LoopbackServer } from '../testing/loopback-server.ts'; import { fakeApiDocument, type FakeApiVersion } from './fake-api-document.ts'; import { answerTo, type ApiAnswering, type ProgrammedAnswer, type SeenApiRequest } from './fake-api-routes.ts'; @@ -30,6 +30,7 @@ export interface FakeApiServer { readonly documentUrl: string; readonly issuing: FakeIssuing | undefined; readonly seen: () => readonly SeenApiRequest[]; + readonly heard: LoopbackServer['heard']; readonly documentReads: () => number; readonly answerNextWith: (answer: ProgrammedAnswer, times?: number) => void; readonly close: () => Promise; @@ -37,6 +38,7 @@ export interface FakeApiServer { interface Issuing extends FakeIssuing { readonly accepts: (token: string) => boolean; + readonly heard: LoopbackServer['heard']; readonly close: () => Promise; } @@ -120,6 +122,7 @@ async function issuingFor(client: ClientRegistration): Promise { each.revokeEveryToken(); } }, + heard: listening.heard, close: listening.close, }; } @@ -146,12 +149,14 @@ export async function serveFakeApi(options: FakeApiOptions = {}): Promise apiHandler(options, answering, issuing)); + const hearing = issuing === undefined ? [listening] : [listening, issuing]; return { origin: listening.origin, baseUrl: `${listening.origin}${basePath}`, documentUrl: `${listening.origin}${documentPath}`, issuing, seen: () => [...seen], + heard: () => hearing.flatMap((each) => each.heard()), documentReads: () => documentReads, answerNextWith: (answer, times = 1) => { programmed.push(...Array.from({ length: times }, () => answer)); diff --git a/packages/tool-servers/src/mcp-testing/fake-mcp-server.ts b/packages/tool-servers/src/mcp-testing/fake-mcp-server.ts index f03f64055..c856e6881 100644 --- a/packages/tool-servers/src/mcp-testing/fake-mcp-server.ts +++ b/packages/tool-servers/src/mcp-testing/fake-mcp-server.ts @@ -1,7 +1,7 @@ import { OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server'; import { Option, Schema } from 'effect'; -import { serveOnLoopback, type FailedRequest, type FetchHandler } from '../testing/loopback-server.ts'; +import { serveOnLoopback, type FetchHandler, type LoopbackServer } from '../testing/loopback-server.ts'; import { fakeAuthorization, type ClientRegistration, type FakeAuthorization } from './fake-authorization.ts'; import { fakeChat, type FakeChat } from './fake-chat.ts'; import { fakeSessions, type FakeSessions } from './fake-sessions.ts'; @@ -35,7 +35,8 @@ export interface FakeMcpServer { readonly url: string; readonly origin: string; readonly seen: () => readonly SeenRequest[]; - readonly failures: () => readonly FailedRequest[]; + readonly heard: LoopbackServer['heard']; + readonly failures: LoopbackServer['failures']; readonly received: () => readonly ReceivedCall[]; readonly chat: FakeChat; readonly openSessions: () => number; @@ -274,6 +275,7 @@ export async function serveFakeMcp(options: FakeMcpOptions = {}): Promise [...seen], + heard: listening.heard, failures: listening.failures, received: () => [...received], chat, diff --git a/packages/tool-servers/src/openapi/api-calls.test.ts b/packages/tool-servers/src/openapi/api-calls.test.ts index 37d8f3b66..9f0c544e5 100644 --- a/packages/tool-servers/src/openapi/api-calls.test.ts +++ b/packages/tool-servers/src/openapi/api-calls.test.ts @@ -120,6 +120,22 @@ describe('the request of a call of a tool of an HTTP API', () => { }); }); +describe('a call of an operation of an HTTP API that needs a header', () => { + it('sends a header the operation needs from the headers of its entry, which give the model no input for it', async () => { + const fake = await fakeApi(); + const session = { 'x-session': 'session-of-acme' }; + const access = await accessTo({ ...keyed(fake), headers: session }); + + const answered = await called(access, 'sessionState'); + + expect(answered).toMatchObject({ + outcome: 'result', + answer: { structuredContent: { session: 'session-of-acme' } }, + }); + expect(fake.seen()[0]).toMatchObject({ path: '/v1/sessions', query: '', headers: session }); + }); +}); + describe('the query of a call of a tool of an HTTP API', () => { it('carries the query parameters of its entry beside the key its security scheme places in the query', async () => { const fake = await fakeApi(); diff --git a/packages/tool-servers/src/openapi/api-client.test.ts b/packages/tool-servers/src/openapi/api-client.test.ts index 37e435fa6..e9e046d05 100644 --- a/packages/tool-servers/src/openapi/api-client.test.ts +++ b/packages/tool-servers/src/openapi/api-client.test.ts @@ -1,6 +1,7 @@ import { Redacted } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; +import { documentOf, toolsOf } from '../api-testing/derived-documents.ts'; import type { CallSettled } from '../clients/server-client.ts'; import { failureOf } from '../clients/server-failures.ts'; import { serveFakeApi, type FakeApiServer } from '../testing/index.ts'; @@ -129,3 +130,37 @@ describe('the address of a call of an HTTP API, checked before it is sent', () = expect(other.seen()).toEqual([]); }); }); + +const unexploded = documentOf({ + '/rows': { + get: { + operationId: 'rows', + parameters: [{ name: 'tag', in: 'query', style: 'form', explode: false, schema: { type: 'array' } }], + }, + }, +}); + +describe('the query of a call of an HTTP API whose document writes a list with explode false', () => { + it('reaches the API as one key, its items joined by commas and a comma inside an item encoded', async () => { + const api = await elsewhere(); + const client = apiClient( + { + ...settings, + headers: new Map([['X-Api-Key', Redacted.make(apiKey)]]), + api: { baseUrl: api.baseUrl, operations: toolsOf(unexploded).operations }, + }, + { fetch: globalThis.fetch, tokens: undefined, now: Date.now }, + ); + + const settled = await client.call({ + tool: 'rows', + input: { tag: ['open', 'mine, too'] }, + meta: {}, + signal: new AbortController().signal, + timeoutMs: 1000, + }); + + expect(settled).toMatchObject({ result: { structuredContent: { tags: ['open,mine, too'] } } }); + expect(api.seen()).toMatchObject([{ path: '/v1/rows', query: '?tag=open,mine%2C+too' }]); + }); +}); diff --git a/packages/tool-servers/src/openapi/api-credentials.test.ts b/packages/tool-servers/src/openapi/api-credentials.test.ts index fdbb47677..1d7ffc079 100644 --- a/packages/tool-servers/src/openapi/api-credentials.test.ts +++ b/packages/tool-servers/src/openapi/api-credentials.test.ts @@ -63,6 +63,29 @@ async function sentWith(security: Readonly>) { }; } +const keyHeaderNeeded = { + openapi: '3.1.0', + info: { title: 'Graph', version: '1' }, + security: [{ bearerAuth: [] }, { apiKeyAuth: [] }], + paths: { + '/search': { + get: { + operationId: 'search', + parameters: [ + { name: 'query', in: 'query', required: true }, + { name: 'X-Api-Key', in: 'header', required: true }, + ], + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer' }, + apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-Api-Key' }, + }, + }, +}; + const basicOfKey = `Basic ${Buffer.from(`ada:${key}`).toString('base64')}`; describe('the credential of a security scheme, placed as the document says', () => { @@ -125,6 +148,29 @@ describe('the credentials of a call whose entry gives more than one scheme', () }); }); +describe('the credentials of a call whose operation needs the header one of its schemes places', () => { + it('are those of the alternative that places the header, though the entry gives an earlier one too', async () => { + const fake = await fakeApi(); + const document = await servedDocumentAt(() => Promise.resolve(Response.json(keyHeaderNeeded))); + const security = { bearerAuth: '${GRAPH_API_KEY}', apiKeyAuth: '${GRAPH_API_KEY}' }; + const reporting = await describedAccess( + { graph: { org: 'acme', document, base_url: fake.baseUrl, security } }, + { environment: { GRAPH_API_KEY: key }, timing }, + ); + closing.push(reporting.access.close); + + const called = await searched(reporting); + const [seen] = fake.seen(); + + expect(called).toMatchObject({ outcome: 'result' }); + expect([seen?.path, seen?.headers['x-api-key'], seen?.headers['authorization']]).toEqual([ + '/v1/search', + key, + undefined, + ]); + }); +}); + describe('the security of an entry its document does not accept', () => { it('stops the start naming the entry and the scheme', async () => { const fake = await fakeApi(); diff --git a/packages/tool-servers/src/openapi/api-description.ts b/packages/tool-servers/src/openapi/api-description.ts index 40c63192f..20e37a530 100644 --- a/packages/tool-servers/src/openapi/api-description.ts +++ b/packages/tool-servers/src/openapi/api-description.ts @@ -71,7 +71,11 @@ export async function describedApi( if (Result.isFailure(read)) { return Result.fail(read.failure); } - const presented = { security: settings.security, auth: settings.auth !== null }; + const presented = { + security: settings.security, + auth: settings.auth !== null, + headers: new Set([...settings.headers.keys()].map((name) => name.toLowerCase())), + }; const derived = Result.flatMap(decodedDocument(read.success), (decoded) => Result.map(derivedTools(decoded, presented), (tools) => ({ document: decoded.document, tools })), ); diff --git a/packages/tool-servers/src/openapi/api-requests.test.ts b/packages/tool-servers/src/openapi/api-requests.test.ts index dd59ed29e..22dbc42b7 100644 --- a/packages/tool-servers/src/openapi/api-requests.test.ts +++ b/packages/tool-servers/src/openapi/api-requests.test.ts @@ -19,8 +19,8 @@ const listing: ApiOperation = { inputs: [ { name: 'folder', in: 'path' }, { name: 'id', in: 'path' }, - { name: 'tag', in: 'query' }, - { name: 'limit', in: 'query' }, + { name: 'tag', in: 'query', joinsLists: false }, + { name: 'limit', in: 'query', joinsLists: false }, ], body: null, credentials: [], @@ -140,6 +140,30 @@ describe('the request of a call of an HTTP API', () => { }); }); +describe('the query of a call of an HTTP API', () => { + it('joins by commas a list of a query parameter written with explode false, each item encoded and the commas not', () => { + const joined: ApiOperation = { + ...listing, + path: '/notes', + inputs: [ + { name: 'label', in: 'query', joinsLists: true }, + { name: 'state', in: 'query', joinsLists: true }, + { name: 'none', in: 'query', joinsLists: true }, + { name: 'tag', in: 'query', joinsLists: false }, + ], + }; + + expect( + Result.map( + apiRequestOf(joined, { label: ['red', 'a,b', 'x y'], state: 'open', none: [], tag: ['p', 'q'] }, endpoint), + ({ url }) => url, + ), + ).toEqual( + Result.succeed('https://graph.example.com/v1/notes?label=red,a%2Cb,x+y&state=open&tag=p&tag=q&tenant=acme'), + ); + }); +}); + describe('the arguments a call of an HTTP API cannot send', () => { it('refuses arguments the operation does not take, a path it cannot fill, an object outside the body, or a missing body', () => { const refusals = [ diff --git a/packages/tool-servers/src/openapi/api-requests.ts b/packages/tool-servers/src/openapi/api-requests.ts index b4abfd974..95dcd2031 100644 --- a/packages/tool-servers/src/openapi/api-requests.ts +++ b/packages/tool-servers/src/openapi/api-requests.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import { Predicate, Redacted, Result } from 'effect'; -import type { ApiOperation } from './api-tools.ts'; +import type { ApiOperation, QueryInput } from './api-tools.ts'; import type { Placement } from './security-schemes.ts'; export interface ApiEndpoint { @@ -82,31 +82,43 @@ function pathOf(operation: ApiOperation, input: Readonly : Result.fail('The path arguments would take the call out of the path of this operation, as a . or a .. does'); } +function formEncoded(text: string): string { + return new URLSearchParams([['', text]]).toString().slice('='.length); +} + +function pairOf(name: string, values: readonly string[]): string { + return `${formEncoded(name)}=${values.map((value) => formEncoded(value)).join(',')}`; +} + +function pairsOf({ name, joinsLists }: QueryInput, values: readonly string[]): readonly string[] { + if (values.length === 0) { + return []; + } + return joinsLists ? [pairOf(name, values)] : values.map((value) => pairOf(name, [value])); +} + function queryOf( operation: ApiOperation, input: Readonly>, endpoint: ApiEndpoint, ): Built { - const query = new URLSearchParams(); - for (const { name } of operation.inputs.filter((each) => each.in === 'query')) { - const values = scalarsOf(name, input[name]); + const pairs: string[] = []; + for (const queried of operation.inputs.filter((each) => each.in === 'query')) { + const values = scalarsOf(queried.name, input[queried.name]); if (Result.isFailure(values)) { return Result.fail(values.failure); } - for (const value of values.success) { - query.append(name, value); - } + pairs.push(...pairsOf(queried, values.success)); } for (const [name, value] of endpoint.query) { - query.append(name, Redacted.value(value)); + pairs.push(pairOf(name, [Redacted.value(value)])); } for (const placement of operation.credentials) { if (placement.kind === 'query') { - query.append(placement.name, Redacted.value(placement.value)); + pairs.push(pairOf(placement.name, [Redacted.value(placement.value)])); } } - const written = query.toString(); - return Result.succeed(written === '' ? '' : `?${written}`); + return Result.succeed(pairs.length === 0 ? '' : `?${pairs.join('&')}`); } function unknownArgument(operation: ApiOperation, input: Readonly>): string | undefined { diff --git a/packages/tool-servers/src/openapi/api-tools.test.ts b/packages/tool-servers/src/openapi/api-tools.test.ts index 5816344db..0146aef0c 100644 --- a/packages/tool-servers/src/openapi/api-tools.test.ts +++ b/packages/tool-servers/src/openapi/api-tools.test.ts @@ -232,8 +232,8 @@ describe('the parameters of the tools of an HTTP API', () => { additionalProperties: false, }); expect(operation?.inputs).toEqual([ - { name: 'q', in: 'query' }, - { name: 'n', in: 'query' }, + { name: 'q', in: 'query', joinsLists: false }, + { name: 'n', in: 'query', joinsLists: false }, ]); }); }); diff --git a/packages/tool-servers/src/openapi/api-tools.ts b/packages/tool-servers/src/openapi/api-tools.ts index 4d7c33f9b..baa12ef80 100644 --- a/packages/tool-servers/src/openapi/api-tools.ts +++ b/packages/tool-servers/src/openapi/api-tools.ts @@ -16,14 +16,22 @@ import { bodyOf, collected, parametersOf, type BodyOf, type JsonBody, type Place import { keepsItsSegments } from './api-requests.ts'; import { documentBounds } from './document-reading.ts'; import { bytesOf, componentsOf, inputSchemaOf, oversizedBecause, type Components } from './input-schemas.ts'; -import { leftOutBecause } from './left-out.ts'; -import { requirementOf, type Placement, type Presented } from './security-schemes.ts'; +import { leftOutBecause, requiredHeadersOf } from './left-out.ts'; +import { headersPlacedBy, requirementOf, type Placement, type Presented } from './security-schemes.ts'; -interface ApiInput { +interface PathInput { readonly name: string; - readonly in: 'path' | 'query'; + readonly in: 'path'; } +export interface QueryInput { + readonly name: string; + readonly in: 'query'; + readonly joinsLists: boolean; +} + +type ApiInput = PathInput | QueryInput; + export interface ApiOperation { readonly tool: ListedTool; readonly method: Method; @@ -116,10 +124,15 @@ function annotationsOf(method: Method): ToolAnnotations { }; } +function inputOf({ name, in: place, explode }: Parameter): readonly ApiInput[] { + if (place === 'path') { + return [{ name, in: 'path' }]; + } + return place === 'query' ? [{ name, in: 'query', joinsLists: explode === false }] : []; +} + function inputsOf(parameters: readonly Placed[]): readonly ApiInput[] { - return parameters.flatMap(({ value }) => - value.in === 'path' || value.in === 'query' ? [{ name: value.name, in: value.in }] : [], - ); + return parameters.flatMap(({ value }) => inputOf(value)); } function namedOperation({ method, path, operation }: Located): string { @@ -162,11 +175,16 @@ function derivedAt(read: ReadDocument, { components, presented }: Context, locat ...(Result.isFailure(body) ? body.failure : []), ]); } - const because = leftOutBecause(located.method, parameters.success, body.success, components); + const unnamed = requiredHeadersOf(parameters.success).filter((header) => !presented.headers.has(header)); + const requirement = requirementOf(read.document, located.operation.value, presented, new Set(unnamed)); + const placed = headersPlacedBy(requirement).map((name) => name.toLowerCase()); + const because = leftOutBecause(located.method, parameters.success, body.success, { + components, + sentHeaders: new Set([...presented.headers, ...placed]), + }); if (because !== undefined) { return Result.succeed(leftOut(located, because)); } - const requirement = requirementOf(read.document, located.operation.value, presented); if (requirement.kind === 'unmet') { return Result.succeed(leftOut(located, requirement.because)); } diff --git a/packages/tool-servers/src/openapi/described-servers.test.ts b/packages/tool-servers/src/openapi/described-servers.test.ts index e981f4a1e..2c5b1105b 100644 --- a/packages/tool-servers/src/openapi/described-servers.test.ts +++ b/packages/tool-servers/src/openapi/described-servers.test.ts @@ -15,6 +15,10 @@ const notes = const keyed = { apiKeyAuth: '${GRAPH_API_KEY}' }; +const sessionStateTool: unknown = expect.objectContaining({ name: 'sessionState' }); + +const offersSessionState: unknown = expect.arrayContaining([expect.objectContaining({ tool: sessionStateTool })]); + async function fakeApi(): Promise { const fake = await serveFakeApi(); onTestFinished(fake.close); @@ -59,6 +63,21 @@ describe('the document of an openapi server, read from its URL at start', () => }); }); +describe('the operations of an openapi server that need a header', () => { + it('are offered where the headers of the entry give each of them, whatever the case either writes', async () => { + const fake = await fakeApi(); + const headers = { 'X-SESSION': 'session-of-acme' }; + const { effect, leftOut } = describing({ + graph: { document: fake.documentUrl, org: 'acme', security: keyed, headers }, + }); + + const { servers } = await Effect.runPromise(effect); + + expect(servers).toMatchObject([{ api: { operations: offersSessionState } }]); + expect(leftOut.map(({ operation }) => operation)).toEqual(['filtered (GET /filtered)', 'upload (POST /uploads)']); + }); +}); + describe('the document of an openapi server, read from a file at start', () => { it('is JSON or YAML in the directory of the configuration file', async () => { const directory = directoryWith({ diff --git a/packages/tool-servers/src/openapi/left-out.test.ts b/packages/tool-servers/src/openapi/left-out.test.ts index 8e0e8eb23..fe5090786 100644 --- a/packages/tool-servers/src/openapi/left-out.test.ts +++ b/packages/tool-servers/src/openapi/left-out.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { documentOf, toolNamed, toolsOf } from '../api-testing/derived-documents.ts'; +import { documentOf, keyPresented, toolNamed, toolsOf } from '../api-testing/derived-documents.ts'; import { fakeApiDocument } from '../testing/index.ts'; describe('an operation of an HTTP API that no tool is offered for', () => { @@ -8,7 +8,7 @@ describe('an operation of an HTTP API that no tool is offered for', () => { expect(toolsOf(fakeApiDocument('3.1', '/v1')).leftOut).toEqual([ { operation: 'sessionState (GET /sessions)', - because: 'it needs the header X-Session, which a function cannot send', + because: 'it needs the header X-Session, which a function cannot send; the headers of the entry can give it', }, { operation: 'filtered (GET /filtered)', @@ -47,8 +47,93 @@ describe('an operation of an HTTP API that no tool is offered for', () => { }); }); +describe('an operation of an HTTP API that needs a header', () => { + it('is offered when the headers of its entry name each header it needs, in any case, which is then no input', () => { + const required = { in: 'header', required: true, schema: { type: 'string' } }; + const { operations, leftOut } = toolsOf( + documentOf({ + '/a': { + get: { + parameters: [ + { name: 'X-Client-Id', ...required }, + { name: 'q', in: 'query' }, + ], + }, + }, + '/b': { + get: { + parameters: [ + { name: 'X-Client-Id', ...required }, + { name: 'X-Account', ...required }, + ], + }, + }, + '/c': { + get: { + parameters: [ + { name: 'x-client-id', ...required }, + { name: 'session', in: 'cookie', required: true }, + ], + }, + }, + }), + { ...keyPresented, headers: new Set(['x-client-id']) }, + ); + + expect(operations.map(({ tool, inputs }) => [tool.name, tool.inputSchema['properties'], inputs])).toEqual([ + ['get_a', { q: {} }, [{ name: 'q', in: 'query', joinsLists: false }]], + ]); + expect(leftOut).toEqual([ + { + operation: 'GET /b', + because: 'it needs the header X-Account, which a function cannot send; the headers of the entry can give it', + }, + { operation: 'GET /c', because: 'it needs the cookie session, which a function cannot send' }, + ]); + }); +}); + +describe('a header parameter of an HTTP API that the OpenAPI specification says to ignore', () => { + it('is Accept, Content-Type or Authorization in any case, never needed and never named in a refusal', () => { + const required = { in: 'header', required: true, schema: { type: 'string' } }; + const { operations, leftOut } = toolsOf( + documentOf({ + '/a': { + get: { + parameters: [ + { name: 'Accept', ...required }, + { name: 'content-type', ...required }, + { name: 'AUTHORIZATION', ...required }, + ], + }, + }, + }), + ); + + expect(operations.map(({ tool, inputs }) => [tool.name, inputs])).toEqual([['get_a', []]]); + expect(leftOut).toEqual([]); + }); + + it('is Host or Content-Length, which the runtime sets itself, said so where it leaves its operation out', () => { + const required = { in: 'header', required: true }; + const { leftOut } = toolsOf( + documentOf({ + '/a': { get: { parameters: [{ name: 'Host', ...required }] } }, + '/b': { get: { parameters: [{ name: 'content-length', ...required }] } }, + }), + ); + + expect(leftOut.map(({ because }) => because)).toEqual([ + 'it needs the header Host, which the runtime sets itself and neither a function nor the headers of the entry can give', + 'it needs the header content-length, which the runtime sets itself and neither a function nor the headers of the entry can give', + ]); + }); +}); + const listed = { name: 'tags', schema: { type: 'array', items: { type: 'string' } } }; +const scalar = { name: 'state', schema: { type: 'string', enum: ['open', 'closed'] } }; + describe('an operation of an HTTP API that a call could only send in another form, or not at all', () => { it('is TRACE, which fetch refuses, or a GET or HEAD that takes a body', () => { const json = { requestBody: { content: { 'application/json': {} } } }; @@ -61,41 +146,87 @@ describe('an operation of an HTTP API that a call could only send in another for ]); }); - it('writes a parameter in a style or with an explode other than the ones a call sends, a path as simple and a query as exploded form', () => { + it('writes a path parameter in a style other than simple, which is the style a call sends a path in', () => { const { operations, leftOut } = toolsOf( documentOf({ '/a/{id}': { get: { parameters: [{ name: 'id', in: 'path', required: true, style: 'label' }] }, put: { parameters: [{ name: 'id', in: 'path', required: true, style: 'simple', explode: true }] }, - }, - '/b': { - get: { parameters: [{ ...listed, in: 'query', explode: false }] }, - put: { parameters: [{ ...listed, in: 'query', style: 'pipeDelimited' }] }, - post: { parameters: [{ ...listed, in: 'query', style: 'form', explode: true }] }, delete: { parameters: [{ name: 'X-Trace', in: 'header', style: 'simple', explode: true }] }, }, }), ); - expect(operations.map(({ tool }) => tool.name)).toEqual(['put_a_id', 'post_b', 'delete_b']); + expect(operations.map(({ tool }) => tool.name)).toEqual(['put_a_id', 'delete_a_id']); expect(leftOut).toEqual([ { operation: 'GET /a/{id}', - because: 'its path parameter id is written with style label and explode true, a form a call does not send', - }, - { - operation: 'GET /b', - because: 'its query parameter tags is written with style form and explode false, a form a call does not send', - }, - { - operation: 'PUT /b', - because: - 'its query parameter tags is written with style pipeDelimited and explode true, a form a call does not send', + because: 'its path parameter id is written with style label, a form a call does not send', }, ]); }); }); +describe('a query parameter of an HTTP API, by the style its document writes it in', () => { + it('leaves its operation out as spaceDelimited, pipeDelimited or deepObject, whatever its schema', () => { + const { operations, leftOut } = toolsOf( + documentOf({ + '/b': { + get: { parameters: [{ ...listed, in: 'query', style: 'form', explode: true }] }, + put: { parameters: [{ ...listed, in: 'query', style: 'pipeDelimited' }] }, + post: { parameters: [{ ...listed, in: 'query', style: 'spaceDelimited', explode: false }] }, + patch: { parameters: [{ ...scalar, in: 'query', style: 'deepObject' }] }, + }, + }), + ); + + expect(operations.map(({ tool }) => tool.name)).toEqual(['get_b']); + expect(leftOut.map(({ operation, because }) => `${operation}: ${because}`)).toEqual([ + 'PUT /b: its query parameter tags is written with style pipeDelimited, a form a call does not send', + 'POST /b: its query parameter tags is written with style spaceDelimited, a form a call does not send', + 'PATCH /b: its query parameter state is written with style deepObject, a form a call does not send', + ]); + }); +}); + +describe('a query parameter of an HTTP API that its document writes as form with explode false', () => { + it('is offered, a scalar sent as written and a list joined by commas, but not an object', () => { + const unexploded = { in: 'query', style: 'form', explode: false }; + const filter = { name: 'f', ...unexploded, schema: { $ref: '#/components/schemas/Filter' } }; + const { operations, leftOut } = toolsOf( + documentOf( + { + '/b': { + get: { + parameters: [ + { ...listed, ...unexploded }, + { ...scalar, ...unexploded }, + ], + }, + put: { parameters: [{ name: 'count', in: 'query', explode: false, schema: { type: 'integer' } }] }, + post: { parameters: [filter] }, + }, + }, + { components: { schemas: { Filter: { type: 'object' } } } }, + ), + ); + + expect(operations.map(({ tool, inputs }) => [tool.name, inputs])).toEqual([ + [ + 'get_b', + [ + { name: 'tags', in: 'query', joinsLists: true }, + { name: 'state', in: 'query', joinsLists: true }, + ], + ], + ['put_b', [{ name: 'count', in: 'query', joinsLists: true }]], + ]); + expect(leftOut).toEqual([ + { operation: 'POST /b', because: 'its query parameter f is an object, which a call does not send in a query' }, + ]); + }); +}); + describe('the tools of an HTTP API, in documents of other shapes', () => { it('are none for a document of no paths', () => { expect(toolsOf({ openapi: '3.1.0' })).toEqual({ operations: [], leftOut: [] }); diff --git a/packages/tool-servers/src/openapi/left-out.ts b/packages/tool-servers/src/openapi/left-out.ts index bc81fda64..e6cc7b93b 100644 --- a/packages/tool-servers/src/openapi/left-out.ts +++ b/packages/tool-servers/src/openapi/left-out.ts @@ -1,10 +1,16 @@ import { JsonPointer, type Schema } from 'effect'; +import { headersTheRuntimeSets } from '../settings/reserved-headers.ts'; import type { Method, Parameter } from './api-document.ts'; import type { BodyOf, Placed } from './api-parameters.ts'; import type { Components } from './input-schemas.ts'; import { isSchemaObject } from './schema-translation.ts'; +interface OperationContext { + readonly components: Components; + readonly sentHeaders: ReadonlySet; +} + const mostReferenceHops = 8; const componentReference = '#/components/schemas/'; @@ -34,11 +40,14 @@ function sharedNameOf(names: readonly string[]): string | undefined { return names.find((name, index) => names.indexOf(name) < index); } -function isSentAsWritten({ in: place, style, explode }: Parameter): boolean { - if (place === 'path') { - return style === undefined || style === 'simple'; - } - return place !== 'query' || ((style === undefined || style === 'form') && explode !== false); +const sentStyles: ReadonlyMap = new Map([ + ['path', 'simple'], + ['query', 'form'], +]); + +function unsentStyleOf({ in: place, style }: Parameter): string | undefined { + const sentStyle = sentStyles.get(place); + return style === undefined || sentStyle === undefined || style === sentStyle ? undefined : style; } function methodBecause(method: Method, body: BodyOf): string | undefined { @@ -51,27 +60,63 @@ function methodBecause(method: Method, body: BodyOf): string | undefined { } function serializedBecause(sent: readonly Parameter[]): string | undefined { - const styled = sent.find((each) => !isSentAsWritten(each)); - return styled === undefined - ? undefined - : `its ${styled.in} parameter ${styled.name} is written with style ${styled.style ?? 'form'} and explode ${String(styled.explode ?? true)}, a form a call does not send`; + for (const each of sent) { + const style = unsentStyleOf(each); + if (style !== undefined) { + return `its ${each.in} parameter ${each.name} is written with style ${style}, a form a call does not send`; + } + } + return undefined; +} + +const ignoredHeaders: ReadonlySet = new Set(['accept', 'content-type', 'authorization']); + +const runtimeHeaders = headersTheRuntimeSets('openapi').names; + +function requiredHeaderOf({ in: place, name, required }: Parameter): string | undefined { + const header = name.toLowerCase(); + return place === 'header' && required === true && !ignoredHeaders.has(header) ? header : undefined; +} + +export function requiredHeadersOf(parameters: readonly Placed[]): readonly string[] { + return parameters.flatMap(({ value }) => { + const header = requiredHeaderOf(value); + return header === undefined ? [] : [header]; + }); +} + +function isUnsent(parameter: Parameter, sentHeaders: ReadonlySet): boolean { + const header = requiredHeaderOf(parameter); + return ( + (parameter.in === 'cookie' && parameter.required === true) || (header !== undefined && !sentHeaders.has(header)) + ); +} + +function unsentWords({ in: place, name }: Parameter): string { + if (place === 'cookie') { + return `it needs the cookie ${name}, which a function cannot send`; + } + return runtimeHeaders.has(name.toLowerCase()) + ? `it needs the header ${name}, which the runtime sets itself and neither a function nor the headers of the entry can give` + : `it needs the header ${name}, which a function cannot send; the headers of the entry can give it`; +} + +function neededBecause(sent: readonly Parameter[], sentHeaders: ReadonlySet): string | undefined { + const needed = sent.find((each) => isUnsent(each, sentHeaders)); + return needed === undefined ? undefined : unsentWords(needed); } export function leftOutBecause( method: Method, parameters: readonly Placed[], body: BodyOf, - components: Components, + { components, sentHeaders }: OperationContext, ): string | undefined { const sent = parameters.map(({ value }) => value); - const unsendable = methodBecause(method, body) ?? serializedBecause(sent); + const unsendable = methodBecause(method, body) ?? serializedBecause(sent) ?? neededBecause(sent, sentHeaders); if (unsendable !== undefined) { return unsendable; } - const needed = sent.find((each) => (each.in === 'header' || each.in === 'cookie') && each.required === true); - if (needed !== undefined) { - return `it needs the ${needed.in} ${needed.name}, which a function cannot send`; - } const objectQuery = sent.find((each) => each.in === 'query' && isObjectSchema(each.schema, components)); if (objectQuery !== undefined) { return `its query parameter ${objectQuery.name} is an object, which a call does not send in a query`; diff --git a/packages/tool-servers/src/openapi/security-schemes.test.ts b/packages/tool-servers/src/openapi/security-schemes.test.ts index eb0f027a4..ed93cb463 100644 --- a/packages/tool-servers/src/openapi/security-schemes.test.ts +++ b/packages/tool-servers/src/openapi/security-schemes.test.ts @@ -20,7 +20,7 @@ const schemes: Schema.JsonObject = { }; function presented(names: readonly string[], auth = false): Presented { - return { security: new Map(names.map((name) => [name, key])), auth }; + return { security: new Map(names.map((name) => [name, key])), auth, headers: new Set() }; } function securedDocument(paths: Schema.JsonObject, security?: Schema.Json): Readonly> { @@ -84,6 +84,84 @@ describe('the requirements of an operation of an HTTP API', () => { }); }); +const needsTheKey = { parameters: [{ name: 'x-api-key', in: 'header', required: true }] }; + +describe('a header an operation of an HTTP API needs, which its security places', () => { + it('is met by the key of the alternative that places it, its own or that of the document, before an earlier one', () => { + const paths = { + '/a': { get: { operationId: 'a', security: [{ headerKey: [] }], ...needsTheKey } }, + '/b': { get: { operationId: 'b', security: [{ bearer: [] }, { headerKey: [] }], ...needsTheKey } }, + '/c': { get: { operationId: 'c', ...needsTheKey } }, + }; + const sentKey = [{ kind: 'header', name: 'X-Api-Key', value: key }]; + + expect(credentialsOf(paths, presented(['headerKey', 'bearer']), [{ headerKey: [] }])).toEqual({ + offered: [ + ['a', sentKey], + ['b', sentKey], + ['c', sentKey], + ], + leftOut: [], + }); + }); + + it('is not met by an alternative that places no such header, the only one the entry gives', () => { + const paths = { + '/b': { get: { operationId: 'b', security: [{ bearer: [] }, { headerKey: [] }], ...needsTheKey } }, + }; + + expect(credentialsOf(paths, presented(['bearer'])).leftOut).toEqual([ + { + operation: 'b (GET /b)', + because: 'it needs the header x-api-key, which a function cannot send; the headers of the entry can give it', + }, + ]); + }); + + it('leaves its operation out in the words of its security where the entry gives none of it', () => { + const paths = { '/a': { get: { operationId: 'a', security: [{ headerKey: [] }], ...needsTheKey } } }; + + expect(credentialsOf(paths, presented([])).leftOut).toEqual([ + { + operation: 'a (GET /a)', + because: + 'it needs the security scheme headerKey of the document, which the security and the auth of the entry do not give', + }, + ]); + }); +}); + +describe('the headers an operation of an HTTP API needs beyond what its security places', () => { + it('chooses the key that places one of two headers it needs, and names the other where it leaves it out', () => { + const security = [{ bearer: [] }, { headerKey: [] }]; + const required = { in: 'header', required: true }; + const paths = { + '/a': { get: { operationId: 'a', security, ...needsTheKey } }, + '/b': { + get: { operationId: 'b', security, parameters: [...needsTheKey.parameters, { name: 'X-Tenant', ...required }] }, + }, + '/c': { + get: { operationId: 'c', security, parameters: [...needsTheKey.parameters, { name: 'Host', ...required }] }, + }, + }; + + expect(credentialsOf(paths, presented(['headerKey', 'bearer']))).toEqual({ + offered: [['a', [{ kind: 'header', name: 'X-Api-Key', value: key }]]], + leftOut: [ + { + operation: 'b (GET /b)', + because: 'it needs the header X-Tenant, which a function cannot send; the headers of the entry can give it', + }, + { + operation: 'c (GET /c)', + because: + 'it needs the header Host, which the runtime sets itself and neither a function nor the headers of the entry can give', + }, + ], + }); + }); +}); + describe('the requirements an operation of an HTTP API takes from its document and its auth block', () => { it('take the requirement of the document where an operation has none, and the operation’s own where it has one', () => { const paths = { diff --git a/packages/tool-servers/src/openapi/security-schemes.ts b/packages/tool-servers/src/openapi/security-schemes.ts index de78df03d..297a8f850 100644 --- a/packages/tool-servers/src/openapi/security-schemes.ts +++ b/packages/tool-servers/src/openapi/security-schemes.ts @@ -15,11 +15,12 @@ export type Placement = export interface Presented { readonly security: ReadonlyMap; readonly auth: boolean; + readonly headers: ReadonlySet; } export type Requirement = | { readonly kind: 'met'; readonly placements: readonly Placement[] } - | { readonly kind: 'unmet'; readonly because: string }; + | { readonly kind: 'unmet'; readonly because: string; readonly headers: readonly string[] }; export interface SecuredEntry { readonly security: ReadonlyMap; @@ -85,21 +86,60 @@ function alternativesInWords(alternatives: readonly SecurityRequirement[]): stri return alternatives.map((alternative) => Object.keys(alternative).join(' with ')).join(', or '); } -export function requirementOf(document: ApiDocument, operation: Operation, presented: Presented): Requirement { +function headersNamedBy(alternatives: readonly SecurityRequirement[], schemes: Schemes): readonly string[] { + return alternatives.flatMap((alternative) => + Object.keys(alternative).flatMap((name) => { + const scheme = schemeNamed(schemes, name); + return scheme?.type === 'apiKey' && scheme.in === 'header' ? [scheme.name] : []; + }), + ); +} + +export function headersPlacedBy(requirement: Requirement): readonly string[] { + return requirement.kind === 'met' + ? requirement.placements.flatMap((placement) => (placement.kind === 'header' ? [placement.name] : [])) + : requirement.headers; +} + +function placesEvery(placements: readonly Placement[], headers: readonly string[]): boolean { + return headers.every((header) => + placements.some((placement) => placement.kind === 'header' && placement.name.toLowerCase() === header), + ); +} + +function placeableOf( + neededHeaders: ReadonlySet, + alternatives: readonly SecurityRequirement[], + schemes: Schemes, +): readonly string[] { + const named = new Set(headersNamedBy(alternatives, schemes).map((header) => header.toLowerCase())); + return [...neededHeaders].filter((header) => named.has(header)); +} + +export function requirementOf( + document: ApiDocument, + operation: Operation, + presented: Presented, + neededHeaders: ReadonlySet, +): Requirement { const alternatives = operation.security ?? document.security ?? []; const schemes = schemesOf(document); if (alternatives.length === 0) { return { kind: 'met', placements: [] }; } - for (const alternative of alternatives) { + const met = alternatives.flatMap((alternative) => { const placements = placementsOf(alternative, schemes, presented); - if (placements !== undefined) { - return { kind: 'met', placements }; - } + return placements === undefined ? [] : [placements]; + }); + const [first] = met; + if (first !== undefined) { + const placeable = placeableOf(neededHeaders, alternatives, schemes); + return { kind: 'met', placements: met.find((placements) => placesEvery(placements, placeable)) ?? first }; } return { kind: 'unmet', because: `it needs the security scheme ${alternativesInWords(alternatives)} of the document, which the security and the auth of the entry do not give`, + headers: headersNamedBy(alternatives, schemes), }; } diff --git a/packages/tool-servers/src/org-servers/connection-documents.test.ts b/packages/tool-servers/src/org-servers/connection-documents.test.ts index de45d021c..8b0725c80 100644 --- a/packages/tool-servers/src/org-servers/connection-documents.test.ts +++ b/packages/tool-servers/src/org-servers/connection-documents.test.ts @@ -100,6 +100,26 @@ describe('a connection to an HTTP API whose document cannot be read', () => { }); }); +describe('a connection to an HTTP API whose entry gives a header its document says an operation needs', () => { + it('offers the operation, and a call of it sends the header', async () => { + const fake = await serveFakeApi({ key: fakeApiKey }); + closing.push(fake.close); + const access = await accessThrough({ + entry: { + document: fake.documentUrl, + headers: { 'X-Session': 'session-of-acme' }, + security: { apiKeyAuth: '${GRAPH_API_KEY}' }, + }, + }); + + const called = await Effect.runPromise( + access.callOnce({ ...alpha, reference: { server: 'graph', tool: 'sessionState' }, input: {}, meta: {} }), + ); + + expect(called).toMatchObject({ outcome: 'result', answer: { structuredContent: { session: 'session-of-acme' } } }); + }); +}); + describe('a connection to an HTTP API whose document is refused for what it says', () => { it('is unavailable as failing in the words of its document, which is not read again for as long as the runtime runs', async () => { const reads = { made: 0 }; diff --git a/packages/tool-servers/src/testing/loopback-server.ts b/packages/tool-servers/src/testing/loopback-server.ts index a671fde2f..864af6c2c 100644 --- a/packages/tool-servers/src/testing/loopback-server.ts +++ b/packages/tool-servers/src/testing/loopback-server.ts @@ -4,14 +4,21 @@ import { Readable } from 'node:stream'; import { Schema } from 'effect'; -export interface FailedRequest { +interface FailedRequest { readonly method: string; readonly path: string; readonly error: string; } +interface HeardRequest { + readonly method: string; + readonly path: string; + readonly userAgent: string | undefined; +} + export interface LoopbackServer { readonly origin: string; + readonly heard: () => readonly HeardRequest[]; readonly failures: () => readonly FailedRequest[]; readonly close: () => Promise; } @@ -87,6 +94,7 @@ export async function serveOnLoopback(handlerAt: (origin: string) => FetchHandle server.listen(0, '127.0.0.1'); await once(server, 'listening'); const origin = `http://127.0.0.1:${portOf(server.address()).port}`; + const heard: HeardRequest[] = []; const failures: FailedRequest[] = []; const answering: Answering = { origin, @@ -96,10 +104,16 @@ export async function serveOnLoopback(handlerAt: (origin: string) => FetchHandle }, }; server.on('request', (incoming: IncomingMessage, outgoing: ServerResponse) => { + heard.push({ + method: String(incoming.method), + path: String(incoming.url), + userAgent: incoming.headers['user-agent'], + }); void respond(incoming, outgoing, answering); }); return { origin, + heard: () => [...heard], failures: () => [...failures], close: async () => { const closed = once(server, 'close');