diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index 3690ceff0..ea497746f 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -1872,7 +1872,8 @@ "url", "expires_at" ], - "additionalProperties": false + "additionalProperties": false, + "description": "The link that approves the connection, while it is pending and the link lasts" } }, "required": [ @@ -1890,11 +1891,10 @@ "connected_by", "granted_scope", "credential_ends_with", - "broken", - "connect" + "broken" ], "additionalProperties": false, - "description": "The connection, pending, and the link that approves it" + "description": "The connection, and the link that approves it while there is one to open" } } } diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 84e0da93a..0495f19e6 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -197,7 +197,7 @@ A connection is a tool server that a person adds to the org through their agent, - A connection is added by a person: where the runtime signs people in, `add_connection` refuses a key, and a runtime of API keys alone, which signs nobody in, refuses every connection, since no person could approve it. - The agent adds a connection without its credential: an entry that holds a value that looks like a credential, or a reference such as `${CRM_API_KEY}`, is refused. The person approves the connection, and gives its credential where it takes one, on Studio's page, which `connect.url` of the answer opens within ten minutes. -- `add_connection` always puts the link in its answer. To a client of the `2026-07-28` revision that declares in its request that it opens links, the `url` form of elicitation, it also asks for the link to be opened, with a message that says what to do. The client opens the page or declines, and retries the call, which answers the connection added once and never adds a second one. A retry by another caller, or in another org, is refused, and the connection stays added. A client of an earlier revision, whose requests carry no capabilities on this stateless server, is not asked; it shows the person the link from the answer, whose words say to show it as a link named `Approve the connection` with its provider's title, or `Approve the connection` for a server given by hand, never as its address. +- `add_connection` puts the link in its answer to the call that adds the connection. To a client of the `2026-07-28` revision that declares in its request that it opens links, the `url` form of elicitation, it also asks for the link to be opened, with a message that says what to do. The client opens the page or declines, and retries the call, which never adds a second connection and answers the connection as it stands then: `connected`, with no `connect`, in words that say nothing is left to open, once the person approved it; `pending` with its link while the link lasts, as when the person continued without approving it, in words that also say the person declined to open the page where the client declined or cancelled; `pending` with no `connect` once the link has expired, in words that say to remove it and add it again; and `removed`, in words that say nothing is connected, where the person cancelled on the page. A retry by another caller, or in another org, is refused, and the connection stays added. A client of an earlier revision, whose requests carry no capabilities on this stateless server, is not asked; it shows the person the link from the answer, whose words say to show it as a link named `Approve the <title> connection` with its provider's title, or `Approve the connection` for a server given by hand, never as its address. - `add_connection` takes one of two inputs. For a provider: `provider`, its name; `placeholders`, the value of each placeholder it takes; and, where they are wanted, `name`, the provider's when left out, `credential` with its `kind` alone, and when it is left out the first way `list_providers` marks offered, or the provider's first where it offers none, and `entry` with `playbooks`, `allowed` and `record_content` alone. The provider gives the rest, and `type`, the server in `entry` and the other fields of `credential` are refused. For a server given by hand: `name`, the name a function gives the server; `type`, `mcp` for a remote MCP server or `openapi` for an HTTP API; `entry`, the server without its credential; and `credential`, with no `placeholders`. - A provider the runtime does not have is refused at `/provider`, in words that say `search` lists the providers a person may connect, a placeholder or a value refused at `/placeholders/<name>`, and what the checks of an entry find in what the provider resolved at `/provider`. - The credential is one object: its `kind` and the fields that kind takes, `variable`, `name` and `in` for `reference`, `name` and `in` for `secret`, each with an optional `scheme` such as `Bearer` where `in` is `header`, which the runtime writes before the value, and `issuer`, an optional `client_id` and an optional `scope` for `client_credentials`. A `client_id` left out is given by the person on Studio's page, with the secret or the key. The kind `authorization` names a provider's way by which the person signs in to the provider and allows Auto: it is taken with `provider` alone, as the kind of its way, and refused for a server given by hand. A field its kind lacks or does not take is refused as `invalid_input` at that field. diff --git a/docs/self-host/connections.md b/docs/self-host/connections.md index 3234194b1..d3dd0dc9c 100644 --- a/docs/self-host/connections.md +++ b/docs/self-host/connections.md @@ -9,6 +9,7 @@ This page is for whoever runs Auto. It says how a connection is added, the provi 1. The person asks their agent to connect a service, such as their code host. Where Auto knows the service as a [provider](#the-providers-auto-ships), the agent calls `add_connection` with the provider's name and the values its placeholders take, such as a site's subdomain. Otherwise it gives the server by hand: its address, its options and how it is credentialled. Either way, never the credential itself. 2. Auto keeps the connection, `pending`, and answers with a link to Studio's page `connect`, which lasts ten minutes: `/studio/connect` on Auto's own address where Auto serves Studio, as it does in [local mode](security.md#studio-served-by-the-runtime), naming the connection and its token alone, and `https://studio.on.auto/connect` otherwise, naming Auto's address and the org too. An agent whose client opens links asks the person to open it; any other agent gives the person the link from its answer, as a link named `Approve the <title> connection` with its provider's title, or `Approve the connection` for a server given by hand, never as its address. 3. On the page, signed in, the person approves the connection. Where it takes a secret or client credentials, the person types the credential there, with the client id where the agent left it out, and giving it approves the connection. Where its provider takes consent, the page sends the person to sign in to the provider and allow Auto, and the provider sends them back to Studio, which finishes the consent. The connection is then `connected`. +4. An agent whose client asked the person to open the page retries its call once the person is done there or declined, and Auto answers the connection as it stands then, adding nothing: `connected`, with no link, and words that say nothing is left to open; `pending` with its link while the link lasts, where the person has not approved it yet or declined to open the page, so the agent gives them the link; `pending` with no link once its ten minutes are over, with words that say to remove it and add it again; or `removed`, where the person pressed Cancel on the page, which removes a pending connection, with words that say nothing is connected. On a runtime that signs people in, only the person who added a connection may approve it, signed in as themselves. In [local mode](security.md#local-mode), the link alone binds: whoever holds it within its ten minutes approves the connection. A link is gone after its ten minutes, and nothing gives a pending connection another, so the way on is to remove the connection and add it again. diff --git a/packages/api/README.md b/packages/api/README.md index ebab5c667..7eae2027d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -152,7 +152,7 @@ So invalid arguments are a tool result with `isError` and an `invalid_input` pro The instructions are composed from the tools the caller may use and the registered definition types. They explain connection scope, put what the person wants kept in a recall function rather than the agent's own notes where the server runs recall functions, require reading the format before writing a definition and ask for its overview, name `list_models` where it is listed and `search` is not, and direct an answer to an outstanding interaction back to the same run. Where `search` and `add_connection` are both listed, they say that a service a playbook will use is connected on this server, whatever connector the agent has of its own: asked to connect, link, sign in to or use one, the agent calls `search` with its words, then `add_connection` with the provider's name, which the person approves on the page it links, and says a service cannot be connected only when no provider or server matches. Scoped connections and read-only callers receive only applicable instructions. `src/mcp/instructions.test.ts` checks these combinations, the opening line of a signed-in person, a key and the local developer, and the 2,000-character limit, about what Claude Code shows of a server's instructions, over the whole text, opening line included, for a person named in 20 characters whose user id takes 32 and whose org id 30, `callerTheInstructionsHold` of `src/bounds/served-bounds.ts`; startup refuses an endpoint whose instructions exceed the bound for that person. The opening line takes 130 of the characters for that person, and the rest is worded tightly enough to leave them: with every tool, 1,869 on `/mcp`, 1,866 on the org endpoint and 1,502 on the playbook endpoint, so 1,999, 1,996 and 1,632 in all (`src/mcp/tool-calling-instructions.test.ts`). A caller whose name, id or org is longer is served the longer text, which a client that shows only 2,000 characters cuts at its end. Tool descriptions and format guides carry the details that do not fit in initialization: `get_guide`'s description names the recipes the connection's tools can follow, `run_definition`'s says that a run that finishes later answers started and `get_run` shows how it ended, `list_models`'s and `search`'s say how a reasoning function names its model and its tools, as the reasoning function's guide does, the prediction function's guide says that a build answers started, `execute`'s and `dry_run`'s say what each does, and those of `list_runs`, `list_playbook_events` and `list_interactions` say that their pages are read on only as the person needs. -**A link to open.** An operation whose definition gives `linkToOpen`, as `add_connection` of `@on.auto/connections` does with Studio's page that approves a connection, puts the page in its output, and its tool also asks the client to open it, with the message the definition gives, through the `url` form of elicitation, when the client declared that form in the capabilities its request carries (`src/tools/linked-answers.ts`). Only a request of the `2026-07-28` revision carries them: the handler serves an earlier revision statelessly, each request "by a fresh instance from the same factory", as the SDK documents its `legacy: 'stateless'`, so the capabilities a `2025-11-25` client declared in its `initialize` never reach the instance that answers its call, nor would the client's answer to the SDK's `elicitInput`, which takes the `url` form, reach the instance that asked; a client of that revision is answered at once, the page in the output. A tool that asks answers `input_required`, with one `url` elicitation and a `requestState` that `createRequestStateCodec` of the SDK signs with the current key of `retryStateKeys`, the HMAC keys `mcpRoutes` is given, and binds with the codec's `bind` to the caller and the org of the request: the tool's answer, with the tool, the caller and the org it was made for. The client opens the page, or the person declines, and retries the call with the state; the SDK verifies the state, and the tool answers what it kept for the same tool, caller and org without running the operation again, so a retry adds nothing a second time. The retry state is signed under a key the runtime keeps for itself in its secret store: 32 random bytes the first server of a ledger makes and names by a fact of the runtime's own, `own_key_kept` in `runtime/own-keys`, as the server's README says, which the server gives `mcpRoutes` as the `current` of `retryStateKeys`, with no `previous`; so every server that shares the ledger signs and verifies under the same key, whichever of them asked, across a restart and across a rotation of the secrets key, which wraps that key again and leaves it as it is, as the server's `src/connections/connections-over-mcp.test.ts` shows. A state is verified under `current` and then under `previous`, which is kept for a later rotation of that key, as `src/tools/linked-answers.test.ts` shows; a state of another key, made for another caller or org, or older than its ten minutes, is refused by the SDK as `-32602`, `Invalid or expired requestState`, and what the first call did stands. +**A link to open.** An operation whose definition gives `linkToOpen` and `answerAfterTheLink`, as `add_connection` of `@on.auto/connections` does with Studio's page that approves a connection, puts the page in its output, and its tool also asks the client to open it, with the message the definition gives, through the `url` form of elicitation, when the output holds a link and the client declared that form in the capabilities its request carries (`src/tools/linked-answers.ts`). Only a request of the `2026-07-28` revision carries them: the handler serves an earlier revision statelessly, each request "by a fresh instance from the same factory", as the SDK documents its `legacy: 'stateless'`, so the capabilities a `2025-11-25` client declared in its `initialize` never reach the instance that answers its call, nor would the client's answer to the SDK's `elicitInput`, which takes the `url` form, reach the instance that asked; a client of that revision is answered at once, the page in the output. A tool that asks answers `input_required`, with one `url` elicitation and a `requestState` that `createRequestStateCodec` of the SDK signs with the current key of `retryStateKeys`, the HMAC keys `mcpRoutes` is given, and binds with the codec's `bind` to the caller and the org of the request: the tool, the caller and the org it was made for, and the link it offered, its `url` and the `id` it is for, which the registration's `link.idOf` reads from the output. The client opens the page, or the person declines, and retries the call with the state; the SDK verifies the state, and for the same tool, caller and org the tool does not run the operation again but dispatches `link.answerAfter({ id, url })` of the registration with the arguments of the retry, the read the definition gives, so a retry adds nothing a second time and answers what stands after the page, in the operation's words. Where the retry's `inputResponses` say the person declined or cancelled, `inputResponse` of the SDK, and the answer still holds a link, its words begin `The person declined to open the page, and its link is in this answer.` A state for another tool is no link offered, and the call runs. The retry state is signed under a key the runtime keeps for itself in its secret store: 32 random bytes the first server of a ledger makes and names by a fact of the runtime's own, `own_key_kept` in `runtime/own-keys`, as the server's README says, which the server gives `mcpRoutes` as the `current` of `retryStateKeys`, with no `previous`; so every server that shares the ledger signs and verifies under the same key, whichever of them asked, across a restart and across a rotation of the secrets key, which wraps that key again and leaves it as it is, as the server's `src/connections/connections-over-mcp.test.ts` shows. A state is verified under `current` and then under `previous`, which is kept for a later rotation of that key, as `src/tools/linked-answers.test.ts` shows; a state of another key, made for another caller or org, or older than its ten minutes, is refused by the SDK as `-32602`, `Invalid or expired requestState`, and what the first call did stands. **Guides.** Every endpoint serves `get_guide`, a read-only tool whose `guide` argument is the enum of the guides' names and whose answer is one guide's whole Markdown as text, and lists the same guides as resources, `guide://<name>`, `text/markdown`, annotated for the assistant, which answer the same text. The server passes them as `shelf`, the `GuideShelf` `guideShelfOf(guides, recipes, definitionTypes)` builds once and checks against the bounds, from the guides, a `Guide` each with its `name`, `title`, `description` and `text`, the recipes and the definition types, and hands the same shelf to `openApiDocumentRoutes` and `guideRoutes`. A `Recipe` is a guide with the `arguments` its prompt takes, the `formatGuide` it embeds, the tools its steps `call` and the `request` it makes of the person's words. Each recipe whose steps call only tools the connection lists, the rule by which `get_guide`'s description names it, is also a prompt, under its name and title, so a playbook endpoint offers no `first-playbook` and a key that may only read no prompt: `prompts/get` answers one user message, the request with the person's words filled in and then the recipe, followed by its format guide as an embedded resource. `guideShelfOf` refuses a shelf without the guide of each definition type and the format guide of each recipe, so no endpoint mounts without them. diff --git a/packages/api/src/mcp/mcp-server-factory.ts b/packages/api/src/mcp/mcp-server-factory.ts index 376ef3fb3..3ac8a3c3c 100644 --- a/packages/api/src/mcp/mcp-server-factory.ts +++ b/packages/api/src/mcp/mcp-server-factory.ts @@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/server'; import { type CallerIdentity, type OperationScope, + type RegisteredLink, type RegisteredPlainLanguage, type Registration, type RouteRegistration, @@ -16,7 +17,7 @@ import { type HandedOff, type OrgCall, } from '../hand-off/caller-hand-off.ts'; -import { toolCallbackOf, type AnsweringServing } from '../tools/linked-answers.ts'; +import { toolCallbackOf, type AnsweringServing, type ToolLink } from '../tools/linked-answers.ts'; import { playbookArgumentOf, withoutPlaybook } from '../tools/playbook-argument.ts'; import type { Arguments, CalledTool, Dispatch } from '../tools/tool-callback.ts'; import { toolDefinitionOf, toolDefinitionTakingPlaybookOf, type ToolDefinition } from '../tools/tool-definition.ts'; @@ -33,7 +34,12 @@ interface Offered<R extends Registration> { readonly plainLanguage: RegisteredPlainLanguage; } -interface Tool extends CalledTool { +interface Dispatching { + readonly dispatch: Dispatch; + readonly link?: ToolLink; +} + +interface Tool extends CalledTool, Dispatching { readonly name: string; readonly definition: ToolDefinition; } @@ -101,7 +107,7 @@ function asGiven(input: Arguments): Arguments { function toolsOf<R extends Registration>( offers: readonly Offered<R>[], - dispatchOf: (registration: R) => Dispatch, + dispatchOf: (registration: R) => Dispatching, operationInputOf: (input: Arguments) => Arguments = asGiven, ): readonly Tool[] { return offers.map(({ registration, definition, plainLanguage }) => ({ @@ -109,12 +115,29 @@ function toolsOf<R extends Registration>( definition, kind: registration.kind, plainLanguage, - dispatch: dispatchOf(registration), operationInputOf, - ...(registration.linkToOpen === undefined ? {} : { linkToOpen: registration.linkToOpen }), + ...dispatchOf(registration), })); } +function dispatchingOf<R>( + dispatchTo: (registration: R) => Dispatch, + registration: R, + link: RegisteredLink<R> | undefined, +): Dispatching { + const dispatch = dispatchTo(registration); + return link === undefined + ? { dispatch } + : { + dispatch, + link: { + toOpen: link.toOpen, + idOf: link.idOf, + dispatchAfter: (offeredLink) => dispatchTo(link.answerAfter(offeredLink)), + }, + }; +} + interface ConnectedCall { readonly connection: Connection; readonly serving: ToolServing; @@ -138,28 +161,38 @@ function serverWithTools(connected: ConnectedCall, served: ServedTools, tools: r function orgDispatchOf( dispatcher: Dispatcher, { caller, org }: OrgCall, -): (registration: Registration<'org'>) => Dispatch { - return (registration) => (input) => dispatcher.dispatchToOrg(registration, { caller, org, input, encoding: 'json' }); +): (registration: Registration<'org'>) => Dispatching { + const dispatchTo = + (registration: Registration<'org'>): Dispatch => + (input) => + dispatcher.dispatchToOrg(registration, { caller, org, input, encoding: 'json' }); + return (registration) => dispatchingOf(dispatchTo, registration, registration.link); } function playbookDispatchOf( dispatcher: Dispatcher, { caller, org, playbook }: PlaybookCall, -): (registration: Registration<'playbook'>) => Dispatch { - return (registration) => (input) => - dispatcher.dispatchToPlaybook(registration, { caller, org, playbook, input, encoding: 'json' }); +): (registration: Registration<'playbook'>) => Dispatching { + const dispatchTo = + (registration: Registration<'playbook'>): Dispatch => + (input) => + dispatcher.dispatchToPlaybook(registration, { caller, org, playbook, input, encoding: 'json' }); + return (registration) => dispatchingOf(dispatchTo, registration, registration.link); } function playbookArgumentDispatchOf( dispatcher: Dispatcher, { caller, org }: OrgCall, -): (registration: Registration<'playbook'>) => Dispatch { - return (registration) => (input) => { - const argument = playbookArgumentOf(input); - return Result.isFailure(argument) - ? Effect.succeed(argument.failure) - : dispatcher.dispatchToPlaybook(registration, { caller, org, ...argument.success, encoding: 'json' }); - }; +): (registration: Registration<'playbook'>) => Dispatching { + const dispatchTo = + (registration: Registration<'playbook'>): Dispatch => + (input) => { + const argument = playbookArgumentOf(input); + return Result.isFailure(argument) + ? Effect.succeed(argument.failure) + : dispatcher.dispatchToPlaybook(registration, { caller, org, ...argument.success, encoding: 'json' }); + }; + return (registration) => dispatchingOf(dispatchTo, registration, registration.link); } export function orgServerFactory(serving: ToolServing): McpServerFactory { diff --git a/packages/api/src/testing/mcp-clients.ts b/packages/api/src/testing/mcp-clients.ts index 332a4f46f..1ac2a58a2 100644 --- a/packages/api/src/testing/mcp-clients.ts +++ b/packages/api/src/testing/mcp-clients.ts @@ -204,7 +204,8 @@ export async function withMcpSession<T>( export interface LinkOpening { readonly opens: 'links' | 'forms alone'; readonly revision: 'current revision' | 'previous revision'; - readonly action: 'accept' | 'decline'; + readonly action: 'accept' | 'decline' | 'cancel'; + readonly onThePage?: (url: string) => Promise<unknown>; } export interface LinksAsked { @@ -216,18 +217,22 @@ const openingEveryLink: LinkOpening = { opens: 'links', revision: 'current revis const ElicitationSchema = Schema.Struct({ params: Schema.Unknown }); +const LinkAskedSchema = Schema.Struct({ url: Schema.String }); + export async function calledOpeningLinks( { url, headers }: McpConnection, call: { readonly name: string; readonly arguments: Readonly<Record<string, unknown>> }, opening: Partial<LinkOpening> = {}, ): Promise<LinksAsked> { - const { opens, revision, action } = { ...openingEveryLink, ...opening }; + const { opens, revision, action, onThePage } = { ...openingEveryLink, ...opening }; const elicited: unknown[] = []; const capabilities = { elicitation: opens === 'links' ? { url: {} } : { form: {} } }; const versionNegotiation = { mode: revision === 'current revision' ? { pin: '2026-07-28' } : 'legacy' } as const; const client = new Client({ name: 'auto-runtime-tests', version: '2.0.0' }, { capabilities, versionNegotiation }); - client.setRequestHandler('elicitation/create', (request: unknown) => { - elicited.push(Schema.decodeUnknownSync(ElicitationSchema)(request).params); + client.setRequestHandler('elicitation/create', async (request: unknown) => { + const { params } = Schema.decodeUnknownSync(ElicitationSchema)(request); + elicited.push(params); + await onThePage?.(Schema.decodeUnknownSync(LinkAskedSchema)(params).url); return { action }; }); await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { ...headers } } })); diff --git a/packages/api/src/tools/linked-answers.test.ts b/packages/api/src/tools/linked-answers.test.ts index 28fb3ec82..f190ad7fc 100644 --- a/packages/api/src/tools/linked-answers.test.ts +++ b/packages/api/src/tools/linked-answers.test.ts @@ -1,10 +1,16 @@ import { authenticatorFor } from '@on.auto/identity'; -import { defineCommand } from '@on.auto/operations'; +import { defineCommand, type OfferedLink, type Outcome } from '@on.auto/operations'; import { Context, Effect, Redacted, Schema } from 'effect'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { listenOnLoopback, type Listening } from '../testing/listening.ts'; -import { calledOpeningLinks, retriedWith, stateAskedBy, type LinkOpening } from '../testing/mcp-clients.ts'; +import { + calledOpeningLinks, + plainTextIn, + retriedWith, + stateAskedBy, + type LinkOpening, +} from '../testing/mcp-clients.ts'; import { acmeAdmin, acmeAlphaWriter, @@ -15,25 +21,43 @@ import { } from '../testing/operation-server.ts'; import { linkedAnswers, toolCallbackOf, type AnsweringServing } from './linked-answers.ts'; -const page = 'https://studio.example/open?page=1'; - const opened = { times: 0 }; +const usedUp: string[] = []; + +function pageOf(made: number): string { + return `https://studio.example/open?page=${String(made)}`; +} + const openPage = defineCommand('org', { name: 'open_page', title: 'Open page', description: 'Makes a page for the person to open. Use it when the person must act on a page. It answers the page.', route: { method: 'POST', path: '/pages' }, - inputSchema: Schema.Record(Schema.String, Schema.Never), - outputSchema: Schema.Struct({ page: Schema.String, made: Schema.Number }), + inputSchema: Schema.Struct({ closed: Schema.optionalKey(Schema.Boolean) }), + outputSchema: Schema.Struct({ + id: Schema.String, + page: Schema.optionalKey(Schema.String), + again: Schema.Boolean, + }), reasons: [], - handle: () => + handle: ({ closed = false }) => Effect.sync(() => { opened.times += 1; - return { page, made: opened.times }; + const made = { id: `page-${String(opened.times)}`, again: false }; + return closed ? made : { ...made, page: pageOf(opened.times) }; }), - plainLanguage: { task: 'make a page', attempt: () => 'make a page', outcome: ({ page: made }) => `Made ${made}.` }, - linkToOpen: ({ page: made }) => ({ message: 'Open the page to approve it', url: made }), + plainLanguage: { + task: 'make a page', + attempt: () => 'make a page', + outcome: ({ page }) => (page === undefined ? 'Nothing is left to open.' : `Made ${page}.`), + }, + linkToOpen: ({ page }) => (page === undefined ? undefined : { message: 'Open the page to approve it', url: page }), + answerAfterTheLink: { + idOf: ({ id }) => id, + read: ({ id, url }) => + Effect.sync(() => (usedUp.includes(url) ? { id, again: true } : { id, page: url, again: true })), + }, }); let server: OperationServer; @@ -49,59 +73,93 @@ afterAll(async () => { await server.runtime.dispose(); }); -function opening(how: Partial<LinkOpening> = {}) { +function opening(how: Partial<LinkOpening> = {}, input: Readonly<Record<string, unknown>> = {}) { const connection = { url: `${listening.origin}/mcp`, headers: { authorization: `Bearer ${acmeAdmin.key}` } }; - return calledOpeningLinks(connection, { name: 'open_page', arguments: {} }, how); + return calledOpeningLinks(connection, { name: 'open_page', arguments: input }, how); } describe('a tool whose operation gives a link to open', () => { - it('asks a client of 2026-07-28 that opens links to open it, with its message, and answers once it was asked', async () => { - const before = opened.times; + it('asks a client of 2026-07-28 that opens links to open it, with its message, and answers the retry with what its read gives, running the operation once', async () => { + const made = opened.times + 1; const { answer, elicited } = await opening(); - expect(elicited).toEqual([{ mode: 'url', message: 'Open the page to approve it', url: page }]); - expect(answer.structuredContent).toEqual({ page, made: before + 1 }); - expect(opened.times).toBe(before + 1); + expect(elicited).toEqual([{ mode: 'url', message: 'Open the page to approve it', url: pageOf(made) }]); + expect(answer.structuredContent).toEqual({ id: `page-${String(made)}`, page: pageOf(made), again: true }); + expect(plainTextIn(answer)).toBe(`Made ${pageOf(made)}.`); + expect(opened.times).toBe(made); }); - it('answers the same when the person declines to open it', async () => { - const before = opened.times; + it('answers the retry after the page was used up with what its read gives then, no link, in its words', async () => { + const made = opened.times + 1; - const { answer, elicited } = await opening({ action: 'decline' }); + const { answer } = await opening({ + onThePage: (url) => { + usedUp.push(url); + return Promise.resolve(); + }, + }); - expect(answer.structuredContent).toEqual({ page, made: before + 1 }); - expect(elicited).toHaveLength(1); + expect(answer.structuredContent).toEqual({ id: `page-${String(made)}`, again: true }); + expect(plainTextIn(answer)).toBe('Nothing is left to open.'); }); + it('says the person declined to open the page, and that its link is in the answer, when they declined or cancelled', async () => { + const declined = await opening({ action: 'decline' }); + const cancelled = await opening({ action: 'cancel' }); + + expect([declined, cancelled].map(({ elicited }) => elicited)).toHaveLength(2); + expect([declined, cancelled].map(({ answer }) => plainTextIn(answer))).toEqual([ + `The person declined to open the page, and its link is in this answer. Made ${pageOf(opened.times - 1)}.`, + `The person declined to open the page, and its link is in this answer. Made ${pageOf(opened.times)}.`, + ]); + expect(cancelled.answer.structuredContent).toMatchObject({ page: pageOf(opened.times), again: true }); + }); +}); + +describe('a tool whose operation gives a link to open, to a client that is not asked to open it', () => { it('answers at once, with the link in the answer, a client that does not open links or speaks 2025-11-25', async () => { const formsAlone = await opening({ opens: 'forms alone' }); const legacy = await opening({ revision: 'previous revision' }); expect([formsAlone.elicited, legacy.elicited]).toEqual([[], []]); - expect([formsAlone.answer.structuredContent, legacy.answer.structuredContent]).toMatchObject([{ page }, { page }]); + expect([formsAlone.answer.structuredContent, legacy.answer.structuredContent]).toEqual([ + { id: `page-${String(opened.times - 1)}`, page: pageOf(opened.times - 1), again: false }, + { id: `page-${String(opened.times)}`, page: pageOf(opened.times), again: false }, + ]); + }); + + it('answers at once, and asks nothing, where its answer holds no link to open', async () => { + const { answer, elicited } = await opening({}, { closed: true }); + + expect(elicited).toEqual([]); + expect(answer.structuredContent).toEqual({ id: `page-${String(opened.times)}`, again: false }); }); }); const words = { kind: 'command', plainLanguage: { attempt: () => 'make a page', outcome: () => 'Made it.', remedies: {} }, - dispatch: () => Effect.die('never dispatched'), + dispatch: () => Effect.succeed<Outcome>({ status: 'succeeded', output: { by: 'the call' } }), operationInputOf: () => ({}), - linkToOpen: () => ({ message: 'Open', url: page }), + link: { + toOpen: () => ({ message: 'Open', url: pageOf(0) }), + idOf: () => 'page-0', + dispatchAfter: + ({ id, url }: OfferedLink) => + () => + Effect.succeed<Outcome>({ status: 'succeeded', output: { by: 'the read', id, url } }), + }, } as const; -const serving: AnsweringServing = { - runCall: () => Promise.reject(new Error('The call was run')), - reportThrown: () => ({ status: 'failed', incident: 'kept-answers' }), - linkedAnswers: linkedAnswers(testRetryStateKeys), -}; - -const answer = { content: [{ type: 'text', text: 'Made it.' }], structuredContent: { page } } as const; - function replaying(keptFor: Readonly<Record<string, string>>) { + const serving: AnsweringServing = { + runCall: server.runtime.run, + reportThrown: () => ({ status: 'failed', incident: 'offered-links' }), + linkedAnswers: linkedAnswers(testRetryStateKeys), + }; const caller = { id: 'acme-admin', name: 'acme-admin', org: 'acme', permissions: [], playbooks: '*' } as const; - const kept = { tool: 'open_page', caller: 'acme-admin', org: 'acme', ...keptFor, answer }; + const kept = { tool: 'open_page', caller: 'acme-admin', org: 'acme', id: 'page-0', url: pageOf(0), ...keptFor }; return toolCallbackOf( serving, { caller, org: 'acme', requestId: 'request', dropped: [], context: Context.empty() }, @@ -109,12 +167,20 @@ function replaying(keptFor: Readonly<Record<string, string>>) { )({}, { mcpReq: { id: 1, signal: new AbortController().signal, requestState: () => kept } }); } -describe('an answer kept for a retry', () => { - it('is given back only to the tool, the caller and the org it was kept for, and otherwise the call runs', async () => { - expect(await replaying({})).toEqual(answer); - expect(await replaying({ tool: 'other_page' })).toMatchObject({ isError: true }); - expect(await replaying({ caller: 'acme-reader' })).toMatchObject({ isError: true }); - expect(await replaying({ org: 'globex' })).toMatchObject({ isError: true }); +describe('a link offered before a retry', () => { + it('is read after only for the tool, the caller and the org it was offered to, and otherwise the call runs', async () => { + const theCall = { structuredContent: { by: 'the call' } }; + + expect(await replaying({})).toEqual({ + content: [ + { type: 'text', text: 'Made it.' }, + { type: 'text', text: JSON.stringify({ by: 'the read', id: 'page-0', url: pageOf(0) }) }, + ], + structuredContent: { by: 'the read', id: 'page-0', url: pageOf(0) }, + }); + expect(await replaying({ tool: 'other_page' })).toMatchObject(theCall); + expect(await replaying({ caller: 'acme-reader' })).toMatchObject(theCall); + expect(await replaying({ org: 'globex' })).toMatchObject(theCall); }); }); @@ -141,15 +207,15 @@ const refusedState = 'Invalid or expired requestState'; const anotherKey = Redacted.make(new Uint8Array(32).fill(9)); describe('the retry of a tool that asked to open a link', () => { - it('is answered by another server of the same key with what the first kept, and runs the operation once', async () => { + it('is answered by another server of the same key with what the read gives, and runs the operation once', async () => { const other = await anotherServer(); - const before = opened.times; + const made = opened.times + 1; const state = await stateAskedBy(keyed(listening.origin), openingThePage); const retried = await retriedWith(keyed(other), openingThePage, state); - expect(retried.structuredContent).toEqual({ page, made: before + 1 }); - expect(opened.times).toBe(before + 1); + expect(retried.structuredContent).toEqual({ id: `page-${String(made)}`, page: pageOf(made), again: true }); + expect(opened.times).toBe(made); }); it('is refused by a server of another key, and to another caller, whose binding differs', async () => { @@ -170,7 +236,7 @@ describe('the retry of a tool that asked to open a link', () => { await expect(retriedWith({ ...inAcme, url: `${local}/orgs/globex/mcp` }, openingThePage, state)).rejects.toThrow( refusedState, ); - expect(await retriedWith(inAcme, openingThePage, state)).toMatchObject({ structuredContent: { page } }); + expect(await retriedWith(inAcme, openingThePage, state)).toMatchObject({ structuredContent: { again: true } }); }); }); @@ -183,8 +249,12 @@ describe('the retry of a tool across a rotation of the key', () => { const before = await stateAskedBy(keyed(listening.origin), openingThePage); const after = await stateAskedBy(keyed(rotated), openingThePage); - expect(await retriedWith(keyed(rotated), openingThePage, before)).toMatchObject({ structuredContent: { page } }); - expect(await retriedWith(keyed(rotated), openingThePage, after)).toMatchObject({ structuredContent: { page } }); + expect(await retriedWith(keyed(rotated), openingThePage, before)).toMatchObject({ + structuredContent: { again: true }, + }); + expect(await retriedWith(keyed(rotated), openingThePage, after)).toMatchObject({ + structuredContent: { again: true }, + }); await expect(retriedWith(keyed(listening.origin), openingThePage, after)).rejects.toThrow(refusedState); }); }); diff --git a/packages/api/src/tools/linked-answers.ts b/packages/api/src/tools/linked-answers.ts index 9dbd6ee03..23ea52b07 100644 --- a/packages/api/src/tools/linked-answers.ts +++ b/packages/api/src/tools/linked-answers.ts @@ -2,14 +2,15 @@ import { CLIENT_CAPABILITIES_META_KEY, createRequestStateCodec, inputRequired, + inputResponse, type CallToolResult, type InputRequiredResult, } from '@modelcontextprotocol/server'; -import type { LinkToOpen } from '@on.auto/operations'; +import type { LinkToOpen, OfferedLink, RegisteredPlainLanguage } from '@on.auto/operations'; import { Option, Redacted, Schema } from 'effect'; import { orgCallOf, type OrgCall } from '../hand-off/caller-hand-off.ts'; -import { callbackFor, type CalledTool, type CallServing, type ToolContext } from './tool-callback.ts'; +import { callbackFor, type CalledTool, type CallServing, type Dispatch, type ToolContext } from './tool-callback.ts'; interface StateCodec { mint(payload: Readonly<Record<string, unknown>>, context?: ToolContext): Promise<string>; @@ -24,36 +25,40 @@ export interface AnsweringServing extends CallServing { readonly linkedAnswers: LinkedAnswers; } +export interface ToolLink { + readonly toOpen: (output: unknown) => LinkToOpen | undefined; + readonly idOf: (output: unknown) => string; + readonly dispatchAfter: (offered: OfferedLink) => Dispatch; +} + interface LinkingTool extends CalledTool { - readonly linkToOpen?: (output: unknown) => LinkToOpen; + readonly link?: ToolLink; } -interface AnswerOwner { +interface LinkOwner { readonly tool: string; readonly caller: string; readonly org: string; } -type Answering = (input: unknown, context: ToolContext) => Promise<CallToolResult>; - type AskingToOpen = (input: unknown, context: ToolContext) => Promise<CallToolResult | InputRequiredResult>; const theLink = 'link'; +const declinedToOpen = 'The person declined to open the page, and its link is in this answer.'; + const OpensLinksSchema = Schema.Struct({ [CLIENT_CAPABILITIES_META_KEY]: Schema.Struct({ elicitation: Schema.Struct({ url: Schema.Record(Schema.String, Schema.Unknown) }), }), }); -const KeptAnswerSchema = Schema.Struct({ +const OfferedLinkStateSchema = Schema.Struct({ tool: Schema.String, caller: Schema.String, org: Schema.String, - answer: Schema.Struct({ - content: Schema.Array(Schema.Struct({ type: Schema.Literal('text'), text: Schema.String })), - structuredContent: Schema.Record(Schema.String, Schema.Unknown), - }), + id: Schema.String, + url: Schema.String, }); const BoundContextSchema = Schema.Struct({ @@ -64,7 +69,7 @@ const opensLinks = Schema.is(OpensLinksSchema); const boundContextOf = Schema.decodeUnknownSync(BoundContextSchema); -const keptAnswerOf = Schema.decodeUnknownOption(KeptAnswerSchema); +const offeredLinkStateOf = Schema.decodeUnknownOption(OfferedLinkStateSchema); function callerAndOrgOf(context: unknown): string { const { caller, org } = orgCallOf(boundContextOf(context).http); @@ -94,47 +99,75 @@ export function linkedAnswers({ current, previous }: RetryStateKeys): LinkedAnsw return { states: previous === undefined ? signing : eitherOf(signing, codecOf(previous)) }; } -function keptAnswerFor({ tool, caller, org }: AnswerOwner, state: unknown): CallToolResult | undefined { +function offeredLinkFor({ tool, caller, org }: LinkOwner, state: unknown): OfferedLink | undefined { return Option.getOrUndefined( - Option.flatMap(keptAnswerOf(state), (kept) => + Option.flatMap(offeredLinkStateOf(state), (kept) => kept.tool === tool && kept.caller === caller && kept.org === org - ? Option.some({ - content: kept.answer.content.map(({ text }) => ({ type: 'text' as const, text })), - structuredContent: { ...kept.answer.structuredContent }, - }) + ? Option.some({ id: kept.id, url: kept.url }) : Option.none(), ), ); } -function askingToOpen( - { states }: LinkedAnswers, - owner: AnswerOwner, - linkOf: (output: unknown) => LinkToOpen, - answering: Answering, -): AskingToOpen { +function declinedToOpenIn({ mcpReq }: ToolContext): boolean { + const response = inputResponse(mcpReq.inputResponses, theLink); + return response.kind === 'elicit' && response.action !== 'accept'; +} + +function wordsAfterTheLink( + plainLanguage: RegisteredPlainLanguage, + { toOpen }: ToolLink, + declined: boolean, +): RegisteredPlainLanguage { + return { + ...plainLanguage, + outcome: (output, input) => { + const words = plainLanguage.outcome(output, input); + return toOpen(output) !== undefined && declined ? `${declinedToOpen} ${words}` : words; + }, + }; +} + +function answeringAfterTheLink( + tool: LinkingTool, + link: ToolLink, + offered: OfferedLink, + context: ToolContext, +): CalledTool { + return { + ...tool, + dispatch: link.dispatchAfter(offered), + plainLanguage: wordsAfterTheLink(tool.plainLanguage, link, declinedToOpenIn(context)), + }; +} + +function askingToOpen(serving: AnsweringServing, call: OrgCall, tool: LinkingTool, link: ToolLink): AskingToOpen { + const owner = { tool: tool.name, caller: call.caller.id, org: call.org }; + const answering = callbackFor(serving, call, tool); return async (input, context) => { - const kept = keptAnswerFor(owner, context.mcpReq.requestState()); - if (kept !== undefined) { - return kept; + const offered = offeredLinkFor(owner, context.mcpReq.requestState()); + if (offered !== undefined) { + return callbackFor(serving, call, answeringAfterTheLink(tool, link, offered, context))(input, context); } const answer = await answering(input, context); const output = answer.structuredContent; if (output === undefined || !opensLinks(context.mcpReq.envelope)) { return answer; } - const { message, url } = linkOf(output); - return inputRequired({ - inputRequests: { [theLink]: inputRequired.elicitUrl({ message, url }) }, - requestState: await states.mint({ ...owner, answer }, context), - }); + const toOpen = link.toOpen(output); + return toOpen === undefined + ? answer + : inputRequired({ + inputRequests: { [theLink]: inputRequired.elicitUrl({ message: toOpen.message, url: toOpen.url }) }, + requestState: await serving.linkedAnswers.states.mint( + { ...owner, id: link.idOf(output), url: toOpen.url }, + context, + ), + }); }; } export function toolCallbackOf(serving: AnsweringServing, call: OrgCall, tool: LinkingTool): AskingToOpen { - const answering = callbackFor(serving, call, tool); - const owner = { tool: tool.name, caller: call.caller.id, org: call.org }; - return tool.linkToOpen === undefined - ? answering - : askingToOpen(serving.linkedAnswers, owner, tool.linkToOpen, answering); + const { link } = tool; + return link === undefined ? callbackFor(serving, call, tool) : askingToOpen(serving, call, tool, link); } diff --git a/packages/api/src/tools/tool-callback.ts b/packages/api/src/tools/tool-callback.ts index 931860931..756877a96 100644 --- a/packages/api/src/tools/tool-callback.ts +++ b/packages/api/src/tools/tool-callback.ts @@ -30,6 +30,7 @@ export interface ToolContext { readonly signal: AbortSignal; readonly _meta?: Readonly<Record<string, unknown>>; readonly envelope?: Readonly<Record<string, unknown>>; + readonly inputResponses?: Readonly<Record<string, unknown>>; readonly requestState: () => unknown; }; } diff --git a/packages/connections/README.md b/packages/connections/README.md index eaf4c5b58..cb66bfd85 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -66,7 +66,7 @@ There is no read model: each call folds the stream (`connectionsFold`, the fold | `list_providers` | query | `GET /providers` | `org:read` | `{ providers, may_add, refusal }` | none | - `add_connection` takes one flat input, two ways in (`AdditionSchema` of `src/operations/connection-fields.ts`, whose check holds each way to its fields as the credential's check holds each kind to its own). Its `credential` is a schema of its own, the fields a credential given by hand takes, kept apart from the record's, which holds the fields of a way of consent beside them. With `provider`, the name of a provider, it takes `placeholders`, `name`, the provider's when left out, `credential.kind`, and where it is left out the first way the listing marks offered to the caller, `isOfferedWay` of `src/providers/provider-listing.ts`, the rule `list_providers` marks each way by, or the provider's first where it offers none, which the checks then refuse in their words, and of `entry` only `playbooks`, `allowed` and `record_content`, and refuses `type`, the server part of `entry` and every field of `credential` but `kind`, each at its field, `Expected no url: the provider gives the server`. Without, it takes `name`, `type`, `entry` and `credential` as a server given by hand, each required, `Expected type, which a server given by hand takes, or provider`, and refuses `placeholders`, and a credential of the kind `authorization` at `/credential/kind`, `Expected another kind: consent is offered through a provider alone, so name the provider, as search lists it, in provider`, since an entry given by hand could name any server and be handed tokens a person consented to at a trusted issuer. A provider is resolved first (`src/providers/provider-additions.ts`): `providerNamed` of `@on.auto/providers` refuses a name the runtime has no provider of at `/provider`, to which this package adds where the names are, `There is no provider named crm here; search lists the providers a person may connect`, and `additionOfProvider` turns the provider and what the caller gave into the name, the type, the entry and the credential a server given by hand would have been, refusing a placeholder or a kind at the pointer the caller wrote. Everything after is the path of a server given by hand, over the resolved addition: the credential's check, the entry's checks, the reading, the policy and the names, none skipped and none run twice; a problem they find in a field the provider resolved is reported at `/provider`, a name's problem at `/name` where the caller gave the name and at `/provider` where it is the provider's, and a problem of `playbooks`, `allowed` or `record_content` at its field. A provider's document beside its entries is read from the folder of the providers, as a file's document is read from the configuration's folder, where a document given by hand must be a URL. A provider's header or query value may hold a reference, `${NAME}`, for a value of the operator's, such as a developer token: the record keeps it as the provider wrote it, the entry's checks take it since the provider and not the caller wrote it, while an entry given by hand still refuses every reference, and the reading substitutes it from `providerValues`, so a provider whose value is not set is refused at `/provider` in the reading's words, `Refers to the environment variable METRICS_DEVELOPER_TOKEN, which is not set`. The record and `connection_added` carry `provider` and `placeholders`, and the resolved entry, so a later change to a provider's entry changes no connection there is. -- `add_connection` first refuses a caller who cannot add a connection, `forbidden` (`adderRefusalOf`): where the runtime signs people in, a caller that is not a signed-in person (`isSignedInPerson` of `@on.auto/identity`), since a key's connection would wait for a person who never comes, and, where it takes keys alone, every caller, with the sentence that this runtime signs nobody in, so no person can approve a connection. In local mode the local developer adds any. It then checks the entry, every problem at its pointer in one `invalid_input` (`additionIssuesOf` in `src/entries/entry-checks.ts`): the name with `serverNameProblems`; an `mcp` connection's `url` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and a `document` that is not a URL, which would be a file on the runtime's disk; the entry with `credentialProblems` of `@on.auto/config`, so a value that looks like a credential is refused, and with `substituted` of the same package against an empty environment, so any `${…}` is refused as a reference; and the credential: a header the runtime sets itself (`headersTheRuntimeSets` of `@on.auto/tool-servers`, which the headers of an entry are checked against too) or the entry sends already, a query parameter or a security scheme for an `mcp` connection, and a query parameter the entry sends already. Where all of these pass (`entryIssuesOf`), it reads the entry with its credential placed, a value standing in for the one the person gives (`readAddition`), as the source reads it to serve it with the real value (see [The calls](#the-calls)), so what the file's reading refuses is refused here, at its pointer under `/entry`, and no connection reaches the ledger that its use would refuse for its entry. Client credentials that leave their `client_id` to the page are read with a client id standing in for it too, since the reading builds the `auth` block, which takes one. So is a credential of the kind `authorization`, whose client is recorded only when its consent finishes, so that a provider's `{client_id}` is read with a value in its place, as it is served; the kinds whose credential takes a `client_id` are `takesAClientId` of `src/registry/credential.ts`, which asks `holdsAClientId` of `@on.auto/providers`. Every other check runs on what was given. What the reading refuses in a client id, one that looks like a credential, is refused at `/credential/client_id`, the field the agent wrote, in words of its own, `Looks like a credential, which a client id never is: leave the secret out, and the person gives it on the page the link opens`, which replace the reading's (`heldCredentialAsClientId`, keyed on the reading's `/entry/auth/client_id`). A secret may go in `Authorization`, which the runtime sets itself only for client credentials and an entry never sends, since its name names a credential. It then applies the policy, `forbidden` with the sentence of the rule, refuses the name of an entry of the file, `conflict` of the kind `taken`, and records the connection with a link: a token of 32 random bytes in base64url, of which the fact keeps the SHA-256 alone. `connect.url` is the page `connect` under the base of the Studio the person can reach, `studioBaseOf` of `@on.auto/identity`, `<runtime>/studio/` where the runtime serves Studio and the root of the Studio host otherwise, as `<base>connect?connection=<id>&token=<token>` where the runtime serves Studio, since that page knows the runtime that serves it and asks it for the org, and `<base>connect?server=<runtime>&org=<org>&connection=<id>&token=<token>` on the Studio host, which knows neither (`connectPageOf`, by `served` of the Studio settings). Its definition gives `linkToOpen`, the page and a message saying what to do there, which the MCP tool asks a client to open (`@on.auto/api`). Its words end by saying who approves the connection where, and that the link is shown to the person as a link named `Approve the <title> connection`, the title of its provider in the list the addition was resolved against, or `Approve the connection` for a server given by hand, never as its address (`linkWordsOf` of `src/operations/connection-words.ts`); its description says, after its first sentence, that a service a playbook will use is connected here, whatever connector the agent has of its own for it, and ends with the sentence that the call grants nothing and no credential passes through it. +- `add_connection` first refuses a caller who cannot add a connection, `forbidden` (`adderRefusalOf`): where the runtime signs people in, a caller that is not a signed-in person (`isSignedInPerson` of `@on.auto/identity`), since a key's connection would wait for a person who never comes, and, where it takes keys alone, every caller, with the sentence that this runtime signs nobody in, so no person can approve a connection. In local mode the local developer adds any. It then checks the entry, every problem at its pointer in one `invalid_input` (`additionIssuesOf` in `src/entries/entry-checks.ts`): the name with `serverNameProblems`; an `mcp` connection's `url` that is neither https nor on a loopback address (`isPinnableUrl` of `@on.auto/tool-servers`), and a `document` that is not a URL, which would be a file on the runtime's disk; the entry with `credentialProblems` of `@on.auto/config`, so a value that looks like a credential is refused, and with `substituted` of the same package against an empty environment, so any `${…}` is refused as a reference; and the credential: a header the runtime sets itself (`headersTheRuntimeSets` of `@on.auto/tool-servers`, which the headers of an entry are checked against too) or the entry sends already, a query parameter or a security scheme for an `mcp` connection, and a query parameter the entry sends already. Where all of these pass (`entryIssuesOf`), it reads the entry with its credential placed, a value standing in for the one the person gives (`readAddition`), as the source reads it to serve it with the real value (see [The calls](#the-calls)), so what the file's reading refuses is refused here, at its pointer under `/entry`, and no connection reaches the ledger that its use would refuse for its entry. Client credentials that leave their `client_id` to the page are read with a client id standing in for it too, since the reading builds the `auth` block, which takes one. So is a credential of the kind `authorization`, whose client is recorded only when its consent finishes, so that a provider's `{client_id}` is read with a value in its place, as it is served; the kinds whose credential takes a `client_id` are `takesAClientId` of `src/registry/credential.ts`, which asks `holdsAClientId` of `@on.auto/providers`. Every other check runs on what was given. What the reading refuses in a client id, one that looks like a credential, is refused at `/credential/client_id`, the field the agent wrote, in words of its own, `Looks like a credential, which a client id never is: leave the secret out, and the person gives it on the page the link opens`, which replace the reading's (`heldCredentialAsClientId`, keyed on the reading's `/entry/auth/client_id`). A secret may go in `Authorization`, which the runtime sets itself only for client credentials and an entry never sends, since its name names a credential. It then applies the policy, `forbidden` with the sentence of the rule, refuses the name of an entry of the file, `conflict` of the kind `taken`, and records the connection with a link: a token of 32 random bytes in base64url, of which the fact keeps the SHA-256 alone. `connect.url` is the page `connect` under the base of the Studio the person can reach, `studioBaseOf` of `@on.auto/identity`, `<runtime>/studio/` where the runtime serves Studio and the root of the Studio host otherwise, as `<base>connect?connection=<id>&token=<token>` where the runtime serves Studio, since that page knows the runtime that serves it and asks it for the org, and `<base>connect?server=<runtime>&org=<org>&connection=<id>&token=<token>` on the Studio host, which knows neither (`connectPageOf`, by `served` of the Studio settings). Its definition gives `linkToOpen`, the page and a message saying what to do there, which the MCP tool asks a client to open (`@on.auto/api`), and `answerAfterTheLink`, by which the tool answers the client's retry: it reads the connection by its id as it stands then, with the ending of its credential, and answers it with `connect`, the link it offered, while the connection is pending and the link lasts (`openLinkOf` of `src/registry/registry-lookup.ts`), and without it otherwise, connected, pending past its link, broken or removed, as Cancel on Studio's page removes it, so `AddedSchema` holds `connect` only while there is a link to open. Its words, `addedWordsOf` of `src/operations/connection-words.ts`, end, where the answer holds the link, by saying who approves the connection where, and that the link is shown to the person as a link named `Approve the <title> connection`, the title of its provider in the list the addition was resolved against, or `Approve the connection` for a server given by hand, never as its address; without it they say the connection is connected and nothing is left to open, that the person cancelled it and nothing is connected, or that its link is used up or expired and the way on is to remove it and add it again; its description says, after its first sentence, that a service a playbook will use is connected here, whatever connector the agent has of its own for it, and ends with the sentence that the call grants nothing and no credential passes through it. - `list_providers` (`src/providers`) answers the providers the policy could allow here, sorted by name, each with `name`, `title`, `description`, `website`, `type`, its ways, `credentials`, as `{ kind, in?, name?, scheme?, issuer?, scope?, description?, offered, because? }`, and the placeholders a person gives, `placeholders`, as `{ name, description, pattern?, example?, required, choices? }`. A way is listed where `credential_kinds` takes its kind and the hosts it reaches, `hostAlternativesOf` of `@on.auto/providers`, could pass `allowed_hosts`: a fixed host a pattern takes; a host with a placeholder where every host is allowed, where an allowed host has its shape, the placeholder standing for letters, digits and hyphens, or where a pattern `*.` and a host ends the part of the host after its last placeholder; none where `allowed_hosts` is empty; and, of a placeholder whose choices set the host, the hosts of any one choice. A way is `offered: false` while a variable its provider's references name is not set, with `because` saying whoever runs the runtime sets it up first, and a way of the kind `authorization` is `offered: false` where the consent client has none of the client sources it lists, `sourcesOf` of what `hadSources` of `ConsentClient` answers for the base of the Studio the page opens, `studioBaseFor`, once the clients of Auto's consent service are listed where the client takes that source and the base is one the service sends people back to, judged with the way's issuer as the provider writes it and the scope it asks, with `because` `The provider drive takes consent through a client whoever runs Auto registers, which is not set up here`; `add_connection` refuses such a way at `/credential/kind` in the same words, judged with the issuer resolved. A provider with no way listed is left out. `may_add` says whether this caller may add a connection here, and `refusal`, where it may not, the first sentence that refuses, in this order: the sentence a caller without `org:write` is refused with (`permissionRefusalOf` of `@on.auto/operations`), which the dispatcher checks before any handler runs, then the adder check `add_connection` begins with, then `people_may_add`; it is `null` otherwise. Nothing of an entry is secret, so the list is answered whole. - `makeProviderOffering(services)` (`src/providers/provider-offering.ts`) is the `ProviderOffering` of `@on.auto/tool-servers`, which the server gives the org's `search`, an effect for a caller: to a caller for whom `may_add` would be `true`, by the same judgement, `refusalToAdd`, it answers the providers `list_providers` lists with at least one way `offered`, each by `name`, `title` and `description`, reading `offered` from the listing and judging nothing again, so a provider none of whose ways is offered is left out of `search` while the page lists it with its ways marked, and to any other caller nothing, so `search` lists the providers a person may connect only to a caller who may add one. - `read_link` matches the token as `approve` does, never binding a person, and answers the record and `link`, `{ expires_at }`, the time `connection_link_issued` holds and the match checks, so the page can show the connection and how long the person has, and `consent`, `{ started_at, expires_at }`, while a start is open. For a connection of the kind `authorization` it answers `client_source` too, the source a start would take now, `sourceOf` of what `hadSources` of the consent client answers for the base of the Studio the link opens, the one rule the start takes its source by, `registered` where whoever runs the runtime registered a client at the issuer, else `auto` where Auto's consent service serves it, then the rest, so the page can say before the person goes that Auto's client is used; it leaves the field out where the runtime has no source the way takes, and for a connection of another kind asks the consent client nothing. The start's own `client_source` is the source it took, which differs only where a metadata document the start meant to take is one the server does not take. `approve` and `give_credential` answer the record alone, since they use the link up. diff --git a/packages/connections/src/operations/add-connection.ts b/packages/connections/src/operations/add-connection.ts index b02d9897f..98de522a3 100644 --- a/packages/connections/src/operations/add-connection.ts +++ b/packages/connections/src/operations/add-connection.ts @@ -7,8 +7,8 @@ import { adderRefusalOf } from '../policy/policy-rules.ts'; import { checkedAdditionOf, refusalOfTheAddition } from '../providers/provider-additions.ts'; import { AddedSchema, AdditionSchema } from './connection-fields.ts'; import type { ConnectionServices } from './connection-services.ts'; -import { connectionNamed, linkWordsOf } from './connection-words.ts'; -import { additionOf, recordInRegistry, recordedCaller } from './registry-access.ts'; +import { addedWordsOf, linkToOpenOf } from './connection-words.ts'; +import { additionOf, recordInRegistry, recordedCaller, standingAfterTheLink } from './registry-access.ts'; const description = [ "Starts connecting a person's account at a service, by the name of a provider that search lists, or a server given by hand as name, type, entry and credential.", @@ -58,11 +58,9 @@ export function defineAddConnection({ task: 'add a connection', attempt: ({ name, provider }) => name === undefined ? `connect the provider ${quoted(String(provider))}` : `add the connection ${quoted(name)}`, - outcome: (added) => `Added ${connectionNamed(added)}. ${linkWordsOf(added, serving.providers)}`, + outcome: (added) => addedWordsOf(added, serving.providers), }, - linkToOpen: (added) => ({ - message: `Open this page to approve ${connectionNamed(added)}, and give its credential where it takes one, within ten minutes.`, - url: added.connect.url, - }), + linkToOpen: linkToOpenOf, + answerAfterTheLink: { idOf: ({ id }) => id, read: (offered) => standingAfterTheLink(serving.secrets, offered) }, }); } diff --git a/packages/connections/src/operations/answer-after-the-link.test.ts b/packages/connections/src/operations/answer-after-the-link.test.ts new file mode 100644 index 000000000..14bb2c293 --- /dev/null +++ b/packages/connections/src/operations/answer-after-the-link.test.ts @@ -0,0 +1,89 @@ +import type { Outcome, Succeeded } from '@on.auto/operations'; +import { Option, Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { added, adding, crm, notes, type Added } from '../testing/additions.ts'; +import { ada, harness, operationNamed, type Harness } from '../testing/harness.ts'; + +const IdentifiedSchema = Schema.Struct({ output: Schema.Struct({ id: Schema.String }) }); + +function outputOf(outcome: Outcome<Succeeded>): unknown { + return 'output' in outcome ? outcome.output : undefined; +} + +function linkingOf(h: Harness) { + const { registration } = operationNamed(h.operations, 'add_connection'); + return Option.getOrThrow(Option.fromNullishOr('link' in registration ? registration.link : undefined)); +} + +function answeredAfterTheLink(h: Harness, { id, url }: Added, input: unknown = notes, at?: string) { + return h.call({ registration: linkingOf(h).answerAfter({ id, url }) }, ada, input, at); +} + +describe('the answer to adding a connection after its link', () => { + it('is read again by the id of the connection the link approves', async () => { + const h = harness(); + const outcome = await adding(h, notes); + + expect(linkingOf(h).idOf(outputOf(outcome))).toBe(Schema.decodeUnknownSync(IdentifiedSchema)(outcome).output.id); + }); + + it('is the connection connected, with no link, once the person approved it', async () => { + const h = harness(); + const link = await added(h, notes); + await h.call(operationNamed(h.operations, 'approve'), ada, { id: link.id, token: link.token }); + + const answer = await answeredAfterTheLink(h, link); + + expect(answer).toMatchObject({ status: 'succeeded', output: { id: link.id, status: 'connected' } }); + expect(answer).not.toHaveProperty('output.connect'); + }); + + it('is the connection pending with its link, while the link lasts', async () => { + const h = harness(); + const link = await added(h, notes); + + expect(await answeredAfterTheLink(h, link, notes, '2026-10-15T14:09:59.999Z')).toMatchObject({ + status: 'succeeded', + output: { id: link.id, status: 'pending', connect: { url: link.url, expires_at: link.expiresAt } }, + }); + }); +}); + +describe('the answer to adding a connection after its link is gone', () => { + it('is the connection pending with no link, once its link has expired', async () => { + const h = harness(); + const link = await added(h, notes); + + const answer = await answeredAfterTheLink(h, link, notes, link.expiresAt); + + expect(answer).toMatchObject({ status: 'succeeded', output: { status: 'pending' } }); + expect(answer).not.toHaveProperty('output.connect'); + }); + + it('is the connection removed, with no link, once the person cancelled it on the page', async () => { + const h = harness(); + const link = await added(h, notes); + await h.call(operationNamed(h.operations, 'remove_connection'), ada, { id: link.id }); + + const answer = await answeredAfterTheLink(h, link); + + expect(answer).toMatchObject({ status: 'succeeded', output: { status: 'removed' } }); + expect(answer).not.toHaveProperty('output.connect'); + }); +}); + +describe('the read of a connection after its link', () => { + it('reads the connection as it stands, the ending of its credential with it, and adds no other', async () => { + const h = harness(); + const link = await added(h, crm); + const credential = { id: link.id, token: link.token, value: 'Bearer crm-0123456789' }; + await h.call(operationNamed(h.operations, 'give_credential'), ada, credential); + const facts = await h.facts(); + + expect(await answeredAfterTheLink(h, link, crm)).toMatchObject({ + output: { status: 'connected', credential_ends_with: '6789' }, + }); + expect(await h.facts()).toEqual(facts); + }); +}); diff --git a/packages/connections/src/operations/connection-fields.ts b/packages/connections/src/operations/connection-fields.ts index 10204f0d7..4b8209385 100644 --- a/packages/connections/src/operations/connection-fields.ts +++ b/packages/connections/src/operations/connection-fields.ts @@ -16,11 +16,18 @@ export const ExpiresAtField = Schema.String.annotate({ description: 'When the li export const AddedSchema = Schema.Struct({ ...ConnectionSchema.fields, - connect: Schema.Struct({ - url: Schema.String.annotate({ description: "Studio's page, where the person approves the connection" }), - expires_at: ExpiresAtField, - }), -}).annotate({ identifier: 'AddedConnection', description: 'The connection, pending, and the link that approves it' }); + connect: Schema.optionalKey( + Schema.Struct({ + url: Schema.String.annotate({ description: "Studio's page, where the person approves the connection" }), + expires_at: ExpiresAtField, + }).annotate({ description: 'The link that approves the connection, while it is pending and the link lasts' }), + ), +}).annotate({ + identifier: 'AddedConnection', + description: 'The connection, and the link that approves it while there is one to open', +}); + +export type AddedConnection = typeof AddedSchema.Type; const { variable, name, in: placement, scheme, issuer, client_id: clientId, scope } = CredentialFieldsSchema.fields; diff --git a/packages/connections/src/operations/connection-words.test.ts b/packages/connections/src/operations/connection-words.test.ts index cb4d3d06e..0477ad533 100644 --- a/packages/connections/src/operations/connection-words.test.ts +++ b/packages/connections/src/operations/connection-words.test.ts @@ -1,7 +1,8 @@ import type { LinkToOpen, Outcome, RegisteredPlainLanguage, Succeeded } from '@on.auto/operations'; +import { Option } from 'effect'; import { describe, expect, it } from 'vitest'; -import { added, crm, notes, statusesOf } from '../testing/additions.ts'; +import { added, crm, notes, statusesOf, type Added } from '../testing/additions.ts'; import { ada, harness, operationNamed, type Harness } from '../testing/harness.ts'; function wordsOf(h: Harness, name: string): RegisteredPlainLanguage { @@ -21,10 +22,22 @@ async function outcomeWords(h: Harness, name: string, input: Readonly<Record<str return wordsOf(h, name).outcome(outputOf(outcome), input); } -async function linkOf(h: Harness): Promise<LinkToOpen | undefined> { +function linkingOf(h: Harness) { const { registration } = operationNamed(h.operations, 'add_connection'); - const outcome = await h.call({ registration }, ada, crm); - return 'linkToOpen' in registration ? registration.linkToOpen?.(outputOf(outcome)) : undefined; + return Option.getOrThrow(Option.fromNullishOr('link' in registration ? registration.link : undefined)); +} + +async function linkOf(h: Harness): Promise<LinkToOpen | undefined> { + const outcome = await h.call(operationNamed(h.operations, 'add_connection'), ada, crm); + return linkingOf(h).toOpen(outputOf(outcome)); +} + +async function outputAfterTheLink(h: Harness, { id, url }: Added, at?: string): Promise<unknown> { + return outputOf(await h.call({ registration: linkingOf(h).answerAfter({ id, url }) }, ada, notes, at)); +} + +async function wordsAfterTheLink(h: Harness, link: Added, at?: string): Promise<string> { + return wordsOf(h, 'add_connection').outcome(await outputAfterTheLink(h, link, at), notes); } describe('the words of adding a connection', () => { @@ -52,6 +65,43 @@ describe('the words of adding a connection', () => { }); }); +describe('the words of the answer after the link of a connection', () => { + it('say it is connected and nothing is left to open, once the person approved it', async () => { + const h = harness(); + const link = await added(h, notes); + await h.call(operationNamed(h.operations, 'approve'), ada, { id: link.id, token: link.token }); + + expect(await wordsAfterTheLink(h, link)).toBe('Connected the connection “notes”; nothing is left to open.'); + }); + + it('say where the person approves it while its link lasts, and to remove it and add it again once it expired', async () => { + const h = harness(); + const link = await added(h, notes); + + expect(await wordsAfterTheLink(h, link)).toBe(await outcomeWords(harness(), 'add_connection', notes)); + expect(await wordsAfterTheLink(h, link, link.expiresAt)).toBe( + 'The connection “notes” is pending, and its link is used up or expired, so nothing is left to open: remove it and add it again.', + ); + }); + + it('say the person cancelled it and nothing is connected, once it was removed', async () => { + const h = harness(); + const link = await added(h, notes); + await h.call(operationNamed(h.operations, 'remove_connection'), ada, { id: link.id }); + + expect(await wordsAfterTheLink(h, link)).toBe( + 'The person cancelled the connection “notes” on the page, so it is removed and nothing is connected.', + ); + }); + + it('ask no agent to have the person open a link once there is none', async () => { + const h = harness(); + const link = await added(h, notes); + + expect(linkingOf(h).toOpen(await outputAfterTheLink(h, link, link.expiresAt))).toBeUndefined(); + }); +}); + describe('the words of listing connections', () => { it('list them by name and status, and say when there is none', async () => { const h = harness(); diff --git a/packages/connections/src/operations/connection-words.ts b/packages/connections/src/operations/connection-words.ts index 001e50743..2df8efd5f 100644 --- a/packages/connections/src/operations/connection-words.ts +++ b/packages/connections/src/operations/connection-words.ts @@ -1,8 +1,9 @@ -import { counted, listed, quoted, type Noun } from '@on.auto/operations'; +import { capitalized, counted, listed, quoted, type LinkToOpen, type Noun } from '@on.auto/operations'; import { providerNamed, type ProviderList } from '@on.auto/providers'; import { Result } from 'effect'; import type { Connection } from '../registry/connection.ts'; +import type { AddedConnection } from './connection-fields.ts'; const connectionNoun: Noun = { one: 'connection', other: 'connections' }; @@ -36,7 +37,33 @@ function approvalLinkNamed(providerTitle: string | undefined): string { return quoted(providerTitle === undefined ? 'Approve the connection' : `Approve the ${providerTitle} connection`); } -export function linkWordsOf({ provider }: Pick<Connection, 'provider'>, providers: ProviderList): string { +function linkWordsOf({ provider }: Pick<Connection, 'provider'>, providers: ProviderList): string { const linkName = approvalLinkNamed(providerTitleOf(provider, providers)); return `The person who added it approves it, and gives its credential where it takes one, on the page its link opens, within ten minutes; shown to them as a link named ${linkName}, never as its address.`; } + +function standingWordsOf(connection: Connection): string { + const named = connectionNamed(connection); + if (connection.status === 'connected') { + return `Connected ${named}; nothing is left to open.`; + } + if (connection.status === 'removed') { + return `The person cancelled ${named} on the page, so it is removed and nothing is connected.`; + } + return `${capitalized(named)} is ${connection.status}, and its link is used up or expired, so nothing is left to open: remove it and add it again.`; +} + +export function addedWordsOf({ connect, ...connection }: AddedConnection, providers: ProviderList): string { + return connect === undefined + ? standingWordsOf(connection) + : `Added ${connectionNamed(connection)}. ${linkWordsOf(connection, providers)}`; +} + +export function linkToOpenOf({ connect, ...connection }: AddedConnection): LinkToOpen | undefined { + return connect === undefined + ? undefined + : { + message: `Open this page to approve ${connectionNamed(connection)}, and give its credential where it takes one, within ten minutes.`, + url: connect.url, + }; +} diff --git a/packages/connections/src/operations/read-connections.ts b/packages/connections/src/operations/read-connections.ts index b3cf14453..734ad4847 100644 --- a/packages/connections/src/operations/read-connections.ts +++ b/packages/connections/src/operations/read-connections.ts @@ -7,7 +7,7 @@ import { ConnectionIdField } from './connection-fields.ts'; import type { ConnectionServices } from './connection-services.ts'; import { connectionsListed } from './connection-words.ts'; import { foundConnection, loadRegistry } from './registry-access.ts'; -import { shownConnections } from './shown-connections.ts'; +import { shownConnection, shownConnections } from './shown-connections.ts'; type Reading = Pick<ConnectionServices, 'secrets'>; @@ -56,9 +56,6 @@ export function defineGetConnection({ secrets }: Reading) { inputSchema: Schema.Struct({ id: ConnectionIdField }), answer: { kind: 'json', outputSchema: ConnectionSchema }, reasons: ['not_found'], - handle: Effect.fnUntraced(function* ({ id }) { - const [shown] = yield* shownConnections(secrets, [yield* foundConnection(id)]); - return yield* Effect.fromNullishOr(shown).pipe(Effect.orDie); - }), + handle: ({ id }) => Effect.flatMap(foundConnection(id), (kept) => shownConnection(secrets, kept)), }); } diff --git a/packages/connections/src/operations/registry-access.ts b/packages/connections/src/operations/registry-access.ts index 8d436e35e..9bd8a0c4f 100644 --- a/packages/connections/src/operations/registry-access.ts +++ b/packages/connections/src/operations/registry-access.ts @@ -5,6 +5,7 @@ import { randomUUIDv7, recordedCallerOf, type NotFound, + type OfferedLink, type RecordedCaller, } from '@on.auto/operations'; import { DateTime, Effect } from 'effect'; @@ -12,8 +13,10 @@ import { DateTime, Effect } from 'effect'; import type { GivenAddition } from '../providers/provider-additions.ts'; import type { ConnectionAddition, ConnectionCommandData } from '../registry/connection-commands.ts'; import { connectionsStream, registryDecider } from '../registry/registry-decider.ts'; -import { findConnection } from '../registry/registry-lookup.ts'; +import { findConnection, openLinkOf } from '../registry/registry-lookup.ts'; import type { KeptConnection, OpenLink, Registry } from '../registry/registry.ts'; +import type { SecretKeeping } from './connection-services.ts'; +import { shownConnection } from './shown-connections.ts'; export const loadRegistry: Effect.Effect<Registry, never, OrgReader> = Effect.gen(function* () { const { state } = yield* (yield* OrgReader).load(connectionsStream, registryDecider); @@ -26,6 +29,16 @@ export function foundConnection(id: string): Effect.Effect<KeptConnection, NotFo export const recordedCaller = Effect.map(Effect.service(Caller), recordedCallerOf); +export const standingAfterTheLink = Effect.fnUntraced(function* ( + secrets: Pick<SecretKeeping, 'endingsOf'>, + { id, url }: OfferedLink, +) { + const kept = yield* Effect.orDie(foundConnection(id)); + const connection = yield* shownConnection(secrets, kept); + const link = openLinkOf(kept, DateTime.formatIso(yield* DateTime.now)); + return link === undefined ? connection : { ...connection, connect: { url, expires_at: link.expires_at } }; +}); + export const actedNow = Effect.gen(function* () { const { id: by } = yield* Caller; return { by, at: DateTime.formatIso(yield* DateTime.now) }; diff --git a/packages/connections/src/operations/shown-connections.ts b/packages/connections/src/operations/shown-connections.ts index 5325496d2..00212b624 100644 --- a/packages/connections/src/operations/shown-connections.ts +++ b/packages/connections/src/operations/shown-connections.ts @@ -17,3 +17,10 @@ export function shownConnections( kept.map(({ connection, secretId }) => connectionShown(connection, endingOf(endings, secretId))), ); } + +export function shownConnection( + secrets: Pick<SecretKeeping, 'endingsOf'>, + kept: KeptConnection, +): Effect.Effect<Connection> { + return Effect.flatMap(shownConnections(secrets, [kept]), ([shown]) => Effect.orDie(Effect.fromNullishOr(shown))); +} diff --git a/packages/connections/src/registry/registry-lookup.ts b/packages/connections/src/registry/registry-lookup.ts index 51b42f290..c7b79c394 100644 --- a/packages/connections/src/registry/registry-lookup.ts +++ b/packages/connections/src/registry/registry-lookup.ts @@ -32,6 +32,10 @@ export function findConnection(registry: Registry, id: string): Effect.Effect<Ke return Effect.fromResult(keptConnectionOf(registry, id)); } +export function openLinkOf({ link }: KeptConnection, at: string): OpenLink | undefined { + return link !== undefined && link.expires_at > at ? link : undefined; +} + function isOfTheToken(kept: KeptConnection, tokenSha256: string): kept is KeptWithLink { const { connection, link } = kept; return connection.status === 'pending' && link !== undefined && link.token_sha256 === tokenSha256; diff --git a/packages/operations/README.md b/packages/operations/README.md index be369bfd4..155ac4f99 100644 --- a/packages/operations/README.md +++ b/packages/operations/README.md @@ -68,7 +68,7 @@ When an operation is defined, it checks again what the compiler cannot see, on t At run time, when a handler fails with a reason it did not declare, the call fails; it is not rejected. -A definition may give `linkToOpen`, a function of its output that answers `{ message, url }`: a page the person is to open, such as Studio's page that approves a connection, and a sentence of what to do there. The registration carries it as a function of the output as JSON, and an MCP tool asks a client that opens links to open it (`@on.auto/api`); the output holds the address either way. +A definition may give `linkToOpen`, a function of its output that answers `{ message, url }`, or nothing where the output holds no link: a page the person is to open, such as Studio's page that approves a connection, and a sentence of what to do there. It gives `answerAfterTheLink` with it, `{ idOf, read }`: the id of what the link is for, read from the output, and `read({ id, url })`, which answers the output as it stands once the person is back from the page, with the services a handler of its scope and kind may use; a definition that gives one of the two without the other is refused. The registration carries them as `link`, `{ toOpen, idOf, answerAfter }`, each a function of the output as JSON, `answerAfter(link)` a registration like its own whose run decodes the call's input as its own does and answers what the read gives. An MCP tool asks a client that opens links to open the page and answers the client's retry with `answerAfter` (`@on.auto/api`); the output holds the address either way. A definition sets `reachesOutside: true` when its handler calls a system outside the server, such as a model provider, and `mayChangeOutside: true` when that call may change something there, such as a tool that writes; the registration carries both, `false` when left out, and a transport can tell its callers, as the MCP tools do with `openWorldHint` and `destructiveHint`. diff --git a/packages/operations/src/definition/codecs.test.ts b/packages/operations/src/definition/codecs.test.ts index 6ad7094e0..3d86a7fbe 100644 --- a/packages/operations/src/definition/codecs.test.ts +++ b/packages/operations/src/definition/codecs.test.ts @@ -1,9 +1,9 @@ -import { Effect, Schema } from 'effect'; +import { Effect, Option, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { defineCommand, defineQuery, takesNoBody } from '../index.ts'; +import { defineCommand, defineQuery, takesNoBody, type OfferedLink } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; -import { harness, toPlaybook } from '../testing/harness.ts'; +import { harness, toOrg, toPlaybook } from '../testing/harness.ts'; import { holdTree } from '../testing/misbehaving.ts'; import { addNote, listNotes } from '../testing/notes.ts'; @@ -117,29 +117,67 @@ describe('the input of a call as strings', () => { }); }); -describe('the link a registration asks a person to open', () => { - const Linked = Schema.Struct({ opened: Schema.String, at: Schema.Date }); - - const linking = defineCommand('org', { - title: 'Link', - description: 'Links.', - inputSchema: NoInput, - reasons: [], - name: 'link', - route: { method: 'DELETE', path: '/link' }, - outputSchema: Linked, - handle: () => Effect.succeed({ opened: 'https://page.example/open', at: new Date(0) }), - linkToOpen: ({ opened, at }) => ({ message: `Open it before ${at.toISOString()}`, url: opened }), - }); +const Linked = Schema.Struct({ id: Schema.String, opened: Schema.optionalKey(Schema.String), at: Schema.Date }); + +const linked = { + title: 'Link', + description: 'Links.', + inputSchema: NoInput, + reasons: [], + name: 'link', + route: { method: 'DELETE', path: '/link' }, + outputSchema: Linked, + handle: () => Effect.succeed({ id: 'page-1', opened: 'https://page.example/open', at: new Date(0) }), +} as const; + +function linkOf({ opened, at }: typeof Linked.Type) { + return opened === undefined ? undefined : { message: `Open it before ${at.toISOString()}`, url: opened }; +} + +const answeredAgain = { + idOf: ({ id }: typeof Linked.Type) => id, + read: ({ id, url }: OfferedLink) => Effect.succeed({ id, opened: `${url}#again`, at: new Date(1000) }), +}; + +const linking = defineCommand('org', { ...linked, linkToOpen: linkOf, answerAfterTheLink: answeredAgain }); + +const offered = { id: 'page-1', url: 'https://page.example/open' }; + +function answeringAfter(link: OfferedLink) { + return Option.getOrThrow(Option.fromNullishOr(linking.registration.link)).answerAfter(link); +} - it('is read from its output as JSON gives it, and is not there unless its definition gives one', () => { - const output = { opened: 'https://page.example/open', at: '1970-01-01T00:00:00.000Z' }; +describe('the link a registration asks a person to open', () => { + it('is read from its output as JSON gives it, with the id of what it is for, and is not there unless its definition gives one', () => { + const output = { id: 'page-1', opened: 'https://page.example/open', at: '1970-01-01T00:00:00.000Z' }; - expect(linking.registration.linkToOpen?.(output)).toEqual({ + expect(linking.registration.link?.toOpen(output)).toEqual({ message: 'Open it before 1970-01-01T00:00:00.000Z', url: 'https://page.example/open', }); - expect(addNote.registration.linkToOpen).toBeUndefined(); + expect(linking.registration.link?.toOpen({ id: 'page-1', at: '1970-01-01T00:00:00.000Z' })).toBeUndefined(); + expect(linking.registration.link?.idOf(output)).toBe('page-1'); + expect(addNote.registration.link).toBeUndefined(); + }); + + it('answers the retry after it with what its read gives, under the input of the call', async () => { + const { dispatcher, run } = harness(); + const toAcme = toOrg('acme'); + + expect(await run(dispatcher.dispatchToOrg(answeringAfter(offered), toAcme(acmeAdmin)))).toEqual({ + status: 'succeeded', + output: { id: 'page-1', opened: 'https://page.example/open#again', at: '1970-01-01T00:00:01.000Z' }, + }); + expect(await run(dispatcher.dispatchToOrg(answeringAfter(offered), toAcme(acmeAdmin, { other: 1 })))).toMatchObject( + { status: 'rejected', reason: 'invalid_input' }, + ); + }); + + it('is refused where a definition gives the link without the answer after it, or that answer without the link', () => { + const halfLinked = 'The operation link gives one of linkToOpen and answerAfterTheLink without the other'; + + expect(() => defineCommand('org', { ...linked, linkToOpen: linkOf })).toThrow(halfLinked); + expect(() => defineCommand('org', { ...linked, answerAfterTheLink: answeredAgain })).toThrow(halfLinked); }); }); diff --git a/packages/operations/src/definition/codecs.ts b/packages/operations/src/definition/codecs.ts index 2db8e1af4..cec5ae34d 100644 --- a/packages/operations/src/definition/codecs.ts +++ b/packages/operations/src/definition/codecs.ts @@ -2,9 +2,11 @@ import { Cause, Effect, Option, Result, Schema, SchemaIssue, type SchemaAST, typ import { rejected, type Rejected } from '../outcome/outcome.ts'; import { pointerOf } from '../outcome/pointer.ts'; +import type { DeclarableReason, Rejection } from '../outcome/rejection.ts'; import type { LinkToOpen, PlainLanguage, RegisteredPlainLanguage } from '../plain-language/plain-language.ts'; -import type { ObjectSchema } from './definition.ts'; +import type { AnswerAfterTheLink, ObjectSchema } from './definition.ts'; import type { InputEncoding } from './registered-run.ts'; +import type { RegisteredLink } from './registration.ts'; type IssueSegment = PropertyKey | StandardSchema.StandardSchemaV1.PathSegment; @@ -87,15 +89,31 @@ export function plainLanguageFields<In extends ObjectSchema, Out extends ObjectS return language === undefined ? {} : { plainLanguage: registeredPlainLanguage(language, inputSchema, outputSchema) }; } -export function linkToOpenFields<Out extends ObjectSchema>( - linkOf: ((output: Out['Type']) => LinkToOpen) | undefined, +interface Linking<Out, R extends DeclarableReason, Services> { + readonly name: string; + readonly linkToOpen?: ((output: Out) => LinkToOpen | undefined) | undefined; + readonly answerAfterTheLink?: AnswerAfterTheLink<Out, R, Services> | undefined; +} + +export function linkFields<Out extends ObjectSchema, R extends DeclarableReason, Services, Answering>( + { name, linkToOpen, answerAfterTheLink }: Linking<Out['Type'], R, Services>, outputSchema: Out, -): { readonly linkToOpen?: (output: unknown) => LinkToOpen } { - if (linkOf === undefined) { + answeringBy: (read: () => Effect.Effect<Out['Type'], Rejection<R>, Services>) => Answering, +): { readonly link?: RegisteredLink<Answering> } { + if (linkToOpen === undefined && answerAfterTheLink === undefined) { return {}; } + if (linkToOpen === undefined || answerAfterTheLink === undefined) { + throw new Error(`The operation ${name} gives one of linkToOpen and answerAfterTheLink without the other`); + } const decodeOutput = Schema.decodeUnknownSync(Schema.toCodecJson(outputSchema)); - return { linkToOpen: (output) => linkOf(decodeOutput(output)) }; + return { + link: { + toOpen: (output) => linkToOpen(decodeOutput(output)), + idOf: (output) => answerAfterTheLink.idOf(decodeOutput(output)), + answerAfter: (offered) => answeringBy(() => answerAfterTheLink.read(offered)), + }, + }; } export function typeValidator<S extends ObjectSchema>(schema: S): (value: S['Type']) => Effect.Effect<S['Type']> { diff --git a/packages/operations/src/definition/definition.ts b/packages/operations/src/definition/definition.ts index 7229468d2..5b8a3e778 100644 --- a/packages/operations/src/definition/definition.ts +++ b/packages/operations/src/definition/definition.ts @@ -7,6 +7,16 @@ import type { DeclarableReason, Rejection } from '../outcome/rejection.ts'; import type { LinkToOpen, PlainLanguage } from '../plain-language/plain-language.ts'; import type { MethodOf, PathParameters, Route } from './route.ts'; +export interface OfferedLink { + readonly id: string; + readonly url: string; +} + +export interface AnswerAfterTheLink<Out, R extends DeclarableReason, Services> { + readonly idOf: (output: Out) => string; + readonly read: (link: OfferedLink) => Effect.Effect<Out, Rejection<R>, Services>; +} + interface ObjectValue { readonly [field: string]: unknown; } @@ -40,6 +50,7 @@ export interface Definition< Out extends ObjectSchema, R extends DeclarableReason, Services, + ReadServices = never, > { readonly name: string; readonly title: string; @@ -56,12 +67,13 @@ export interface Definition< readonly reasons: readonly R[]; readonly handle: (input: In['Type']) => Effect.Effect<Out['Type'], NoInfer<Rejection<R>>, Services>; readonly plainLanguage?: PlainLanguage<NoInfer<In['Type']>, NoInfer<Out['Type']>>; - readonly linkToOpen?: (output: NoInfer<Out['Type']>) => LinkToOpen; + readonly linkToOpen?: (output: NoInfer<Out['Type']>) => LinkToOpen | undefined; + readonly answerAfterTheLink?: AnswerAfterTheLink<NoInfer<Out['Type']>, NoInfer<R>, ReadServices>; } export type StreamedBodyKind = 'octet-stream' | 'csv'; -export type BodyKind = 'json' | 'none' | StreamedBodyKind; +type BodyKind = 'json' | 'none' | StreamedBodyKind; export interface StreamedBody { readonly kind: StreamedBodyKind; diff --git a/packages/operations/src/definition/operation.ts b/packages/operations/src/definition/operation.ts index 4234f25c8..2f3855107 100644 --- a/packages/operations/src/definition/operation.ts +++ b/packages/operations/src/definition/operation.ts @@ -3,7 +3,7 @@ import { Effect, type Schema } from 'effect'; import type { OperationKind, OperationScope } from '../caller/operation-scope.ts'; import { succeeded, type Answered, type Rejected } from '../outcome/outcome.ts'; import type { DeclarableReason, Rejection } from '../outcome/rejection.ts'; -import { inputDecoder, linkToOpenFields, outputEncoder, plainLanguageFields, typeValidator } from './codecs.ts'; +import { inputDecoder, linkFields, outputEncoder, plainLanguageFields, typeValidator } from './codecs.ts'; import type { Definition, InputSchemaFor, ObjectSchema, RelativePath } from './definition.ts'; import type { HandlerServices } from './handler-services.ts'; import type { InputEncoding } from './registered-run.ts'; @@ -29,9 +29,10 @@ export function defineQuery< Out extends ObjectSchema, const R extends DeclarableReason = never, Services extends HandlerServices<S, 'query'> = never, + ReadServices extends HandlerServices<S, 'query'> = never, >( scope: S, - definition: Definition<'query', P, In, Out, R, Services>, + definition: Definition<'query', P, In, Out, R, Services, ReadServices>, ): Operation<S, 'query', In['Type'], Out['Type'], R, Services> { return defineOperation(scope, 'query', definition); } @@ -43,9 +44,10 @@ export function defineCommand< Out extends ObjectSchema, const R extends DeclarableReason = never, Services extends HandlerServices<S, 'command'> = never, + ReadServices extends HandlerServices<S, 'command'> = never, >( scope: S, - definition: Definition<'command', P, In, Out, R, Services>, + definition: Definition<'command', P, In, Out, R, Services, ReadServices>, ): Operation<S, 'command', In['Type'], Out['Type'], R, Services> { return defineOperation(scope, 'command', definition); } @@ -69,10 +71,11 @@ function defineOperation< Out extends ObjectSchema, R extends DeclarableReason, Services extends HandlerServices<S, K>, + ReadServices extends HandlerServices<S, K>, >( scope: S, kind: K, - definition: Definition<K, string, In, Out, R, Services>, + definition: Definition<K, string, In, Out, R, Services, ReadServices>, ): Operation<S, K, In['Type'], Out['Type'], R, Services> { const { inputSchema, outputSchema, handle } = definition; const fields = registeredFieldsOf(scope, kind, definition); @@ -80,13 +83,18 @@ function defineOperation< const encodeOutput = outputEncoder(outputSchema); const validateInput = typeValidator(inputSchema); const validateOutput = typeValidator(outputSchema); + const runOf = (answer: (input: In['Type']) => Effect.Effect<Out['Type'], Rejection<R>, Services | ReadServices>) => + runnerOf(decodeInput, answer, encodeOutput, fields.reasons); + const registration = { + ...fields, + output: registeredOutputOf(outputSchema), + ...plainLanguageFields(definition.plainLanguage, inputSchema, outputSchema), + run: runOf(handle), + }; return { registration: { - ...fields, - output: registeredOutputOf(outputSchema), - ...plainLanguageFields(definition.plainLanguage, inputSchema, outputSchema), - ...linkToOpenFields(definition.linkToOpen, outputSchema), - run: runnerOf(decodeInput, handle, encodeOutput, fields.reasons), + ...registration, + ...linkFields(definition, outputSchema, (read) => ({ ...registration, run: runOf(read) })), }, call: (input) => validateInput(input).pipe(Effect.flatMap(handle), Effect.tap(validateOutput)), }; diff --git a/packages/operations/src/definition/registration-parts.ts b/packages/operations/src/definition/registration-parts.ts index e671c64bf..fab20c854 100644 --- a/packages/operations/src/definition/registration-parts.ts +++ b/packages/operations/src/definition/registration-parts.ts @@ -50,7 +50,7 @@ type RegisteredDefinition<K extends OperationKind> = Pick< type RegisteredFields<S extends OperationScope, K extends OperationKind> = Omit< RegisteredFieldsOf<S, K>, - 'plainLanguage' | 'linkToOpen' + 'plainLanguage' >; export function registeredFieldsOf<S extends OperationScope, K extends OperationKind>( diff --git a/packages/operations/src/definition/registration.ts b/packages/operations/src/definition/registration.ts index bdbeea902..210b40a46 100644 --- a/packages/operations/src/definition/registration.ts +++ b/packages/operations/src/definition/registration.ts @@ -3,7 +3,7 @@ import type { Permission } from '../caller/permission.ts'; import type { Answered, Succeeded } from '../outcome/outcome.ts'; import type { DeclarableReason } from '../outcome/rejection.ts'; import type { LinkToOpen, RegisteredPlainLanguage } from '../plain-language/plain-language.ts'; -import type { HttpBody, StreamAnswer, SuccessStatusByKind } from './definition.ts'; +import type { HttpBody, OfferedLink, StreamAnswer, SuccessStatusByKind } from './definition.ts'; import type { HandlerServices } from './handler-services.ts'; import type { JsonSchemaDocument } from './json-schema.ts'; import type { RegisteredRun } from './registered-run.ts'; @@ -28,7 +28,12 @@ export interface RegisteredFieldsOf<S extends OperationScope, K extends Operatio readonly input: JsonSchemaDocument; readonly refusesEmptyBody: boolean; readonly plainLanguage?: RegisteredPlainLanguage; - readonly linkToOpen?: (output: unknown) => LinkToOpen; +} + +export interface RegisteredLink<R> { + readonly toOpen: (output: unknown) => LinkToOpen | undefined; + readonly idOf: (output: unknown) => string; + readonly answerAfter: (link: OfferedLink) => R; } interface RunnableOf<S extends OperationScope, K extends OperationKind, A extends Succeeded> extends RegisteredFieldsOf< @@ -42,9 +47,10 @@ interface RunnableOf<S extends OperationScope, K extends OperationKind, A extend export interface RegistrationOf<S extends OperationScope, K extends OperationKind> extends RunnableOf<S, K, Answered> { readonly servedOverHttpAlone?: never; readonly output: JsonSchemaDocument; + readonly link?: RegisteredLink<RegistrationOf<S, K>>; } -export interface JsonAnswered { +interface JsonAnswered { readonly kind: 'json'; readonly output: JsonSchemaDocument; } diff --git a/packages/operations/src/index.ts b/packages/operations/src/index.ts index d4afa9e74..f951fc71c 100644 --- a/packages/operations/src/index.ts +++ b/packages/operations/src/index.ts @@ -169,16 +169,7 @@ export { NotFound } from './outcome/not-found.ts'; export { defineCommand, defineQuery, type Operation } from './definition/operation.ts'; export { defineHttpOperation, type HttpOperation } from './definition/http-operation.ts'; export { isStreamedBody, mediaTypeOfBody } from './definition/http-body.ts'; -export type { - AnswerKind, - BodyKind, - HttpBody, - HttpDefinition, - JsonAnswer, - StreamAnswer, - StreamedBody, - StreamedBodyKind, -} from './definition/definition.ts'; +export type { HttpBody, OfferedLink, StreamedBody } from './definition/definition.ts'; export { OrgReader } from './ledger/org-reader.ts'; export { explanationOf, @@ -250,11 +241,9 @@ export type { DeclarableReason, Rejection, RejectionKind } from './outcome/rejec export type { InputEncoding } from './definition/registered-run.ts'; export type { HttpRegistration, - HttpRegistrationOf, - JsonAnswered, RegisteredAnswer, + RegisteredLink, Registration, - RegistrationOf, RouteRegistration, Runnable, } from './definition/registration.ts'; diff --git a/packages/server/src/connections/connections-over-mcp.test.ts b/packages/server/src/connections/connections-over-mcp.test.ts index 015731433..fe11168d7 100644 --- a/packages/server/src/connections/connections-over-mcp.test.ts +++ b/packages/server/src/connections/connections-over-mcp.test.ts @@ -32,11 +32,26 @@ const notes = { type: 'mcp', entry: { url: 'https://notes.example.com/mcp' }, cr const AddedSchema = Schema.Struct({ connect: Schema.Struct({ url: Schema.String }) }); +const PageSchema = Schema.Struct({ connection: Schema.String, token: Schema.String }); + function adding(name: string, opening: Partial<LinkOpening> = {}) { const connection = { url: `${server.origin}/mcp`, headers: {} }; return calledOpeningLinks(connection, { name: 'add_connection', arguments: { ...notes, name } }, opening); } +function linkOn(url: string) { + return Schema.decodeUnknownSync(PageSchema)(Object.fromEntries(new URL(url).searchParams)); +} + +function approving(url: string) { + const { connection, token } = linkOn(url); + return server.call('POST', `/v1/orgs/local/connections/${connection}/approve`, { body: { token } }); +} + +function cancelling(url: string) { + return server.call('DELETE', `/v1/orgs/local/connections/${linkOn(url).connection}`); +} + function toolsOn(path: string): Promise<readonly string[]> { return withMcpSession('current revision', { url: `${server.origin}${path}`, headers: {} }, async (session) => toolNamesIn(await session.listTools()), @@ -60,7 +75,7 @@ describe('the tools of connections', () => { }); describe('add_connection over MCP', () => { - it('asks a client that opens links to open the page of the link, saying what to do, and puts the link in its answer', async () => { + it('asks a client that opens links to open the page of the link, saying what to do, and answers the retry after a continue without approving with the link', async () => { const { answer, elicited } = await adding('notes-linked'); const { url } = Schema.decodeUnknownSync(AddedSchema)(answer.structuredContent).connect; @@ -73,6 +88,10 @@ describe('add_connection over MCP', () => { }, ]); expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/studio\/connect\?connection=[\da-f-]{36}&token=[\w-]{43}$/u); + expect(answer.structuredContent).toMatchObject({ name: 'notes-linked', status: 'pending' }); + expect(plainTextIn(answer)).toBe( + 'Added the connection “notes-linked”. The person who added it approves it, and gives its credential where it takes one, on the page its link opens, within ten minutes; shown to them as a link named “Approve the connection”, never as its address.', + ); }); it('asks nothing of a client that does not declare it opens links, or speaks 2025-11-25, and answers the link, with words that say to show it by a name', async () => { @@ -90,6 +109,40 @@ describe('add_connection over MCP', () => { }); }); +describe('the retry of add_connection after the page', () => { + it('answers the connection connected after the person approved it on the page, with no link, in words that say nothing is left to open', async () => { + const { answer } = await adding('notes-approved', { onThePage: approving }); + + expect(answer.structuredContent).toMatchObject({ + name: 'notes-approved', + status: 'connected', + connected_by: { id: 'local', name: localDeveloper.name }, + }); + expect(answer.structuredContent).not.toHaveProperty('connect'); + expect(plainTextIn(answer)).toBe('Connected the connection “notes-approved”; nothing is left to open.'); + }); + + it('answers the connection removed after the person cancelled on the page, in words that say nothing is connected', async () => { + const { answer } = await adding('notes-cancelled', { onThePage: cancelling }); + + expect(answer.structuredContent).toMatchObject({ name: 'notes-cancelled', status: 'removed' }); + expect(answer.structuredContent).not.toHaveProperty('connect'); + expect(plainTextIn(answer)).toBe( + 'The person cancelled the connection “notes-cancelled” on the page, so it is removed and nothing is connected.', + ); + }); + + it('answers the connection pending with its link after the person declined to open the page, in words that say they declined', async () => { + const { answer } = await adding('notes-declined', { action: 'decline' }); + + expect(Schema.decodeUnknownSync(AddedSchema)(answer.structuredContent).connect.url).toMatch(/\/studio\/connect\?/u); + expect(answer.structuredContent).toMatchObject({ name: 'notes-declined', status: 'pending' }); + expect(plainTextIn(answer)).toMatch( + /^The person declined to open the page, and its link is in this answer\. Added the connection “notes-declined”\. /u, + ); + }); +}); + async function servedFor(environment: Readonly<Record<string, string>> = {}): Promise<ReasoningServer> { const served = await servingReasoning([], environment); const stopping: { done?: Promise<void> } = {};