From fefd18c57d5fa5f466fe502d61c291bc7118eeab Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 17 Sep 2026 22:10:15 +0200 Subject: [PATCH 01/15] docs: mark SDK authorization implementation in progress Signed-off-by: Samuele Verzi --- docs/acceptance/sdk-mcp-authorization-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/sdk-mcp-authorization-lifecycle.md b/docs/acceptance/sdk-mcp-authorization-lifecycle.md index 0c000a2266..434d87e52d 100644 --- a/docs/acceptance/sdk-mcp-authorization-lifecycle.md +++ b/docs/acceptance/sdk-mcp-authorization-lifecycle.md @@ -4,7 +4,7 @@ **Work classification:** Architectural — this adds durable public TypeScript SDK resource, stream, status, result, and control contracts for a stateful session-bound authorization workflow. **Decision record:** [ADR 0348](../adr/0348-typescript-sdk-mcp-authorization-lifecycle.md) **Phase:** ergonomic TypeScript SDK MCP authorization lifecycle -**Status:** proposed, 2026-09-18. The directing user approved the session-bound lifecycle, Run handoff, lazy dispatch, bounded recovery contract, and the attachment, termination, and correlation clarifications requested during Plan / Interface review. +**Status:** in-progress, 2026-09-18. Implementation proceeds as a sequential stack layer above open Plan / Interface PR #1687 by explicit directing-user instruction and includes the attachment, termination, and correlation clarifications requested during review. **Delivery:** Split. The public SDK object model, Run parking contract, single-consumption stream grammar, control routing, and disconnect semantics require Plan / Interface review before implementation. **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1469](https://github.com/stacklok/mecatl/issues/1469) From 2ec773016993303a5988542aecc65dd765c1eb6e Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 17 Sep 2026 22:09:33 +0200 Subject: [PATCH 02/15] feat(sdk): hand off parked run authorization Signed-off-by: Samuele Verzi --- sdk/typescript/src/client.ts | 13 +- sdk/typescript/src/index.ts | 4 + sdk/typescript/src/run.ts | 156 ++++++++++++++++--- sdk/typescript/test/run.test.ts | 260 +++++++++++++++++++++++++++++++- 4 files changed, 410 insertions(+), 23 deletions(-) diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index e55f37cd6a..28ce093922 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -1605,12 +1605,19 @@ function unwrapEvents( transport: TransportKind, release: () => void, ): AsyncIterator { + let returned = false; + const close = async () => { + release(); + if (returned) return; + returned = true; + await responses.return?.(); + }; return { next: async () => { try { const next = await responses.next(); if (next.done) { - release(); + await close(); return { done: true, value: undefined }; } const event = next.value.event; @@ -1629,6 +1636,10 @@ function unwrapEvents( throw error; } }, + return: async () => { + await close(); + return { done: true, value: undefined }; + }, }; } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 92630580e7..6d942576a1 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -169,9 +169,13 @@ export type { PermissionAskResponder, PermissionVerdict, Run, + RunAuthorizationRequiredOutcome, + RunCompletedOutcome, RunOptions, + RunOutcome, RunResult, } from "./run.js"; +export { RunAuthorizationRequiredError } from "./run.js"; export type { RunControls, RunSteerAcknowledgement, diff --git a/sdk/typescript/src/run.ts b/sdk/typescript/src/run.ts index 418b5e96ec..d4d043893e 100644 --- a/sdk/typescript/src/run.ts +++ b/sdk/typescript/src/run.ts @@ -2,6 +2,7 @@ import type { MessageInitShape } from "@bufbuild/protobuf"; import { InvalidStateError, + type MecatlErrorOptions, PermissionAskAlreadyResolvedError, ProtocolError, type TransportKind, @@ -55,6 +56,33 @@ export interface RunResult { readonly rawEvent: EventOf<"result">; } +/** A normally completed run outcome. @public */ +export interface RunCompletedOutcome { + readonly outcome: "completed"; + readonly result: RunResult; +} + +/** A run that handed off one pending external authorization. @public */ +export interface RunAuthorizationRequiredOutcome { + readonly outcome: "authorization_required"; + readonly sessionId: string; + readonly runId: string; + readonly authorization: EventOf<"authorization.required">; +} + +/** The closed set of normal outcomes from Run.outcome(). @public */ +export type RunOutcome = RunCompletedOutcome | RunAuthorizationRequiredOutcome; + +/** Run.result() consumed a valid authorization handoff instead of a completed result. @public */ +export class RunAuthorizationRequiredError extends InvalidStateError { + readonly outcome: RunAuthorizationRequiredOutcome; + + constructor(outcome: RunAuthorizationRequiredOutcome, options: Omit) { + super(`Run ${outcome.runId} requires external authorization`, options); + this.outcome = outcome; + } +} + /** One accepted server run and its single-consumption event stream. @public */ export interface Run extends AsyncIterable { readonly id: string; @@ -91,6 +119,13 @@ export interface Run extends AsyncIterable { * @returns A promise that resolves after the steering request is sent. */ steer(text: string): Promise; + /** + * Drains all remaining events and returns either completion or an authorization handoff. + * + * @returns The normal terminal outcome for this run. + * @throws `InvalidStateError` when the run is already being consumed. + */ + outcome(): Promise; /** * Drains all remaining events and returns the typed terminal outcome. * @@ -106,7 +141,7 @@ export interface RunOperations { send(frame: MessageInitShape): void; } -type ConsumptionMode = "events" | "result"; +type ConsumptionMode = "events" | "outcome" | "result"; type PendingAsk = { readonly controller: AbortController; readonly plan: boolean }; export class RunImpl implements Run { @@ -123,6 +158,8 @@ export class RunImpl implements Run { #consumption: ConsumptionMode | undefined; #ended = false; #firstPending = true; + #authorization: EventOf<"authorization.required"> | undefined; + #streamEnded = false; #terminal: EventOf<"result"> | undefined; #steerSequence = 0; @@ -239,26 +276,46 @@ export class RunImpl implements Run { return this.#consumer(); } + async outcome(): Promise { + this.#claim("outcome"); + return this.#drainOutcome(); + } + async result(): Promise { this.#claim("result"); + const outcome = await this.#drainOutcome(); + if (outcome.outcome === "authorization_required") { + throw new RunAuthorizationRequiredError(outcome, { + transport: this.#operations.transportKind, + }); + } + return outcome.result; + } + + async #drainOutcome(): Promise { for (;;) { const next = await this.#next(); if (next.done) break; } const event = this.#terminal; - if (event === undefined) { - throw new ProtocolError("The Converse stream ended without a terminal result", { - transport: this.#operations.transportKind, - }); + if (event !== undefined) { + return { outcome: "completed", result: runResult(this.sessionId, this.id, event) }; } + + const authorization = this.#authorization; + if (authorization !== undefined) return this.#authorizationOutcome(authorization); + + throw this.#protocol("The Converse stream ended without a terminal outcome"); + } + + #authorizationOutcome( + authorization: EventOf<"authorization.required">, + ): RunAuthorizationRequiredOutcome { return { - content: event.payload.text, - rawEvent: event, + authorization, + outcome: "authorization_required", runId: this.id, sessionId: this.sessionId, - stopReason: event.payload.stop, - text: event.payload.text, - usage: event.payload.usage ?? event.usage, }; } @@ -276,7 +333,12 @@ export class RunImpl implements Run { #consumer(): AsyncIterator { return { next: () => this.#next(), - return: async () => ({ done: true, value: undefined }), + return: async () => { + if (this.#authorization !== undefined || this.#terminal !== undefined) { + await this.#closeStream(); + } + return { done: true, value: undefined }; + }, }; } @@ -286,7 +348,7 @@ export class RunImpl implements Run { this.#firstPending = false; return { done: false, value: this.#first }; } - if (this.#terminal !== undefined) return { done: true, value: undefined }; + if (this.#streamEnded) return { done: true, value: undefined }; let next: IteratorResult; try { next = await this.#events.next(); @@ -295,22 +357,51 @@ export class RunImpl implements Run { throw error; } if (next.done) { + this.#streamEnded = true; this.#end(); - throw new ProtocolError("The Converse stream ended without a terminal result", { - transport: this.#operations.transportKind, - }); + if (this.#terminal !== undefined || this.#authorization !== undefined) { + return { done: true, value: undefined }; + } + throw this.#protocol("The Converse stream ended without a terminal outcome"); + } + if (this.#terminal !== undefined) { + await this.#closeStream(); + throw this.#protocol("The Converse stream returned an event after its terminal result"); + } + if (this.#authorization !== undefined) { + await this.#closeStream(); + throw this.#protocol("The Converse stream returned an event after authorization parking"); } if (next.value.runId !== this.id) { - throw new ProtocolError("The Converse stream changed run id", { - transport: this.#operations.transportKind, - }); + await this.#closeStream(); + throw this.#protocol("The Converse stream changed run id"); + } + try { + const event = decodeEvent(next.value, this.#operations.transportKind); + this.#observe(event); + return { done: false, value: event }; + } catch (error) { + await this.#closeStream(); + throw error; } - const event = decodeEvent(next.value, this.#operations.transportKind); - this.#observe(event); - return { done: false, value: event }; } #observe(event: Event): void { + if (event.kind === "authorization.required") { + if ( + this.sessionId === "" || + this.id === "" || + event.runId !== this.id || + event.payload.status !== "pending" || + event.payload.authorizationId === "" || + event.payload.callId === "" + ) { + throw this.#protocol("The Converse stream returned a malformed authorization requirement"); + } + this.#authorization = event; + this.#end(); + return; + } if (event.kind === "permission.ask") { this.#startAsk(event); return; @@ -401,6 +492,17 @@ export class RunImpl implements Run { this.#pendingAsks.clear(); } + async #closeStream(): Promise { + if (this.#streamEnded) return; + this.#streamEnded = true; + this.#end(); + await this.#events.return?.(); + } + + #protocol(message: string): ProtocolError { + return new ProtocolError(message, { transport: this.#operations.transportKind }); + } + #send(frame: MessageInitShape): void { this.#operations.assertOpen(); this.#operations.send(frame); @@ -424,4 +526,16 @@ function terminal(event: Event): event is EventOf<"result"> { return event.kind === "result"; } +function runResult(sessionId: string, runId: string, event: EventOf<"result">): RunResult { + return { + content: event.payload.text, + rawEvent: event, + runId, + sessionId, + stopReason: event.payload.stop, + text: event.payload.text, + usage: event.payload.usage ?? event.usage, + }; +} + export type ConverseFrame = MessageInitShape; diff --git a/sdk/typescript/test/run.test.ts b/sdk/typescript/test/run.test.ts index 955a0b7723..3327924c43 100644 --- a/sdk/typescript/test/run.test.ts +++ b/sdk/typescript/test/run.test.ts @@ -1,14 +1,17 @@ +import { create } from "@bufbuild/protobuf"; import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect"; import { describe, expect, it } from "vitest"; -import { HarnessService } from "../src/gen/mecatl/v1/harness_pb.js"; +import { EventSchema, HarnessService } from "../src/gen/mecatl/v1/harness_pb.js"; import { connect, InvalidStateError, ProtocolError, + RunAuthorizationRequiredError, SessionBusyError, TransportError, } from "../src/index.js"; +import { RunImpl } from "../src/run.js"; function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -36,7 +39,262 @@ function terminal(runId: string, stop = "end_turn", text = "done") { }; } +function authorizationRequired( + runId: string, + authorizationId = "authorization-1", + callId = "call-1", +) { + return { + event: { + authorization: { + authorizationId, + callId, + displayName: "Example MCP", + status: "pending", + }, + runId, + type: "authorization.required", + }, + }; +} + describe("run choreography", () => { + it("Run outcome discriminates completion from authorization parking", async () => { + let sequence = 0; + const transport = createRouterTransport((router) => { + router.service(HarnessService, { + createSession: () => ({ sessionId: "session-outcome" }), + getCompatibilityInfo: () => ({ apiMajor: 1, capabilities: {}, features: ["server_info"] }), + converse: async function* () { + sequence += 1; + const runId = `run-${sequence}`; + if (sequence === 1) { + yield event(runId, "message.delta", "hello"); + yield terminal(runId); + return; + } + yield authorizationRequired(runId); + }, + }); + }); + const client = connect({ transport }); + const session = await client.sessions.create({}); + + const completed = await (await session.run("complete")).outcome(); + expect(completed).toMatchObject({ + outcome: "completed", + result: { runId: "run-1", sessionId: "session-outcome", text: "done" }, + }); + + const parked = await (await session.run("authorize")).outcome(); + expect(parked).toMatchObject({ + authorization: { + kind: "authorization.required", + payload: { + authorizationId: "authorization-1", + callId: "call-1", + status: "pending", + }, + }, + outcome: "authorization_required", + runId: "run-2", + sessionId: "session-outcome", + }); + await client.close(); + }); + + it("authorization parked Run iteration and result use normal handoff semantics", async () => { + let sequence = 0; + const transport = createRouterTransport((router) => { + router.service(HarnessService, { + createSession: () => ({ sessionId: "session-handoff" }), + getCompatibilityInfo: () => ({ apiMajor: 1, capabilities: {}, features: ["server_info"] }), + converse: async function* () { + sequence += 1; + yield authorizationRequired( + `run-${sequence}`, + `authorization-${sequence}`, + `call-${sequence}`, + ); + }, + }); + }); + const client = connect({ transport }); + const session = await client.sessions.create({}); + + const iterated = await session.run("iterate"); + const events = []; + for await (const value of iterated) events.push(value); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: "authorization.required", + payload: { authorizationId: "authorization-1", callId: "call-1", status: "pending" }, + }); + + const drained = await session.run("result"); + let failure: unknown; + try { + await drained.result(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(RunAuthorizationRequiredError); + expect(failure).not.toBeInstanceOf(ProtocolError); + expect((failure as RunAuthorizationRequiredError).outcome).toMatchObject({ + authorization: { + payload: { authorizationId: "authorization-2", callId: "call-2", status: "pending" }, + }, + outcome: "authorization_required", + runId: "run-2", + sessionId: "session-handoff", + }); + await client.close(); + }); + + it("authorization parked Run releases SDK ownership without cancelling authorization", async () => { + const requestEnds: Array>> = []; + const responseClosed = new Set(); + let sequence = 0; + const never = new Promise(() => undefined); + const transport = createRouterTransport((router) => { + router.service(HarnessService, { + createSession: () => ({ sessionId: "session-release" }), + getCompatibilityInfo: () => ({ apiMajor: 1, capabilities: {}, features: ["server_info"] }), + converse: async function* (requests) { + const input = requests[Symbol.asyncIterator](); + const first = await input.next(); + sequence += 1; + const runId = `run-${sequence}`; + requestEnds.push(input.next()); + try { + yield authorizationRequired(runId, `authorization-${sequence}`, `call-${sequence}`); + if (first.value?.kind.case === "prompt" && first.value.kind.value.text === "return") { + await never; + } + } finally { + responseClosed.add(runId); + } + }, + }); + }); + const client = connect({ transport }); + const session = await client.sessions.create({}); + + const iteratedToEof = await session.run("eof"); + for await (const _event of iteratedToEof) { + // Drain through authorization EOF. + } + expect(responseClosed.has("run-1")).toBe(true); + + await (await session.run("outcome")).outcome(); + expect(responseClosed.has("run-2")).toBe(true); + + await expect((await session.run("result")).result()).rejects.toBeInstanceOf( + RunAuthorizationRequiredError, + ); + expect(responseClosed.has("run-3")).toBe(true); + + const returned = await session.run("return"); + const iterator = returned[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { kind: "authorization.required" }, + }); + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + + const afterPark = await session.run("after park"); + await afterPark.outcome(); + expect(responseClosed.has("run-5")).toBe(true); + + const requestResults = await Promise.all(requestEnds); + expect(requestResults).toHaveLength(5); + expect(requestResults.every((result) => result.done === true)).toBe(true); + await client.close(); + + let responseIteratorReturns = 0; + const direct = new RunImpl( + "session-direct", + "run-direct", + create(EventSchema, authorizationRequired("run-direct").event), + { + next: async () => ({ done: true, value: undefined }), + return: async () => { + responseIteratorReturns += 1; + return { done: true, value: undefined }; + }, + }, + { + assertOpen: () => undefined, + send: () => undefined, + transportKind: "grpc", + }, + ); + const directIterator = direct[Symbol.asyncIterator](); + await directIterator.next(); + await directIterator.return?.(); + expect(responseIteratorReturns).toBe(1); + }); + + it("Run outcome preserves completed and malformed stream behavior", async () => { + let sequence = 0; + const transport = createRouterTransport((router) => { + router.service(HarnessService, { + createSession: () => ({ sessionId: "session-contract" }), + getCompatibilityInfo: () => ({ apiMajor: 1, capabilities: {}, features: ["server_info"] }), + converse: async function* () { + sequence += 1; + const runId = `run-${sequence}`; + if (sequence === 1) { + yield terminal(runId, "end_turn", "still completed"); + return; + } + if (sequence === 2) { + yield event(runId, "message.delta", "truncated"); + return; + } + if (sequence === 3) { + yield { + event: { + authorization: { authorizationId: "", callId: "", status: "granted" }, + runId, + type: "authorization.required", + }, + }; + return; + } + yield terminal(runId); + if (sequence === 4) yield terminal(runId, "error", "duplicate"); + }, + }); + }); + const client = connect({ transport }); + const session = await client.sessions.create({}); + + const resultRun = await session.run("result"); + await expect(resultRun.result()).resolves.toMatchObject({ + runId: "run-1", + stopReason: "end_turn", + text: "still completed", + }); + await expect(resultRun.outcome()).rejects.toBeInstanceOf(InvalidStateError); + expect(() => resultRun[Symbol.asyncIterator]()).toThrow(InvalidStateError); + + const truncated = await session.run("truncated"); + await expect(truncated.outcome()).rejects.toBeInstanceOf(ProtocolError); + await expect(truncated.result()).rejects.toBeInstanceOf(InvalidStateError); + + await expect(session.run("malformed authorization")).rejects.toBeInstanceOf(ProtocolError); + + const duplicate = await session.run("duplicate terminal"); + await expect(duplicate.outcome()).rejects.toBeInstanceOf(ProtocolError); + + const outcomeRun = await session.run("outcome claim"); + await expect(outcomeRun.outcome()).resolves.toMatchObject({ outcome: "completed" }); + await expect(outcomeRun.result()).rejects.toBeInstanceOf(InvalidStateError); + expect(() => outcomeRun[Symbol.asyncIterator]()).toThrow(InvalidStateError); + await client.close(); + }); + it("run resolves on acceptance with the first run ID", async () => { const accepted = deferred(); const releaseEvent = deferred(); From 674f16b9485725f3b8f8217b834784fe7a6836a1 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 17 Sep 2026 22:02:55 +0200 Subject: [PATCH 03/15] feat(sdk): add MCP authorization lifecycle core Co-authored-by: Codex Signed-off-by: Samuele Verzi --- sdk/typescript/etc/mecatl-sdk-deno.api.md | 99 +++ sdk/typescript/etc/mecatl-sdk-node.api.md | 99 +++ sdk/typescript/etc/mecatl-sdk.api.md | 99 +++ sdk/typescript/src/client.ts | 7 + sdk/typescript/src/http.ts | 12 +- sdk/typescript/src/index.ts | 8 + sdk/typescript/src/mcp-authorization.ts | 566 ++++++++++++++++++ sdk/typescript/src/rpc-catalog.ts | 8 +- sdk/typescript/test/mcp-authorization.test.ts | 561 +++++++++++++++++ sdk/typescript/test/package.test.ts | 4 + 10 files changed, 1457 insertions(+), 6 deletions(-) create mode 100644 sdk/typescript/src/mcp-authorization.ts create mode 100644 sdk/typescript/test/mcp-authorization.test.ts diff --git a/sdk/typescript/etc/mecatl-sdk-deno.api.md b/sdk/typescript/etc/mecatl-sdk-deno.api.md index dc9a6340fc..ff3f0c8c6b 100644 --- a/sdk/typescript/etc/mecatl-sdk-deno.api.md +++ b/sdk/typescript/etc/mecatl-sdk-deno.api.md @@ -612,6 +612,73 @@ export const MAX_PROMPT_MEDIA_BYTES: number; // @public export const MAX_PROMPT_MEDIA_PARTS = 16; +// @public +export interface McpAuthorization { + // (undocumented) + readonly authorizationId: string; + // (undocumented) + cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; + // (undocumented) + presentation(requestOptions?: RequestOptions): Promise; + // (undocumented) + recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationFlow extends AsyncIterable { + // (undocumented) + readonly authorizationId: string; + // (undocumented) + cancelContinuation(requestOptions?: RequestOptions): Promise; + // (undocumented) + readonly continuationRunId: string | undefined; + // (undocumented) + readonly operation: McpAuthorizationOperation; + // (undocumented) + resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; + // (undocumented) + result(): Promise; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationFlowOptions { + onPermissionAsk?: PermissionAskResponder; + permissionRequestOptions?: RequestOptions; +} + +// @public +export type McpAuthorizationOperation = "recheck" | "cancel"; + +// @public +export type McpAuthorizationResult = { + readonly outcome: "pending"; + readonly status: "pending"; + readonly authorization: EventOf<"authorization.required">; +} | { + readonly outcome: "settled"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; +} | { + readonly outcome: "completed"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly continuation: RunResult; +} | { + readonly outcome: "authorization_required"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly nextAuthorization: EventOf<"authorization.required">; +}; + +// @public +export type McpAuthorizationStatus = "pending" | "granted" | "denied" | "cancelled" | "expired" | "interrupted" | "failed" | "closed"; + // @public export const McpConnectorAvailability: { readonly Available: "available"; @@ -964,6 +1031,7 @@ export interface Run extends AsyncIterable { cancel(): Promise; // (undocumented) readonly id: string; + outcome(): Promise; resolveAsk(askId: string, verdict: PermissionVerdict): Promise; result(): Promise; // (undocumented) @@ -971,6 +1039,33 @@ export interface Run extends AsyncIterable { steer(text: string): Promise; } +// @public +export class RunAuthorizationRequiredError extends InvalidStateError { + constructor(outcome: RunAuthorizationRequiredOutcome, options: Omit); + // (undocumented) + readonly outcome: RunAuthorizationRequiredOutcome; +} + +// @public +export interface RunAuthorizationRequiredOutcome { + // (undocumented) + readonly authorization: EventOf<"authorization.required">; + // (undocumented) + readonly outcome: "authorization_required"; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface RunCompletedOutcome { + // (undocumented) + readonly outcome: "completed"; + // (undocumented) + readonly result: RunResult; +} + // @public export interface RunControls { cancel(requestOptions?: RequestOptions): Promise; @@ -987,6 +1082,9 @@ export interface RunOptions { onPlanApproval?: PlanApprovalResponder; } +// @public +export type RunOutcome = RunCompletedOutcome | RunAuthorizationRequiredOutcome; + // @public export interface RunResult { // (undocumented) @@ -1214,6 +1312,7 @@ export interface Session { // (undocumented) readonly id: string; listMcpConnectors(options?: RequestOptions): Promise; + mcpAuthorization(authorizationId: string): McpAuthorization; rename(title: string, options?: RequestOptions): Promise; resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; retry(options?: RunOptions, requestOptions?: RequestOptions): Promise; diff --git a/sdk/typescript/etc/mecatl-sdk-node.api.md b/sdk/typescript/etc/mecatl-sdk-node.api.md index e337d56370..e0eecc9e51 100644 --- a/sdk/typescript/etc/mecatl-sdk-node.api.md +++ b/sdk/typescript/etc/mecatl-sdk-node.api.md @@ -633,6 +633,73 @@ export const MAX_PROMPT_MEDIA_BYTES: number; // @public export const MAX_PROMPT_MEDIA_PARTS = 16; +// @public +export interface McpAuthorization { + // (undocumented) + readonly authorizationId: string; + // (undocumented) + cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; + // (undocumented) + presentation(requestOptions?: RequestOptions): Promise; + // (undocumented) + recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationFlow extends AsyncIterable { + // (undocumented) + readonly authorizationId: string; + // (undocumented) + cancelContinuation(requestOptions?: RequestOptions): Promise; + // (undocumented) + readonly continuationRunId: string | undefined; + // (undocumented) + readonly operation: McpAuthorizationOperation; + // (undocumented) + resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; + // (undocumented) + result(): Promise; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationFlowOptions { + onPermissionAsk?: PermissionAskResponder; + permissionRequestOptions?: RequestOptions; +} + +// @public +export type McpAuthorizationOperation = "recheck" | "cancel"; + +// @public +export type McpAuthorizationResult = { + readonly outcome: "pending"; + readonly status: "pending"; + readonly authorization: EventOf<"authorization.required">; +} | { + readonly outcome: "settled"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; +} | { + readonly outcome: "completed"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly continuation: RunResult; +} | { + readonly outcome: "authorization_required"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly nextAuthorization: EventOf<"authorization.required">; +}; + +// @public +export type McpAuthorizationStatus = "pending" | "granted" | "denied" | "cancelled" | "expired" | "interrupted" | "failed" | "closed"; + // @public export const McpConnectorAvailability: { readonly Available: "available"; @@ -993,6 +1060,7 @@ export interface Run extends AsyncIterable { cancel(): Promise; // (undocumented) readonly id: string; + outcome(): Promise; resolveAsk(askId: string, verdict: PermissionVerdict): Promise; result(): Promise; // (undocumented) @@ -1000,6 +1068,33 @@ export interface Run extends AsyncIterable { steer(text: string): Promise; } +// @public +export class RunAuthorizationRequiredError extends InvalidStateError { + constructor(outcome: RunAuthorizationRequiredOutcome, options: Omit); + // (undocumented) + readonly outcome: RunAuthorizationRequiredOutcome; +} + +// @public +export interface RunAuthorizationRequiredOutcome { + // (undocumented) + readonly authorization: EventOf<"authorization.required">; + // (undocumented) + readonly outcome: "authorization_required"; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface RunCompletedOutcome { + // (undocumented) + readonly outcome: "completed"; + // (undocumented) + readonly result: RunResult; +} + // @public export interface RunControls { cancel(requestOptions?: RequestOptions): Promise; @@ -1016,6 +1111,9 @@ export interface RunOptions { onPlanApproval?: PlanApprovalResponder; } +// @public +export type RunOutcome = RunCompletedOutcome | RunAuthorizationRequiredOutcome; + // @public export interface RunResult { // (undocumented) @@ -1243,6 +1341,7 @@ export interface Session { // (undocumented) readonly id: string; listMcpConnectors(options?: RequestOptions): Promise; + mcpAuthorization(authorizationId: string): McpAuthorization; rename(title: string, options?: RequestOptions): Promise; resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; retry(options?: RunOptions, requestOptions?: RequestOptions): Promise; diff --git a/sdk/typescript/etc/mecatl-sdk.api.md b/sdk/typescript/etc/mecatl-sdk.api.md index 46bf5b881a..524936e4d0 100644 --- a/sdk/typescript/etc/mecatl-sdk.api.md +++ b/sdk/typescript/etc/mecatl-sdk.api.md @@ -597,6 +597,73 @@ export const MAX_PROMPT_MEDIA_BYTES: number; // @public export const MAX_PROMPT_MEDIA_PARTS = 16; +// @public +export interface McpAuthorization { + // (undocumented) + readonly authorizationId: string; + // (undocumented) + cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; + // (undocumented) + presentation(requestOptions?: RequestOptions): Promise; + // (undocumented) + recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationFlow extends AsyncIterable { + // (undocumented) + readonly authorizationId: string; + // (undocumented) + cancelContinuation(requestOptions?: RequestOptions): Promise; + // (undocumented) + readonly continuationRunId: string | undefined; + // (undocumented) + readonly operation: McpAuthorizationOperation; + // (undocumented) + resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; + // (undocumented) + result(): Promise; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationFlowOptions { + onPermissionAsk?: PermissionAskResponder; + permissionRequestOptions?: RequestOptions; +} + +// @public +export type McpAuthorizationOperation = "recheck" | "cancel"; + +// @public +export type McpAuthorizationResult = { + readonly outcome: "pending"; + readonly status: "pending"; + readonly authorization: EventOf<"authorization.required">; +} | { + readonly outcome: "settled"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; +} | { + readonly outcome: "completed"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly continuation: RunResult; +} | { + readonly outcome: "authorization_required"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly nextAuthorization: EventOf<"authorization.required">; +}; + +// @public +export type McpAuthorizationStatus = "pending" | "granted" | "denied" | "cancelled" | "expired" | "interrupted" | "failed" | "closed"; + // @public export const McpConnectorAvailability: { readonly Available: "available"; @@ -916,6 +983,7 @@ export interface Run extends AsyncIterable { cancel(): Promise; // (undocumented) readonly id: string; + outcome(): Promise; resolveAsk(askId: string, verdict: PermissionVerdict): Promise; result(): Promise; // (undocumented) @@ -923,6 +991,33 @@ export interface Run extends AsyncIterable { steer(text: string): Promise; } +// @public +export class RunAuthorizationRequiredError extends InvalidStateError { + constructor(outcome: RunAuthorizationRequiredOutcome, options: Omit); + // (undocumented) + readonly outcome: RunAuthorizationRequiredOutcome; +} + +// @public +export interface RunAuthorizationRequiredOutcome { + // (undocumented) + readonly authorization: EventOf<"authorization.required">; + // (undocumented) + readonly outcome: "authorization_required"; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface RunCompletedOutcome { + // (undocumented) + readonly outcome: "completed"; + // (undocumented) + readonly result: RunResult; +} + // @public export interface RunControls { cancel(requestOptions?: RequestOptions): Promise; @@ -939,6 +1034,9 @@ export interface RunOptions { onPlanApproval?: PlanApprovalResponder; } +// @public +export type RunOutcome = RunCompletedOutcome | RunAuthorizationRequiredOutcome; + // @public export interface RunResult { // (undocumented) @@ -1166,6 +1264,7 @@ export interface Session { // (undocumented) readonly id: string; listMcpConnectors(options?: RequestOptions): Promise; + mcpAuthorization(authorizationId: string): McpAuthorization; rename(title: string, options?: RequestOptions): Promise; resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; retry(options?: RunOptions, requestOptions?: RequestOptions): Promise; diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 28ce093922..b54cc67266 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -33,6 +33,7 @@ import { type WatchSessionEventsResponse, } from "./gen/mecatl/v1/harness_pb.js"; import { createHttpTransport, type HttpTransportOptions } from "./http.js"; +import { createMcpAuthorization, type McpAuthorization } from "./mcp-authorization.js"; import { type McpConnectorInventory, projectMcpConnectorInventory, @@ -197,6 +198,8 @@ export interface ClearSessionOptions { /** A durable Mecatl session handle. @public */ export interface Session { readonly id: string; + /** Binds one external authorization ID to this session without performing I/O. */ + mcpAuthorization(authorizationId: string): McpAuthorization; /** * Reads the current broker connector inventory for this session. * @@ -664,6 +667,10 @@ class SessionImpl implements Session { ); } + mcpAuthorization(authorizationId: string): McpAuthorization { + return createMcpAuthorization(this.id, authorizationId, this.#operations); + } + async attach(runId?: string, options: AttachOptions = {}): Promise { this.#operations.assertOpen(); let unregister: () => void = () => undefined; diff --git a/sdk/typescript/src/http.ts b/sdk/typescript/src/http.ts index 06ffdfe2a7..fa7a6a31be 100644 --- a/sdk/typescript/src/http.ts +++ b/sdk/typescript/src/http.ts @@ -424,7 +424,7 @@ class HttpTransport implements Transport { const effectiveSignal = timeoutSignal(signal, timeoutMs); let route: Route; let body: JsonRecord = {}; - let wrapEvent = false; + let responseField: string | undefined; let startControls: (() => Promise) | undefined; let controlFailure: Promise = new Promise(() => undefined); @@ -437,7 +437,7 @@ class HttpTransport implements Transport { }); } const sessionId = start.value.sessionId; - wrapEvent = true; + responseField = "event"; if (start.case === "prompt") { route = sessionControlRoute("prompt", sessionId); body = { @@ -475,6 +475,7 @@ class HttpTransport implements Transport { path: resolved.path, }; body = record(resolved.body ?? {}); + responseField = resolved.classification.responseField; } const response = await this.#request(route, body, effectiveSignal, header); @@ -514,7 +515,10 @@ class HttpTransport implements Transport { let normalized: JsonValue; let message: MessageShape; try { - normalized = normalizeHttpWktJson(output, wrapEvent ? { event: raw } : raw); + normalized = normalizeHttpWktJson( + output, + responseField === undefined ? raw : { [responseField]: raw }, + ); message = fromJson(output, normalized, { ignoreUnknownFields: true }); } catch (cause) { throw new ProtocolError("The mecatl SSE stream contained an invalid event", { @@ -524,7 +528,7 @@ class HttpTransport implements Transport { }); } registerRawJson(message, raw); - if (wrapEvent) { + if (responseField === "event") { const event = (message as { readonly event?: object | undefined }).event; if (event !== undefined) registerRawJson(event, raw); } else if (method.name === "WatchSessionEvents") { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6d942576a1..94821752b8 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -101,6 +101,14 @@ export type { export { MECATL_EVENT_KINDS } from "./events.js"; export type { HttpTransportOptions } from "./http.js"; export { createHttpTransport } from "./http.js"; +export type { + McpAuthorization, + McpAuthorizationFlow, + McpAuthorizationFlowOptions, + McpAuthorizationOperation, + McpAuthorizationResult, + McpAuthorizationStatus, +} from "./mcp-authorization.js"; export type { McpConnectorInventory, McpConnectorStatus, diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts new file mode 100644 index 0000000000..1c90002b11 --- /dev/null +++ b/sdk/typescript/src/mcp-authorization.ts @@ -0,0 +1,566 @@ +import type { + DescMessage, + DescMethodStreaming, + DescMethodUnary, + MessageInitShape, + MessageShape, +} from "@bufbuild/protobuf"; +import type { CallOptions } from "@connectrpc/connect"; + +import { InvalidStateError, normalizeError, ProtocolError, type TransportKind } from "./errors.js"; +import { decodeEvent, type Event, type EventOf } from "./events.js"; +import { + HarnessService, + type RecheckMcpAuthorizationRequestSchema, + type RecheckMcpAuthorizationResponse, +} from "./gen/mecatl/v1/harness_pb.js"; +import type { RequestOptions } from "./namespaces-core.js"; +import type { PermissionAskResponder, PermissionVerdict, RunResult } from "./run.js"; + +/** The closed authorization status vocabulary interpreted by the lifecycle helper. @public */ +export type McpAuthorizationStatus = + | "pending" + | "granted" + | "denied" + | "cancelled" + | "expired" + | "interrupted" + | "failed" + | "closed"; + +/** The server transition requested by one authorization flow. @public */ +export type McpAuthorizationOperation = "recheck" | "cancel"; + +/** Application-owned behavior for one authorization continuation. @public */ +export interface McpAuthorizationFlowOptions { + /** Automatically answers ordinary permission asks observed on the continuation. */ + onPermissionAsk?: PermissionAskResponder; + /** Request options used only for automatic permission replies. */ + permissionRequestOptions?: RequestOptions; +} + +/** The authoritative result of one authorization recheck or cancellation. @public */ +export type McpAuthorizationResult = + | { + readonly outcome: "pending"; + readonly status: "pending"; + readonly authorization: EventOf<"authorization.required">; + } + | { + readonly outcome: "settled"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + } + | { + readonly outcome: "completed"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly continuation: RunResult; + } + | { + readonly outcome: "authorization_required"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly nextAuthorization: EventOf<"authorization.required">; + }; + +/** One lazy, single-consumption authorization control and optional continuation. @public */ +export interface McpAuthorizationFlow extends AsyncIterable { + readonly sessionId: string; + readonly authorizationId: string; + readonly operation: McpAuthorizationOperation; + readonly continuationRunId: string | undefined; + resolveAsk( + askId: string, + verdict: PermissionVerdict, + requestOptions?: RequestOptions, + ): Promise; + cancelContinuation(requestOptions?: RequestOptions): Promise; + result(): Promise; +} + +/** A reusable session-bound correlation handle for one server-owned authorization. @public */ +export interface McpAuthorization { + readonly sessionId: string; + readonly authorizationId: string; + presentation(requestOptions?: RequestOptions): Promise; + recheck( + options?: McpAuthorizationFlowOptions, + requestOptions?: RequestOptions, + ): McpAuthorizationFlow; + cancel( + options?: McpAuthorizationFlowOptions, + requestOptions?: RequestOptions, + ): McpAuthorizationFlow; +} + +export interface McpAuthorizationOperations { + assertOpen(): void; + registerRun(cancel: () => Promise): () => void; + readonly transportKind: TransportKind; + stream( + method: DescMethodStreaming, + input: AsyncIterable>, + options?: CallOptions, + ): AsyncIterable>; + unary( + method: DescMethodUnary, + input: MessageInitShape, + options?: CallOptions, + ): Promise>; +} + +type ConsumptionMode = "events" | "result"; +type TerminalStatus = Exclude; + +const authorizationStatuses = new Set([ + "pending", + "granted", + "denied", + "cancelled", + "expired", + "interrupted", + "failed", + "closed", +]); + +type AuthorizationRequest = MessageInitShape; + +class AuthorizationInput implements AsyncIterable { + readonly #first: AuthorizationRequest; + #closed = false; + #firstPending = true; + #waiting: ((result: IteratorResult) => void) | undefined; + + constructor(first: AuthorizationRequest) { + this.#first = first; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#waiting?.({ done: true, value: undefined }); + this.#waiting = undefined; + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async () => { + if (this.#firstPending) { + this.#firstPending = false; + return { done: false, value: this.#first }; + } + if (this.#closed) return { done: true, value: undefined }; + return new Promise>((resolve) => { + this.#waiting = resolve; + }); + }, + return: async () => { + this.close(); + return { done: true, value: undefined }; + }, + }; + } +} + +class McpAuthorizationFlowImpl implements McpAuthorizationFlow { + readonly authorizationId: string; + readonly operation: McpAuthorizationOperation; + readonly sessionId: string; + readonly #operations: McpAuthorizationOperations; + readonly #requestOptions: RequestOptions | undefined; + #abort: AbortController | undefined; + #authorization: EventOf<"authorization.required"> | EventOf<"authorization.resolved"> | undefined; + #consumption: ConsumptionMode | undefined; + #continuationRunId: string | undefined; + #continuationTerminal: EventOf<"result"> | undefined; + #ended = false; + #events: AsyncIterator | undefined; + #input: AuthorizationInput | undefined; + #nextAuthorization: EventOf<"authorization.required"> | undefined; + #release: (() => void) | undefined; + #repeatSeen = false; + #result: McpAuthorizationResult | undefined; + + constructor( + sessionId: string, + authorizationId: string, + operation: McpAuthorizationOperation, + operations: McpAuthorizationOperations, + flowOptions: McpAuthorizationFlowOptions, + requestOptions: RequestOptions | undefined, + ) { + this.sessionId = sessionId; + this.authorizationId = authorizationId; + this.operation = operation; + this.#operations = operations; + void flowOptions; + this.#requestOptions = requestOptions; + } + + get continuationRunId(): string | undefined { + return this.#continuationRunId; + } + + [Symbol.asyncIterator](): AsyncIterator { + this.#claim("events"); + return { + next: () => this.#next(), + return: async () => { + await this.#close(); + return { done: true, value: undefined }; + }, + }; + } + + async result(): Promise { + this.#claim("result"); + for (;;) { + const next = await this.#next(); + if (next.done) break; + } + if (this.#result === undefined) + throw this.#protocol("The authorization flow produced no result"); + return this.#result; + } + + async resolveAsk( + askId: string, + verdict: PermissionVerdict, + requestOptions?: RequestOptions, + ): Promise { + this.#operations.assertOpen(); + void askId; + void verdict; + void requestOptions; + throw new InvalidStateError("The authorization continuation has no observed permission ask", { + transport: this.#operations.transportKind, + }); + } + + async cancelContinuation(requestOptions?: RequestOptions): Promise { + this.#operations.assertOpen(); + void requestOptions; + throw new InvalidStateError("The authorization continuation run has not been observed", { + transport: this.#operations.transportKind, + }); + } + + #claim(mode: ConsumptionMode): void { + this.#operations.assertOpen(); + if (this.#consumption !== undefined) { + throw new InvalidStateError( + `MCP authorization events are already being consumed through ${this.#consumption}`, + { transport: this.#operations.transportKind }, + ); + } + this.#consumption = mode; + } + + async #start(): Promise { + if (this.#events !== undefined) return; + this.#operations.assertOpen(); + const callerSignal = this.#requestOptions?.signal; + if (callerSignal?.aborted === true) { + throw normalizeError(callerSignal.reason, this.#operations.transportKind); + } + + const abort = new AbortController(); + const signal = + callerSignal === undefined ? abort.signal : AbortSignal.any([callerSignal, abort.signal]); + const input = new AuthorizationInput({ + authorizationId: this.authorizationId, + sessionId: this.sessionId, + }); + const method = + this.operation === "recheck" + ? HarnessService.method.recheckMcpAuthorization + : HarnessService.method.cancelMcpAuthorization; + const stream = this.#operations.stream( + method as typeof HarnessService.method.recheckMcpAuthorization, + input, + { ...this.#requestOptions, signal }, + ); + this.#abort = abort; + this.#input = input; + this.#events = stream[Symbol.asyncIterator]() as AsyncIterator; + let released = false; + let unregister: () => void = () => undefined; + this.#release = () => { + if (released) return; + released = true; + unregister(); + }; + unregister = this.#operations.registerRun(() => this.#close()); + } + + async #next(): Promise> { + this.#operations.assertOpen(); + if (this.#ended) return { done: true, value: undefined }; + try { + await this.#start(); + const next = await this.#events?.next(); + if (next === undefined || next.done) return await this.#finishEOF(); + const raw = next.value.event; + if (raw === undefined) + throw this.#protocol("The authorization stream returned a frame without an event"); + const event = decodeEvent(raw, this.#operations.transportKind); + this.#observe(event); + return { done: false, value: event }; + } catch (error) { + await this.#close(); + throw error; + } + } + + #observe(event: Event): void { + if (this.#authorization === undefined) { + this.#authorization = this.#validateAuthoritative(event); + return; + } + if (this.#authorization.kind === "authorization.required") { + throw this.#protocol("A pending authorization result cannot have a continuation"); + } + if (event.runId === "") { + throw this.#protocol("An authorization continuation event has no run id"); + } + this.#continuationRunId ??= event.runId; + if (event.runId !== this.#continuationRunId) { + throw this.#protocol("The authorization continuation changed run id"); + } + if (this.#continuationTerminal !== undefined || this.#nextAuthorization !== undefined) { + throw this.#protocol("The authorization continuation returned an event after its terminal"); + } + + if (event.kind === "authorization.resolved") { + if (!sameAuthorization(event, this.#authorization)) { + throw this.#protocol("The authorization continuation changed the original resolution"); + } + if (this.#repeatSeen) { + throw this.#protocol( + "The authorization continuation repeated the original resolution twice", + ); + } + this.#repeatSeen = true; + return; + } + if (event.kind === "authorization.required") { + if ( + event.payload.status !== "pending" || + event.payload.authorizationId === this.authorizationId || + event.payload.authorizationId === "" || + event.payload.callId === "" + ) { + throw this.#protocol( + "The authorization continuation returned a malformed chained authorization", + ); + } + this.#nextAuthorization = event; + return; + } + if (event.kind === "result") this.#continuationTerminal = event; + } + + #validateAuthoritative( + event: Event, + ): EventOf<"authorization.required"> | EventOf<"authorization.resolved"> { + if (event.runId !== "") { + throw this.#protocol("The authoritative authorization event unexpectedly carried a run id"); + } + if (event.kind !== "authorization.required" && event.kind !== "authorization.resolved") { + throw this.#protocol("The authorization stream did not begin with an authorization event"); + } + const status = event.payload.status; + if ( + event.payload.authorizationId !== this.authorizationId || + event.payload.callId === "" || + !authorizationStatuses.has(status as McpAuthorizationStatus) + ) { + throw this.#protocol( + "The authoritative authorization event has invalid correlation or status", + ); + } + if ( + (status === "pending" && event.kind !== "authorization.required") || + (status !== "pending" && event.kind !== "authorization.resolved") + ) { + throw this.#protocol( + "The authoritative authorization event has an invalid kind and status pairing", + ); + } + return event; + } + + async #finishEOF(): Promise> { + const authorization = this.#authorization; + if (authorization === undefined) { + throw this.#protocol("The authorization stream ended without an authoritative result"); + } + + if (this.#continuationRunId === undefined) { + this.#result = + authorization.kind === "authorization.required" + ? { authorization, outcome: "pending", status: "pending" } + : { + authorization, + outcome: "settled", + status: authorization.payload.status as TerminalStatus, + }; + } else { + if (authorization.kind !== "authorization.resolved") { + throw this.#protocol("A pending authorization result cannot have a continuation"); + } + if (!this.#repeatSeen) { + throw this.#protocol("The authorization continuation omitted the original resolution"); + } + const status = authorization.payload.status as TerminalStatus; + if (this.#continuationTerminal !== undefined) { + this.#result = { + authorization, + continuation: runResult( + this.sessionId, + this.#continuationRunId, + this.#continuationTerminal, + ), + continuationRunId: this.#continuationRunId, + outcome: "completed", + status, + }; + } else if (this.#nextAuthorization !== undefined) { + this.#result = { + authorization, + continuationRunId: this.#continuationRunId, + nextAuthorization: this.#nextAuthorization, + outcome: "authorization_required", + status, + }; + } else { + throw this.#protocol("The authorization continuation ended without a terminal outcome"); + } + } + await this.#close(); + return { done: true, value: undefined }; + } + + async #close(): Promise { + if (this.#ended) return; + this.#ended = true; + this.#abort?.abort(); + this.#input?.close(); + try { + await this.#events?.return?.(); + } catch { + // Releasing an aborted transport stream is best-effort. + } finally { + this.#release?.(); + } + } + + #protocol(message: string): ProtocolError { + return new ProtocolError(message, { transport: this.#operations.transportKind }); + } +} + +class McpAuthorizationImpl implements McpAuthorization { + readonly authorizationId: string; + readonly sessionId: string; + readonly #operations: McpAuthorizationOperations; + + constructor(sessionId: string, authorizationId: string, operations: McpAuthorizationOperations) { + this.sessionId = sessionId; + this.authorizationId = authorizationId; + this.#operations = operations; + } + + async presentation(requestOptions?: RequestOptions): Promise { + this.#operations.assertOpen(); + const response = await this.#operations.unary( + HarnessService.method.getMcpAuthorizationPresentation, + { authorizationId: this.authorizationId, sessionId: this.sessionId }, + requestOptions, + ); + const value = response.url; + let parsed: URL; + try { + parsed = new URL(value); + } catch (cause) { + throw new ProtocolError("The MCP authorization presentation URL is malformed", { + cause, + transport: this.#operations.transportKind, + }); + } + if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.hostname === "") { + throw new ProtocolError("The MCP authorization presentation URL must be absolute HTTP(S)", { + transport: this.#operations.transportKind, + }); + } + return value; + } + + recheck( + options: McpAuthorizationFlowOptions = {}, + requestOptions?: RequestOptions, + ): McpAuthorizationFlow { + return this.#flow("recheck", options, requestOptions); + } + + cancel( + options: McpAuthorizationFlowOptions = {}, + requestOptions?: RequestOptions, + ): McpAuthorizationFlow { + return this.#flow("cancel", options, requestOptions); + } + + #flow( + operation: McpAuthorizationOperation, + options: McpAuthorizationFlowOptions, + requestOptions: RequestOptions | undefined, + ): McpAuthorizationFlow { + return new McpAuthorizationFlowImpl( + this.sessionId, + this.authorizationId, + operation, + this.#operations, + options, + requestOptions, + ); + } +} + +function sameAuthorization( + event: EventOf<"authorization.resolved">, + original: EventOf<"authorization.resolved">, +): boolean { + const left = event.payload; + const right = original.payload; + return ( + left.authorizationId === right.authorizationId && + left.callId === right.callId && + left.displayName === right.displayName && + left.status === right.status && + left.expiresAt?.seconds === right.expiresAt?.seconds && + left.expiresAt?.nanos === right.expiresAt?.nanos + ); +} + +function runResult(sessionId: string, runId: string, event: EventOf<"result">): RunResult { + return { + content: event.payload.text, + rawEvent: event, + runId, + sessionId, + stopReason: event.payload.stop, + text: event.payload.text, + usage: event.payload.usage ?? event.usage, + }; +} + +export function createMcpAuthorization( + sessionId: string, + authorizationId: string, + operations: McpAuthorizationOperations, +): McpAuthorization { + return new McpAuthorizationImpl(sessionId, authorizationId, operations); +} diff --git a/sdk/typescript/src/rpc-catalog.ts b/sdk/typescript/src/rpc-catalog.ts index bf3b14e0a9..9e445c0389 100644 --- a/sdk/typescript/src/rpc-catalog.ts +++ b/sdk/typescript/src/rpc-catalog.ts @@ -1132,8 +1132,10 @@ const rpcCatalogRows = [ "/v1/sessions/{id}/mcp-authorizations/{authorization_id}/recheck", ["id=session_id", "authorization_id=authorization_id"], [], - "json", + "none", "sse", + "", + "event", ), }), rpc({ @@ -1148,8 +1150,10 @@ const rpcCatalogRows = [ "/v1/sessions/{id}/mcp-authorizations/{authorization_id}/cancel", ["id=session_id", "authorization_id=authorization_id"], [], - "json", + "none", "sse", + "", + "event", ), }), rpc({ diff --git a/sdk/typescript/test/mcp-authorization.test.ts b/sdk/typescript/test/mcp-authorization.test.ts new file mode 100644 index 0000000000..099f8e1f50 --- /dev/null +++ b/sdk/typescript/test/mcp-authorization.test.ts @@ -0,0 +1,561 @@ +import type { + DescMessage, + DescMethodStreaming, + DescMethodUnary, + MessageInitShape, +} from "@bufbuild/protobuf"; +import { create } from "@bufbuild/protobuf"; +import type { ContextValues, StreamResponse, Transport, UnaryResponse } from "@connectrpc/connect"; +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { + connect, + createHttpTransport, + InvalidStateError, + type McpAuthorization, + type McpAuthorizationResult, + ProtocolError, + type RequestOptions, + SESSION_ID_HEADER_NAME, + ServerError, +} from "../src/index.js"; + +const sessionId = "session-authorization"; +const authorizationId = "authorization-1"; +const continuationRunId = "run-continuation"; + +function authorization( + type: "authorization.required" | "authorization.resolved", + status: string, + options: { + readonly authorizationId?: string; + readonly callId?: string; + readonly displayName?: string; + readonly runId?: string; + } = {}, +) { + return { + authorization: { + authorizationId: options.authorizationId ?? authorizationId, + callId: options.callId ?? "call-1", + displayName: options.displayName ?? "Example connector", + status, + }, + runId: options.runId ?? "", + type, + }; +} + +function message(runId = continuationRunId, text = "continuing") { + return { runId, text, type: "message.delta" }; +} + +function terminal(runId = continuationRunId, text = "complete") { + return { + result: { stop: "end_turn", text, usage: { inputTokens: 4n, outputTokens: 2n } }, + runId, + type: "result", + }; +} + +type WireEvent = + | ReturnType + | ReturnType + | ReturnType; + +interface TransportCall { + readonly headers: Headers; + readonly input: unknown; + readonly method: string; + readonly signal: AbortSignal | undefined; + readonly timeoutMs: number | undefined; +} + +class AuthorizationTransport implements Transport { + readonly calls: TransportCall[] = []; + readonly controls: Array<{ readonly method: "cancel" | "recheck"; readonly request: unknown }> = + []; + readonly #streams: WireEvent[][]; + + constructor(streams: WireEvent[][] = [], presentation = "https://identity.example/authorize") { + this.#streams = streams.map((events) => [...events]); + this.presentation = presentation; + } + + readonly presentation: string; + + async unary( + method: DescMethodUnary, + signal: AbortSignal | undefined, + timeoutMs: number | undefined, + headers: HeadersInit | undefined, + input: MessageInitShape, + contextValues?: ContextValues, + ): Promise> { + this.calls.push({ + headers: new Headers(headers), + input, + method: method.name, + signal, + timeoutMs, + }); + void contextValues; + const responseHeader = new Headers(); + const responseTrailer = new Headers(); + let value: Record; + switch (method.name) { + case "GetCompatibilityInfo": + value = { apiMajor: 1, capabilities: {}, features: [] }; + break; + case "GetSession": + value = { session: { sessionId } }; + break; + case "GetMcpAuthorizationPresentation": + responseHeader.set("x-response", "presentation-header"); + responseTrailer.set("x-response", "presentation-trailer"); + value = { url: this.presentation }; + break; + default: + throw new Error(`Unexpected unary ${method.name}`); + } + return { + header: responseHeader, + message: create(method.output, value as MessageInitShape), + method, + service: method.parent, + stream: false, + trailer: responseTrailer, + }; + } + + async stream( + method: DescMethodStreaming, + signal: AbortSignal | undefined, + timeoutMs: number | undefined, + headers: HeadersInit | undefined, + input: AsyncIterable>, + contextValues?: ContextValues, + ): Promise> { + this.calls.push({ + headers: new Headers(headers), + input, + method: method.name, + signal, + timeoutMs, + }); + void contextValues; + const first = await input[Symbol.asyncIterator]().next(); + this.controls.push({ + method: method.name === "CancelMcpAuthorization" ? "cancel" : "recheck", + request: first.value, + }); + const events = this.#streams.shift() ?? []; + const messages = (async function* () { + for (const event of events) { + yield create(method.output, { event } as unknown as MessageInitShape); + } + })(); + return { + header: new Headers(), + message: messages, + method, + service: method.parent, + stream: true, + trailer: new Headers(), + }; + } +} + +async function grpcSession( + streams: WireEvent[][] = [], + presentation?: string, +): Promise<{ + readonly authorization: McpAuthorization; + readonly client: ReturnType; + readonly transport: AuthorizationTransport; +}> { + const transport = new AuthorizationTransport(streams, presentation); + const client = connect({ transport }); + const session = await client.sessions.get(sessionId); + return { authorization: session.mcpAuthorization(authorizationId), client, transport }; +} + +function httpClient( + presentation: string | Response, + streams: readonly (readonly Record[])[] = [], +): { + readonly client: ReturnType; + readonly requests: Array<{ readonly body: BodyInit | null | undefined; readonly path: string }>; +} { + const requests: Array<{ readonly body: BodyInit | null | undefined; readonly path: string }> = []; + const remaining = streams.map((events) => [...events]); + const fetch: typeof globalThis.fetch = async (input, init) => { + const path = new URL(String(input)).pathname; + requests.push({ body: init?.body, path }); + if (path === "/v1/compatibility") { + return Response.json({ api_major: 1, capabilities: {}, features: [] }); + } + if (path === `/v1/sessions/${sessionId}`) { + return Response.json({ session_id: sessionId, state: "idle" }); + } + if (path.endsWith("/presentation")) { + return presentation instanceof Response ? presentation : Response.json({ url: presentation }); + } + const events = remaining.shift() ?? []; + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + }; + return { + client: connect({ + transport: createHttpTransport({ baseUrl: "http://mecatl.test", fetch }), + transportKind: "http", + }), + requests, + }; +} + +function httpAuthorization( + type: "authorization.required" | "authorization.resolved", + status: string, + runId = "", + id = authorizationId, +): Record { + return { + authorization: { + authorization_id: id, + call_id: "call-1", + display_name: "Example connector", + status, + }, + run_id: runId, + type, + }; +} + +describe("MCP authorization lifecycle", () => { + it("MCP authorization handle binds exact correlation without I/O", async () => { + const transport = new AuthorizationTransport(); + const client = connect({ transport }); + const session = await client.sessions.get(sessionId); + const before = transport.calls.length; + + const handle = session.mcpAuthorization(authorizationId); + + expect(handle).toMatchObject({ authorizationId, sessionId }); + expect(transport.calls).toHaveLength(before); + expectTypeOf(handle).toEqualTypeOf(); + await client.close(); + }); + + it("MCP authorization presentation is live validated and application owned", async () => { + const { authorization: handle, client, transport } = await grpcSession(); + const controller = new AbortController(); + const responseHeaders: string[] = []; + const responseTrailers: string[] = []; + const requestOptions: RequestOptions = { + headers: { "x-caller": "kept" }, + onHeader: (headers) => responseHeaders.push(headers.get("x-response") ?? ""), + onTrailer: (headers) => responseTrailers.push(headers.get("x-response") ?? ""), + signal: controller.signal, + timeoutMs: 4_321, + }; + + await expect(handle.presentation(requestOptions)).resolves.toBe( + "https://identity.example/authorize", + ); + const call = transport.calls.find( + (candidate) => candidate.method === "GetMcpAuthorizationPresentation", + ); + expect(call).toMatchObject({ + input: { authorizationId, sessionId }, + signal: controller.signal, + timeoutMs: 4_321, + }); + expect(call?.headers.get("x-caller")).toBe("kept"); + expect(call?.headers.get(SESSION_ID_HEADER_NAME)).toBe(sessionId); + expect(responseHeaders).toEqual(["presentation-header"]); + expect(responseTrailers).toEqual(["presentation-trailer"]); + expect( + transport.calls.filter((candidate) => candidate.method === "GetMcpAuthorizationPresentation"), + ).toHaveLength(1); + await client.close(); + }); + + it("MCP authorization presentation preserves protocol and server failures", async () => { + for (const value of ["", "/authorize", "file:///secret", "not a URL"]) { + const harness = await grpcSession([], value); + await expect(harness.authorization.presentation()).rejects.toBeInstanceOf(ProtocolError); + await harness.client.close(); + } + + const failure = httpClient( + Response.json({ code: "not_found", detail: "authorization unavailable" }, { status: 404 }), + ); + const session = await failure.client.sessions.get(sessionId); + const error = await session + .mcpAuthorization(authorizationId) + .presentation() + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(ServerError); + expect(error).toMatchObject({ code: "not_found", transport: "http" }); + await failure.client.close(); + }); + + it("MCP authorization operations start only on first consumption", async () => { + const { + authorization: handle, + client, + transport, + } = await grpcSession([ + [authorization("authorization.required", "pending")], + [authorization("authorization.resolved", "cancelled")], + ]); + const before = transport.calls.length; + const recheck = handle.recheck(); + const cancel = handle.cancel(); + + expect(recheck).toMatchObject({ authorizationId, operation: "recheck", sessionId }); + expect(cancel).toMatchObject({ authorizationId, operation: "cancel", sessionId }); + expect(recheck).not.toBe(cancel); + const iterator = recheck[Symbol.asyncIterator](); + expect(transport.calls).toHaveLength(before); + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { kind: "authorization.required" }, + }); + expect( + transport.calls.filter((call) => call.method === "RecheckMcpAuthorization"), + ).toHaveLength(1); + expect(transport.calls.some((call) => call.method === "CancelMcpAuthorization")).toBe(false); + await iterator.return?.(); + await client.close(); + }); + + it("MCP authorization flow start preserves request timing and exact control", async () => { + const { + authorization: handle, + client, + transport, + } = await grpcSession([ + [authorization("authorization.required", "pending")], + [authorization("authorization.resolved", "cancelled")], + ]); + const controller = new AbortController(); + const options: RequestOptions = { + headers: { "x-caller": "kept" }, + signal: controller.signal, + timeoutMs: 4_567, + }; + await expect(handle.recheck(undefined, options).result()).resolves.toMatchObject({ + outcome: "pending", + status: "pending", + }); + const call = transport.calls.find( + (candidate) => candidate.method === "RecheckMcpAuthorization", + ); + expect(call).toMatchObject({ signal: expect.any(AbortSignal), timeoutMs: 4_567 }); + expect(call?.headers.get("x-caller")).toBe("kept"); + expect(call?.headers.get(SESSION_ID_HEADER_NAME)).toBe(sessionId); + expect(transport.controls).toEqual([ + { method: "recheck", request: { authorizationId, sessionId } }, + ]); + + const aborted = new AbortController(); + aborted.abort(new Error("caller stopped")); + const before = transport.calls.length; + await expect( + handle.cancel(undefined, { signal: aborted.signal }).result(), + ).rejects.toMatchObject({ + code: "transport", + }); + expect(transport.calls).toHaveLength(before); + + const http = httpClient("https://identity.example/authorize", [ + [httpAuthorization("authorization.required", "pending")], + ]); + const httpSession = await http.client.sessions.get(sessionId); + await expect( + httpSession.mcpAuthorization(authorizationId).recheck().result(), + ).resolves.toMatchObject({ outcome: "pending" }); + expect(http.requests.find((request) => request.path.endsWith("/recheck"))).toEqual({ + body: undefined, + path: `/v1/sessions/${sessionId}/mcp-authorizations/${authorizationId}/recheck`, + }); + await http.client.close(); + await client.close(); + }); + + it("MCP authorization flow validates the authoritative control result", async () => { + const malformed: WireEvent[][] = [ + [message("", "unknown event")], + [authorization("authorization.required", "pending", { runId: "unexpected-run" })], + [authorization("authorization.required", "pending", { authorizationId: "other" })], + [authorization("authorization.required", "pending", { callId: "" })], + [authorization("authorization.required", "future")], + [authorization("authorization.resolved", "pending")], + [authorization("authorization.required", "granted")], + ]; + const { authorization: handle, client } = await grpcSession(malformed); + for (const _events of malformed) { + await expect(handle.recheck().result()).rejects.toBeInstanceOf(ProtocolError); + } + await client.close(); + }); + + it("MCP authorization status-only results are discriminated values", async () => { + const statuses = [ + "pending", + "granted", + "denied", + "cancelled", + "expired", + "interrupted", + "failed", + "closed", + ] as const; + const streams = statuses.map((status) => [ + authorization( + status === "pending" ? "authorization.required" : "authorization.resolved", + status, + ), + ]); + const { authorization: handle, client } = await grpcSession(streams); + const results: McpAuthorizationResult[] = []; + for (const _status of statuses) results.push(await handle.recheck().result()); + + expect(results.map(({ outcome, status }) => `${outcome}:${status}`)).toEqual([ + "pending:pending", + "settled:granted", + "settled:denied", + "settled:cancelled", + "settled:expired", + "settled:interrupted", + "settled:failed", + "settled:closed", + ]); + expectTypeOf().toMatchTypeOf< + | { readonly outcome: "pending"; readonly status: "pending" } + | { + readonly outcome: "settled" | "completed" | "authorization_required"; + readonly status: Exclude<(typeof statuses)[number], "pending">; + } + >(); + await client.close(); + }); + + it("MCP authorization flow is single consumption", async () => { + const { + authorization: handle, + client, + transport, + } = await grpcSession([ + [authorization("authorization.required", "pending")], + [authorization("authorization.required", "pending")], + ]); + const iterated = handle.recheck(); + const iterator = iterated[Symbol.asyncIterator](); + expect(() => iterated[Symbol.asyncIterator]()).toThrow(InvalidStateError); + await expect(iterated.result()).rejects.toBeInstanceOf(InvalidStateError); + await iterator.return?.(); + + const drained = handle.recheck(); + await expect(drained.result()).resolves.toMatchObject({ outcome: "pending" }); + await expect(drained.result()).rejects.toBeInstanceOf(InvalidStateError); + expect(() => drained[Symbol.asyncIterator]()).toThrow(InvalidStateError); + expect( + transport.calls.filter((call) => call.method === "RecheckMcpAuthorization"), + ).toHaveLength(1); + await client.close(); + }); + + it("MCP authorization continuation validates run and repeated resolution grammar", async () => { + const original = authorization("authorization.resolved", "granted"); + const repeated = authorization("authorization.resolved", "granted", { + runId: continuationRunId, + }); + const cases: WireEvent[][] = [ + [original, repeated, message("changed-run"), terminal("changed-run")], + [original, message(), terminal()], + [original, repeated, repeated, terminal()], + [original, repeated, terminal(), terminal()], + [original, repeated, terminal(), message()], + [ + original, + repeated, + authorization("authorization.required", "pending", { + runId: continuationRunId, + }), + ], + ]; + const { authorization: handle, client } = await grpcSession(cases); + for (const _events of cases) { + await expect(handle.recheck().result()).rejects.toBeInstanceOf(ProtocolError); + } + await client.close(); + }); + + it("MCP authorization continuation returns one ordinary completed result", async () => { + const sequence = [ + authorization("authorization.resolved", "granted"), + authorization("authorization.resolved", "granted", { runId: continuationRunId }), + message(), + terminal(), + ]; + const { authorization: handle, client } = await grpcSession([sequence, sequence]); + + await expect(handle.recheck().result()).resolves.toMatchObject({ + authorization: { kind: "authorization.resolved" }, + continuation: { + content: "complete", + runId: continuationRunId, + sessionId, + stopReason: "end_turn", + }, + continuationRunId, + outcome: "completed", + status: "granted", + }); + + const iterated = handle.recheck(); + const events = []; + for await (const event of iterated) events.push(event); + expect(events.map((event) => `${event.runId}:${event.kind}`)).toEqual([ + ":authorization.resolved", + `${continuationRunId}:authorization.resolved`, + `${continuationRunId}:message.delta`, + `${continuationRunId}:result`, + ]); + expect(iterated.continuationRunId).toBe(continuationRunId); + await client.close(); + }); + + it("MCP authorization continuation hands off a chained authorization", async () => { + const nextAuthorizationId = "authorization-2"; + const sequence = [ + authorization("authorization.resolved", "granted"), + authorization("authorization.resolved", "granted", { runId: continuationRunId }), + message(), + authorization("authorization.required", "pending", { + authorizationId: nextAuthorizationId, + callId: "call-2", + runId: continuationRunId, + }), + ]; + const { authorization: handle, client } = await grpcSession([sequence]); + + await expect(handle.recheck().result()).resolves.toMatchObject({ + authorization: { payload: { authorizationId }, kind: "authorization.resolved" }, + continuationRunId, + nextAuthorization: { + payload: { authorizationId: nextAuthorizationId, callId: "call-2", status: "pending" }, + kind: "authorization.required", + runId: continuationRunId, + }, + outcome: "authorization_required", + status: "granted", + }); + await client.close(); + }); +}); diff --git a/sdk/typescript/test/package.test.ts b/sdk/typescript/test/package.test.ts index 92da100e28..0365165717 100644 --- a/sdk/typescript/test/package.test.ts +++ b/sdk/typescript/test/package.test.ts @@ -248,6 +248,10 @@ test("packed tarball carries dist and license only", () => { "package/dist/index.d.ts.map", "package/dist/index.js", "package/dist/index.js.map", + "package/dist/mcp-authorization.d.ts", + "package/dist/mcp-authorization.d.ts.map", + "package/dist/mcp-authorization.js", + "package/dist/mcp-authorization.js.map", "package/dist/mcp-workspace-enrollment.d.ts", "package/dist/mcp-workspace-enrollment.d.ts.map", "package/dist/mcp-workspace-enrollment.js", From 3739a94ae56ab3e819b674a63167762aeb625aa2 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 17 Sep 2026 22:40:35 +0200 Subject: [PATCH 04/15] feat(sdk): add authorization continuation controls Co-authored-by: Codex Signed-off-by: Samuele Verzi --- sdk/typescript/src/client.ts | 5 +- sdk/typescript/src/mcp-authorization.ts | 149 ++++++++- .../test/mcp-authorization-control-fixture.ts | 302 ++++++++++++++++++ .../test/mcp-authorization-controls.test.ts | 230 +++++++++++++ .../test/mcp-authorization-recovery.test.ts | 128 ++++++++ 5 files changed, 799 insertions(+), 15 deletions(-) create mode 100644 sdk/typescript/test/mcp-authorization-control-fixture.ts create mode 100644 sdk/typescript/test/mcp-authorization-controls.test.ts create mode 100644 sdk/typescript/test/mcp-authorization-recovery.test.ts diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index b54cc67266..2657bcbf1c 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -668,7 +668,10 @@ class SessionImpl implements Session { } mcpAuthorization(authorizationId: string): McpAuthorization { - return createMcpAuthorization(this.id, authorizationId, this.#operations); + return createMcpAuthorization(this.id, authorizationId, { + ...this.#operations, + promptCapabilities: () => this.#promptCapabilities, + }); } async attach(runId?: string, options: AttachOptions = {}): Promise { diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index 1c90002b11..fdb8d4c530 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -14,8 +14,11 @@ import { type RecheckMcpAuthorizationRequestSchema, type RecheckMcpAuthorizationResponse, } from "./gen/mecatl/v1/harness_pb.js"; +import type { PromptCapabilities } from "./media.js"; import type { RequestOptions } from "./namespaces-core.js"; +import { PLAN_APPROVAL_TOOL } from "./plan.js"; import type { PermissionAskResponder, PermissionVerdict, RunResult } from "./run.js"; +import { createRunControls, type RunControls } from "./run-controls.js"; /** The closed authorization status vocabulary interpreted by the lifecycle helper. @public */ export type McpAuthorizationStatus = @@ -98,6 +101,8 @@ export interface McpAuthorization { export interface McpAuthorizationOperations { assertOpen(): void; + features(options?: RequestOptions): Promise>; + promptCapabilities(): PromptCapabilities | undefined; registerRun(cancel: () => Promise): () => void; readonly transportKind: TransportKind; stream( @@ -114,6 +119,7 @@ export interface McpAuthorizationOperations { type ConsumptionMode = "events" | "result"; type TerminalStatus = Exclude; +type PendingAsk = { readonly controller: AbortController; readonly plan: boolean }; const authorizationStatuses = new Set([ "pending", @@ -170,7 +176,10 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { readonly operation: McpAuthorizationOperation; readonly sessionId: string; readonly #operations: McpAuthorizationOperations; + readonly #flowOptions: McpAuthorizationFlowOptions; readonly #requestOptions: RequestOptions | undefined; + readonly #controlFailure: Promise; + readonly #rejectControlFailure: (error: unknown) => void; #abort: AbortController | undefined; #authorization: EventOf<"authorization.required"> | EventOf<"authorization.resolved"> | undefined; #consumption: ConsumptionMode | undefined; @@ -180,9 +189,12 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { #events: AsyncIterator | undefined; #input: AuthorizationInput | undefined; #nextAuthorization: EventOf<"authorization.required"> | undefined; + readonly #knownAsks = new Set(); + readonly #pendingAsks = new Map(); #release: (() => void) | undefined; #repeatSeen = false; #result: McpAuthorizationResult | undefined; + #runControls: RunControls | undefined; constructor( sessionId: string, @@ -196,8 +208,14 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { this.authorizationId = authorizationId; this.operation = operation; this.#operations = operations; - void flowOptions; + this.#flowOptions = flowOptions; this.#requestOptions = requestOptions; + let rejectControlFailure: (error: unknown) => void = () => undefined; + this.#controlFailure = new Promise((_resolve, reject) => { + rejectControlFailure = reject; + }); + void this.#controlFailure.catch(() => undefined); + this.#rejectControlFailure = rejectControlFailure; } get continuationRunId(): string | undefined { @@ -232,20 +250,31 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { requestOptions?: RequestOptions, ): Promise { this.#operations.assertOpen(); - void askId; - void verdict; - void requestOptions; - throw new InvalidStateError("The authorization continuation has no observed permission ask", { - transport: this.#operations.transportKind, - }); + const pending = this.#pendingAsks.get(askId); + if (pending === undefined) { + throw new InvalidStateError( + `The authorization continuation has no pending permission ask ${askId}`, + { transport: this.#operations.transportKind }, + ); + } + if (pending.plan) { + throw new InvalidStateError( + `Plan approval ask ${askId} must be resolved through the plan workflow`, + { transport: this.#operations.transportKind }, + ); + } + await this.#resolvePendingAsk(askId, verdict, pending, requestOptions); } async cancelContinuation(requestOptions?: RequestOptions): Promise { this.#operations.assertOpen(); - void requestOptions; - throw new InvalidStateError("The authorization continuation run has not been observed", { - transport: this.#operations.transportKind, - }); + const controls = this.#runControls; + if (controls === undefined) { + throw new InvalidStateError("The authorization continuation run has not been observed", { + transport: this.#operations.transportKind, + }); + } + await controls.cancel(requestOptions); } #claim(mode: ConsumptionMode): void { @@ -301,7 +330,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { if (this.#ended) return { done: true, value: undefined }; try { await this.#start(); - const next = await this.#events?.next(); + const next = await Promise.race([this.#events?.next(), this.#controlFailure]); if (next === undefined || next.done) return await this.#finishEOF(); const raw = next.value.event; if (raw === undefined) @@ -326,7 +355,16 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { if (event.runId === "") { throw this.#protocol("An authorization continuation event has no run id"); } - this.#continuationRunId ??= event.runId; + if (this.#continuationRunId === undefined) { + this.#continuationRunId = event.runId; + this.#runControls = createRunControls(this.sessionId, event.runId, { + assertOpen: () => this.#operations.assertOpen(), + features: (options) => this.#operations.features(options), + promptCapabilities: () => this.#operations.promptCapabilities(), + transportKind: this.#operations.transportKind, + unary: (method, input, options) => this.#operations.unary(method, input, options), + }); + } if (event.runId !== this.#continuationRunId) { throw this.#protocol("The authorization continuation changed run id"); } @@ -358,9 +396,91 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { ); } this.#nextAuthorization = event; + this.#retireAllAsks(); return; } - if (event.kind === "result") this.#continuationTerminal = event; + if (event.kind === "permission.ask") { + this.#startAsk(event); + return; + } + if (event.kind === "permission.retract" || event.kind === "approval") { + this.#retireAsk(event.payload.askId); + return; + } + if (event.kind === "result") { + this.#continuationTerminal = event; + this.#retireAllAsks(); + } + } + + #startAsk(event: EventOf<"permission.ask">): void { + const askId = event.payload.askId; + if (askId === "" || this.#knownAsks.has(askId)) { + throw this.#protocol("The authorization continuation returned an invalid permission ask"); + } + this.#knownAsks.add(askId); + const pending = { + controller: new AbortController(), + plan: event.payload.tool === PLAN_APPROVAL_TOOL, + }; + this.#pendingAsks.set(askId, pending); + if (pending.plan) return; + const responder = this.#flowOptions.onPermissionAsk; + if (responder === undefined) return; + + void (async () => { + let verdict: PermissionVerdict | undefined; + try { + verdict = await responder(event.payload, pending.controller.signal); + } catch { + return; + } + if (verdict === undefined || pending.controller.signal.aborted) return; + try { + await this.#resolvePendingAsk( + askId, + verdict, + pending, + this.#flowOptions.permissionRequestOptions, + ); + } catch (error) { + if (!this.#ended) this.#rejectControlFailure(error); + } + })(); + } + + async #resolvePendingAsk( + askId: string, + verdict: PermissionVerdict, + pending: PendingAsk, + requestOptions: RequestOptions | undefined, + ): Promise { + if (this.#pendingAsks.get(askId) !== pending || pending.plan) { + throw new InvalidStateError(`Permission ask ${askId} is no longer pending`, { + transport: this.#operations.transportKind, + }); + } + const controls = this.#runControls; + if (controls === undefined) { + throw new InvalidStateError("The authorization continuation run has not been observed", { + transport: this.#operations.transportKind, + }); + } + this.#pendingAsks.delete(askId); + pending.controller.abort(); + await controls.resolveAsk(askId, verdict, requestOptions); + } + + #retireAsk(askId: string): void { + const pending = this.#pendingAsks.get(askId); + if (pending === undefined) return; + this.#pendingAsks.delete(askId); + pending.controller.abort(); + } + + #retireAllAsks(): void { + for (const pending of this.#pendingAsks.values()) pending.controller.abort(); + this.#pendingAsks.clear(); } #validateAuthoritative( @@ -447,6 +567,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { async #close(): Promise { if (this.#ended) return; this.#ended = true; + this.#retireAllAsks(); this.#abort?.abort(); this.#input?.close(); try { diff --git a/sdk/typescript/test/mcp-authorization-control-fixture.ts b/sdk/typescript/test/mcp-authorization-control-fixture.ts new file mode 100644 index 0000000000..938549a130 --- /dev/null +++ b/sdk/typescript/test/mcp-authorization-control-fixture.ts @@ -0,0 +1,302 @@ +import type { + DescMessage, + DescMethodStreaming, + DescMethodUnary, + MessageInitShape, +} from "@bufbuild/protobuf"; +import { create } from "@bufbuild/protobuf"; +import type { ContextValues, StreamResponse, Transport, UnaryResponse } from "@connectrpc/connect"; + +import { type Client, connect, createHttpTransport, ServerError } from "../src/index.js"; + +export const sessionId = "session-authorization-controls"; +export const authorizationId = "authorization-controls"; +export const continuationRunId = "run-authorization-controls"; + +export interface RecordedCall { + readonly headers: Headers; + readonly input: unknown; + readonly method: string; + readonly signal: AbortSignal | undefined; + readonly timeoutMs: number | undefined; +} + +export type WireEvent = Record; + +export interface StreamPlan { + readonly events?: readonly WireEvent[]; + readonly error?: unknown; + readonly hold?: boolean; +} + +export interface HarnessOptions { + readonly features?: readonly string[]; + readonly streams?: readonly StreamPlan[]; + readonly unary?: (method: string, input: Record) => unknown; +} + +export class LifecycleTransport implements Transport { + readonly calls: RecordedCall[] = []; + activeStreams = 0; + closedStreams = 0; + readonly #features: readonly string[]; + readonly #streams: StreamPlan[]; + readonly #unary: HarnessOptions["unary"]; + + constructor(options: HarnessOptions = {}) { + this.#features = options.features ?? ["prompt_free_controls", "watch_session_events"]; + this.#streams = [...(options.streams ?? [])]; + this.#unary = options.unary; + } + + async unary( + method: DescMethodUnary, + signal: AbortSignal | undefined, + timeoutMs: number | undefined, + headers: HeadersInit | undefined, + input: MessageInitShape, + contextValues?: ContextValues, + ): Promise> { + void contextValues; + this.calls.push({ + headers: new Headers(headers), + input, + method: method.name, + signal, + timeoutMs, + }); + const supplied = this.#unary?.(method.name, input as Record); + if (supplied instanceof Error) throw supplied; + let value: unknown = supplied; + if (value === undefined) { + switch (method.name) { + case "GetCompatibilityInfo": + value = { apiMajor: 1, capabilities: {}, features: [...this.#features] }; + break; + case "GetSession": + value = { + session: { sessionId: String((input as { sessionId?: string }).sessionId ?? "") }, + }; + break; + case "ResolveRunAsk": + value = { + askId: String((input as { askId?: string }).askId ?? ""), + runId: String((input as { expectedRunId?: string }).expectedRunId ?? ""), + }; + break; + case "CancelRun": + value = { runId: String((input as { expectedRunId?: string }).expectedRunId ?? "") }; + break; + default: + throw new Error(`Unexpected unary ${method.name}`); + } + } + return { + header: new Headers({ "x-fixture-response": method.name }), + message: create(method.output, value as MessageInitShape), + method, + service: method.parent, + stream: false, + trailer: new Headers({ "x-fixture-trailer": method.name }), + }; + } + + async stream( + method: DescMethodStreaming, + signal: AbortSignal | undefined, + timeoutMs: number | undefined, + headers: HeadersInit | undefined, + input: AsyncIterable>, + contextValues?: ContextValues, + ): Promise> { + void contextValues; + const first = await input[Symbol.asyncIterator]().next(); + this.calls.push({ + headers: new Headers(headers), + input: first.value, + method: method.name, + signal, + timeoutMs, + }); + const plan = this.#streams.shift() ?? { events: [] }; + const owner = this; + const messages = (async function* () { + owner.activeStreams += 1; + try { + for (const event of plan.events ?? []) { + yield create(method.output, { event } as unknown as MessageInitShape); + } + if (plan.error !== undefined) throw plan.error; + if (plan.hold === true) { + await new Promise((_resolve, reject) => { + const abort = () => reject(signal?.reason ?? new Error("aborted")); + if (signal?.aborted === true) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + } + } finally { + owner.activeStreams -= 1; + owner.closedStreams += 1; + } + })(); + return { + header: new Headers({ "x-fixture-response": method.name }), + message: messages, + method, + service: method.parent, + stream: true, + trailer: new Headers({ "x-fixture-trailer": method.name }), + }; + } +} + +export async function harness(options: HarnessOptions = {}): Promise<{ + readonly client: Client; + readonly session: Awaited>; + readonly transport: LifecycleTransport; +}> { + const transport = new LifecycleTransport(options); + const client = connect({ transport }); + const session = await client.sessions.get(sessionId); + return { client, session, transport }; +} + +export interface HttpRequest { + readonly body: BodyInit | null | undefined; + readonly headers: Headers; + readonly path: string; + readonly signal: AbortSignal | null | undefined; +} + +export async function httpHarness(events: readonly Record[]): Promise<{ + readonly client: Client; + readonly requests: HttpRequest[]; + readonly session: Awaited>; +}> { + const requests: HttpRequest[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const path = new URL(String(input)).pathname; + requests.push({ + body: init?.body, + headers: new Headers(init?.headers), + path, + signal: init?.signal, + }); + if (path === "/v1/compatibility") { + return Response.json({ + api_major: 1, + capabilities: {}, + features: ["prompt_free_controls", "watch_session_events"], + }); + } + if (path === `/v1/sessions/${sessionId}`) { + return Response.json({ session_id: sessionId, state: "idle" }); + } + if (path.endsWith("/recheck")) { + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + } + if (path.endsWith("/resolve-ask")) { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + return Response.json({ ask_id: body.ask_id, run_id: body.expected_run_id }); + } + if (path.endsWith("/cancel")) { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + return Response.json({ run_id: body.expected_run_id }); + } + throw new Error(`Unexpected HTTP request ${path}`); + }; + const client = connect({ + transport: createHttpTransport({ baseUrl: "http://mecatl.test", fetch }), + transportKind: "http", + }); + const session = await client.sessions.get(sessionId); + return { client, requests, session }; +} + +export function httpContinuation(...events: Record[]): Record[] { + const authorization = { + authorization_id: authorizationId, + call_id: "authorization-call", + display_name: "Example MCP", + status: "granted", + }; + return [ + { authorization, run_id: "", type: "authorization.resolved" }, + { authorization, run_id: continuationRunId, type: "authorization.resolved" }, + ...events, + ]; +} + +export function httpAsk(askId: string): Record { + return { + ask: { args: "{}", ask_id: askId, reason: "test", tool: "Shell" }, + run_id: continuationRunId, + type: "permission.ask", + }; +} + +export function authorization( + type: "authorization.required" | "authorization.resolved", + status: string, + options: { + readonly authorizationId?: string; + readonly callId?: string; + readonly runId?: string; + } = {}, +): WireEvent { + return { + authorization: { + authorizationId: options.authorizationId ?? authorizationId, + callId: options.callId ?? "authorization-call", + displayName: "Example MCP", + status, + }, + runId: options.runId ?? "", + type, + }; +} + +export function ask( + askId: string, + options: { readonly runId?: string; readonly tool?: string } = {}, +): WireEvent { + return { + ask: { args: "{}", askId, reason: "test", tool: options.tool ?? "Shell" }, + runId: options.runId ?? continuationRunId, + type: "permission.ask", + }; +} + +export function retract(askId: string, runId = continuationRunId): WireEvent { + return { + ask: { args: "{}", askId, reason: "test", tool: "Shell" }, + runId, + type: "permission.retract", + }; +} + +export function result(runId = continuationRunId): WireEvent { + return { + result: { stop: "end_turn", text: "done", usage: { inputTokens: 1n, outputTokens: 1n } }, + runId, + type: "result", + }; +} + +export function continuation(...events: WireEvent[]): WireEvent[] { + return [ + authorization("authorization.resolved", "granted"), + authorization("authorization.resolved", "granted", { runId: continuationRunId }), + ...events, + ]; +} + +export function notFound(message = "authorization no longer pending"): ServerError { + return new ServerError(message, { code: "not_found", transport: "grpc" }); +} + +export async function flush(): Promise { + for (let index = 0; index < 20; index += 1) await Promise.resolve(); +} diff --git a/sdk/typescript/test/mcp-authorization-controls.test.ts b/sdk/typescript/test/mcp-authorization-controls.test.ts new file mode 100644 index 0000000000..24264affdf --- /dev/null +++ b/sdk/typescript/test/mcp-authorization-controls.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; + +import { + InvalidStateError, + SESSION_ID_HEADER_NAME, + ServerError, + UnsupportedFeatureError, +} from "../src/index.js"; +import { + ask, + authorization, + authorizationId, + continuation, + continuationRunId, + flush, + harness, + httpAsk, + httpContinuation, + httpHarness, + result, + retract, + sessionId, +} from "./mcp-authorization-control-fixture.js"; + +describe("MCP authorization continuation controls", () => { + it("MCP authorization permission decisions and request options remain application owned", async () => { + const automatic = await harness({ + streams: [{ events: continuation(ask("ask-auto"), result()) }], + }); + const seen: string[] = []; + const flow = automatic.session.mcpAuthorization(authorizationId).recheck( + { + onPermissionAsk: async (permission, signal) => { + expect(signal.aborted).toBe(false); + seen.push(permission.askId); + return "allow_once" as const; + }, + permissionRequestOptions: { headers: { "x-authority": "automatic" }, timeoutMs: 91 }, + }, + { headers: { "x-authority": "stream" }, timeoutMs: 90 }, + ); + const automaticIterator = flow[Symbol.asyncIterator](); + await automaticIterator.next(); + await automaticIterator.next(); + await automaticIterator.next(); + await flush(); + const automaticCall = automatic.transport.calls.find((call) => call.method === "ResolveRunAsk"); + expect(seen).toEqual(["ask-auto"]); + expect(automaticCall).toMatchObject({ + input: { askId: "ask-auto", expectedRunId: continuationRunId, sessionId }, + timeoutMs: 91, + }); + expect(automaticCall?.headers.get("x-authority")).toBe("automatic"); + + const manual = await harness({ + streams: [ + { + events: continuation( + ask("ask-manual"), + ask("ask-retracted"), + retract("ask-retracted"), + result(), + ), + }, + ], + }); + const manualFlow = manual.session.mcpAuthorization(authorizationId).recheck(); + const iter = manualFlow[Symbol.asyncIterator](); + await iter.next(); + await iter.next(); + await iter.next(); + await expect( + manualFlow.resolveAsk("ask-manual", "deny", { + headers: { "x-authority": "manual" }, + timeoutMs: 92, + }), + ).resolves.toBeUndefined(); + const manualCall = manual.transport.calls.find((call) => call.method === "ResolveRunAsk"); + expect(manualCall?.headers.get("x-authority")).toBe("manual"); + await iter.next(); + await iter.next(); + await expect(manualFlow.resolveAsk("ask-retracted", "deny")).rejects.toBeInstanceOf( + InvalidStateError, + ); + await expect(manualFlow.resolveAsk("unknown", "deny")).rejects.toBeInstanceOf( + InvalidStateError, + ); + await automatic.client.close(); + await manual.client.close(); + }); + + it("MCP authorization continuation controls are exact run and feature gated", async () => { + const active = await harness({ streams: [{ events: continuation(result()) }] }); + const flow = active.session.mcpAuthorization(authorizationId).recheck(); + await expect(flow.cancelContinuation()).rejects.toBeInstanceOf(InvalidStateError); + const iterator = flow[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); + await expect( + flow.cancelContinuation({ headers: { "x-control": "cancel" } }), + ).resolves.toBeUndefined(); + const cancel = active.transport.calls.find((call) => call.method === "CancelRun"); + expect(cancel?.input).toEqual({ expectedRunId: continuationRunId, sessionId }); + + const unsupported = await harness({ + features: [], + streams: [ + { events: [authorization("authorization.resolved", "granted")] }, + { events: continuation(result()) }, + ], + }); + await expect( + unsupported.session.mcpAuthorization(authorizationId).recheck().result(), + ).resolves.toMatchObject({ outcome: "settled" }); + const unsupportedFlow = unsupported.session.mcpAuthorization(authorizationId).recheck(); + const unsupportedIterator = unsupportedFlow[Symbol.asyncIterator](); + await unsupportedIterator.next(); + await unsupportedIterator.next(); + await expect(unsupportedFlow.cancelContinuation()).rejects.toBeInstanceOf( + UnsupportedFeatureError, + ); + expect(unsupported.transport.calls.some((call) => call.method === "CancelRun")).toBe(false); + await active.client.close(); + await unsupported.client.close(); + }); + + it("MCP authorization request options and unsupported plan asks stay separated", async () => { + const serverFailure = new ServerError("control failed", { + code: "not_found", + transport: "grpc", + }); + const instance = await harness({ + streams: [ + { + events: continuation( + ask("ask-manual"), + ask("ask-plan", { tool: "PresentPlan" }), + result(), + ), + }, + ], + unary: (method) => (method === "ResolveRunAsk" ? serverFailure : undefined), + }); + let responderCalls = 0; + const flow = instance.session.mcpAuthorization(authorizationId).recheck( + { + onPermissionAsk: () => { + responderCalls += 1; + return undefined; + }, + permissionRequestOptions: { headers: { "x-option": "auto" } }, + }, + { headers: { "x-option": "stream" }, timeoutMs: 101 }, + ); + const iterator = flow[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); + await iterator.next(); + await expect( + flow.resolveAsk("ask-manual", "deny", { headers: { "x-option": "manual" }, timeoutMs: 102 }), + ).rejects.toBeInstanceOf(ServerError); + await iterator.next(); + await flush(); + expect(responderCalls).toBe(1); + expect( + instance.transport.calls + .find((call) => call.method === "RecheckMcpAuthorization") + ?.headers.get("x-option"), + ).toBe("stream"); + const control = instance.transport.calls.find((call) => call.method === "ResolveRunAsk"); + expect(control?.headers.get("x-option")).toBe("manual"); + expect(control?.headers.get(SESSION_ID_HEADER_NAME)).toBe(sessionId); + expect(control?.timeoutMs).toBe(102); + await expect(flow.resolveAsk("ask-plan", "deny")).rejects.toBeInstanceOf(InvalidStateError); + expect(instance.transport.calls.filter((call) => call.method === "ResolveRunAsk")).toHaveLength( + 1, + ); + + const http = await httpHarness(httpContinuation(httpAsk("ask-http"))); + const httpFlow = http.session + .mcpAuthorization(authorizationId) + .recheck(undefined, { headers: { "x-option": "http-stream" }, timeoutMs: 103 }); + const httpIterator = httpFlow[Symbol.asyncIterator](); + await httpIterator.next(); + await httpIterator.next(); + await httpIterator.next(); + await httpFlow.resolveAsk("ask-http", "allow_always", { + headers: { "x-option": "http-control" }, + timeoutMs: 104, + }); + expect( + http.requests.find((request) => request.path.endsWith("/recheck"))?.headers.get("x-option"), + ).toBe("http-stream"); + expect( + http.requests + .find((request) => request.path.endsWith("/resolve-ask")) + ?.headers.get("x-option"), + ).toBe("http-control"); + expect( + http.requests + .find((request) => request.path.endsWith("/resolve-ask")) + ?.headers.get(SESSION_ID_HEADER_NAME), + ).toBe(sessionId); + await http.client.close(); + + const automaticFailure = await harness({ + streams: [{ events: continuation(ask("ask-failing")), hold: true }], + unary: (method) => (method === "ResolveRunAsk" ? serverFailure : undefined), + }); + const failingFlow = automaticFailure.session.mcpAuthorization(authorizationId).recheck({ + onPermissionAsk: () => "allow_once", + permissionRequestOptions: { headers: { "x-option": "automatic-failure" } }, + }); + const failingIterator = failingFlow[Symbol.asyncIterator](); + await failingIterator.next(); + await failingIterator.next(); + await failingIterator.next(); + await expect(failingIterator.next()).rejects.toBeInstanceOf(ServerError); + expect( + automaticFailure.transport.calls.filter((call) => call.method === "ResolveRunAsk"), + ).toHaveLength(1); + expect( + automaticFailure.transport.calls + .find((call) => call.method === "ResolveRunAsk") + ?.headers.get("x-option"), + ).toBe("automatic-failure"); + await automaticFailure.client.close(); + await instance.client.close(); + }); +}); diff --git a/sdk/typescript/test/mcp-authorization-recovery.test.ts b/sdk/typescript/test/mcp-authorization-recovery.test.ts new file mode 100644 index 0000000000..38b8e6a5ce --- /dev/null +++ b/sdk/typescript/test/mcp-authorization-recovery.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { InvalidStateError, ProtocolError, ServerError } from "../src/index.js"; +import { + ask, + authorization, + authorizationId, + continuation, + continuationRunId, + harness, + notFound, + result, + sessionId, +} from "./mcp-authorization-control-fixture.js"; + +describe("MCP authorization recovery boundaries", () => { + it("concurrent MCP authorization flows cannot cross consume or correlate", async () => { + const instance = await harness({ + streams: [ + { events: continuation(ask("shared"), result()) }, + { + events: [ + authorization("authorization.resolved", "granted", { authorizationId: "other" }), + ], + }, + ], + }); + const first = instance.session.mcpAuthorization(authorizationId).recheck(); + const second = instance.session.mcpAuthorization(authorizationId).recheck(); + const firstIterator = first[Symbol.asyncIterator](); + const secondIterator = second[Symbol.asyncIterator](); + await firstIterator.next(); + await firstIterator.next(); + await firstIterator.next(); + await expect(secondIterator.next()).rejects.toBeInstanceOf(ProtocolError); + await expect(second.resolveAsk("shared", "deny")).rejects.toBeInstanceOf(InvalidStateError); + await expect(first.resolveAsk("shared", "deny")).resolves.toBeUndefined(); + expect(instance.transport.calls.find((call) => call.method === "ResolveRunAsk")?.input).toEqual( + { + askId: "shared", + expectedRunId: continuationRunId, + sessionId, + verdict: 1, + }, + ); + await instance.client.close(); + }); + + it("MCP authorization flow cancellation releases only SDK owned resources", async () => { + const returned = await harness({ streams: [{ events: continuation(), hold: true }] }); + const returnedFlow = returned.session.mcpAuthorization(authorizationId).recheck(); + const iterator = returnedFlow[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); + await iterator.return?.(); + expect(returned.transport.activeStreams).toBe(0); + expect(returned.transport.closedStreams).toBe(1); + expect( + returned.transport.calls.filter((call) => call.method === "RecheckMcpAuthorization"), + ).toHaveLength(1); + expect(returned.transport.calls.some((call) => call.method === "CancelRun")).toBe(false); + + const lost = await harness({ streams: [{ error: new Error("wire lost") }] }); + await expect( + lost.session.mcpAuthorization(authorizationId).recheck().result(), + ).rejects.toMatchObject({ code: "transport" }); + expect( + lost.transport.calls.filter((call) => call.method === "RecheckMcpAuthorization"), + ).toHaveLength(1); + + const closed = await harness({ streams: [{ events: continuation(), hold: true }] }); + const closeResult = closed.session.mcpAuthorization(authorizationId).recheck().result(); + await Promise.resolve(); + await closed.client.close(); + await expect(closeResult).rejects.toBeDefined(); + expect(closed.transport.activeStreams).toBe(0); + await returned.client.close(); + await lost.client.close(); + }); + + it("MCP authorization recovery never overpromises replay", async () => { + let rechecks = 0; + const instance = await harness({ + streams: [ + { + events: [authorization("authorization.resolved", "granted")], + error: new Error("response lost"), + }, + { error: notFound() }, + ], + }); + await expect( + instance.session.mcpAuthorization(authorizationId).recheck().result(), + ).rejects.toMatchObject({ code: "transport" }); + const failure = await instance.session + .mcpAuthorization(authorizationId) + .recheck() + .result() + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ServerError); + expect(failure).toMatchObject({ code: "not_found" }); + rechecks = instance.transport.calls.filter( + (call) => call.method === "RecheckMcpAuthorization", + ).length; + expect(rechecks).toBe(2); + await instance.client.close(); + }); + + it("MCP authorization exposes correlation without automatic durable recovery", async () => { + const instance = await harness({ streams: [{ events: continuation(), hold: true }] }); + const flow = instance.session.mcpAuthorization(authorizationId).recheck(); + const iterator = flow[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); + expect(flow.continuationRunId).toBe(continuationRunId); + expect(instance.transport.calls.some((call) => call.method === "WatchSessionEvents")).toBe( + false, + ); + const attached = await instance.session.attach(flow.continuationRunId); + expect(attached.runId).toBe(continuationRunId); + await attached.close(); + expect(instance.transport.calls.some((call) => call.method === "WatchSessionEvents")).toBe( + false, + ); + await iterator.return?.(); + await instance.client.close(); + }); +}); From a6bfa9704ee36d7481f95aa0504ab682190695c3 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 17 Sep 2026 23:32:18 +0200 Subject: [PATCH 05/15] test(sdk): cover MCP authorization real wire lifecycle Co-authored-by: Codex Signed-off-by: Samuele Verzi --- internal/adapter/server/grpc.go | 3 + .../mcp_authorization_transport_test.go | 35 ++ .../server/sdk_typescript_http_routes_test.go | 56 ++- sdk/typescript/Taskfile.yml | 7 +- sdk/typescript/e2e/fixturedaemon/main.go | 386 ++++++++++++++++ .../mcp-authorization-disconnect-ask.json | 23 + .../mcp-authorization-disconnect-chain.json | 23 + .../mcp-authorization-disconnect.json | 14 + .../fixtures/mcp-authorization-lifecycle.json | 87 ++++ sdk/typescript/e2e/harness.ts | 90 +++- .../e2e/mcp-authorization.e2e.test.ts | 412 ++++++++++++++++++ sdk/typescript/src/mcp-authorization.ts | 4 + 12 files changed, 1118 insertions(+), 22 deletions(-) create mode 100644 sdk/typescript/e2e/fixturedaemon/main.go create mode 100644 sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-ask.json create mode 100644 sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-chain.json create mode 100644 sdk/typescript/e2e/fixtures/mcp-authorization-disconnect.json create mode 100644 sdk/typescript/e2e/fixtures/mcp-authorization-lifecycle.json create mode 100644 sdk/typescript/e2e/mcp-authorization.e2e.test.ts diff --git a/internal/adapter/server/grpc.go b/internal/adapter/server/grpc.go index ea43b0b879..5e8a395abb 100644 --- a/internal/adapter/server/grpc.go +++ b/internal/adapter/server/grpc.go @@ -1735,6 +1735,9 @@ func (h *HarnessServer) relayMCPAuthorizationControl(ctx context.Context, id ses select { case err := <-controlDone: controlDone = nil + if errors.Is(err, io.EOF) && parkedOnAsk { + h.svc.cancelRegisteredRun(id, result.Run) + } if err != nil && !errors.Is(err, io.EOF) { if sendErr == nil { sendErr = err diff --git a/internal/adapter/server/mcp_authorization_transport_test.go b/internal/adapter/server/mcp_authorization_transport_test.go index 3d27d5f392..3523076f3c 100644 --- a/internal/adapter/server/mcp_authorization_transport_test.go +++ b/internal/adapter/server/mcp_authorization_transport_test.go @@ -34,6 +34,7 @@ type recheckAuthorizationStream struct { sendCalls int recvErr error recvErred chan struct{} + eofAfterAsk chan struct{} responses []*mecatlv1.RecheckMcpAuthorizationResponse } @@ -55,6 +56,10 @@ func (s *recheckAuthorizationStream) Recv() (*mecatlv1.RecheckMcpAuthorizationRe } return nil, s.recvErr } + if s.eofAfterAsk != nil { + <-s.eofAfterAsk + return nil, io.EOF + } if s.requestCh != nil { select { case req := <-s.requestCh: @@ -74,6 +79,9 @@ func (s *recheckAuthorizationStream) Send(response *mecatlv1.RecheckMcpAuthoriza if s.approveOnAsk && response.GetEvent().GetType() == "permission.ask" { s.requestCh <- &mecatlv1.RecheckMcpAuthorizationRequest{Control: &mecatlv1.RecheckMcpAuthorizationRequest_ResumeApproval{ResumeApproval: &mecatlv1.ResumeApproval{AskId: response.GetEvent().GetAsk().GetAskId(), Verdict: mecatlv1.ApprovalVerdict_APPROVAL_VERDICT_ALLOW_ONCE}}} } + if s.eofAfterAsk != nil && response.GetEvent().GetType() == "permission.ask" { + close(s.eofAfterAsk) + } return nil } @@ -340,6 +348,33 @@ func TestMCPAuthorizationGRPCControlEOFDrainsContinuationWithoutCancellingIt(t * } } +func TestMCPAuthorizationGRPCControlEOFCancelsStrandedPermissionContinuation(t *testing.T) { + followup := session.NewToolCall("followup-call", "protected", nil) + f := newLifecycleFixtureWithTurns(t, session.AuthorizationGranted, nil, time.Now, nil, + mockllm.ToolCallTurn(followup), mockllm.TextTurn("must not continue after a stranded ask")) + stream := &recheckAuthorizationStream{ + ctx: t.Context(), + eofAfterAsk: make(chan struct{}), + requests: []*mecatlv1.RecheckMcpAuthorizationRequest{{ + SessionId: "authorization-session", AuthorizationId: f.pending.Authorization.ID, + }}, + } + + if err := NewHarnessServer(f.svc).RecheckMcpAuthorization(stream); err != nil { + t.Fatal(err) + } + if got := stream.responses[len(stream.responses)-1].GetEvent().GetResult().GetStop(); got != "cancelled" { + t.Fatalf("terminal stop = %q, want cancelled", got) + } + persisted, err := f.store.Load(t.Context(), "authorization-session") + if err != nil { + t.Fatal(err) + } + if persisted.State != session.StateCancelled { + t.Fatalf("persisted EOF permission continuation state = %q, want %q", persisted.State, session.StateCancelled) + } +} + // A broken control stream is not a cancellation (H-K5). The stream is a one-shot // RPC on a context deliberately detached from the continuation; cancelling the // run on a transport fault is what destroyed a follow-up authorization park mid diff --git a/internal/adapter/server/sdk_typescript_http_routes_test.go b/internal/adapter/server/sdk_typescript_http_routes_test.go index 2b3160edb5..99ea3f0a9e 100644 --- a/internal/adapter/server/sdk_typescript_http_routes_test.go +++ b/internal/adapter/server/sdk_typescript_http_routes_test.go @@ -119,10 +119,7 @@ func TestSDKTypescriptRelease_Scenario2_HTTPCodecParity(t *testing.T) { if review.response != wantResponse { t.Errorf("catalog row %q response = %q, handler %s derives %q", row.key, review.response, route.handler, wantResponse) } - wantResponseField := "" - if _, rawSession := facts.calls["writeSession"]; rawSession { - wantResponseField = "session" - } + wantResponseField := expectedSDKHTTPResponseField(facts, method.Output()) if review.responseField != wantResponseField { t.Errorf("catalog row %q responseField = %q, handler %s derives %q", row.key, review.responseField, route.handler, wantResponseField) } @@ -277,6 +274,18 @@ func assertSDKHTTPBodyCodec(t *testing.T, key string, review sdkHTTPReview, fact remaining[name] = struct{}{} } } + if facts.rejectsBody { + if !facts.body { + t.Errorf("catalog row %q rejects request bodies without inspecting the HTTP body", key) + } + if !sdkFieldsShareControlOneof(input, remaining) { + t.Errorf("catalog row %q rejects a body, but remaining request fields %v are not one control oneof", key, sortedSDKSet(remaining)) + } + if review.requestBody != "none" { + t.Errorf("catalog row %q request body = %q, handler rejects all bodies", key, review.requestBody) + } + return + } wantBody := "none" if len(remaining) != 0 { wantBody = "json" @@ -292,6 +301,41 @@ func assertSDKHTTPBodyCodec(t *testing.T, key string, review sdkHTTPReview, fact } } +func expectedSDKHTTPResponseField(facts sdkHTTPHandlerFacts, output protoreflect.MessageDescriptor) string { + if _, rawSession := facts.calls["writeSession"]; rawSession { + return "session" + } + if _, rawEvent := facts.calls["toProto"]; !facts.sse || !rawEvent || output.Fields().Len() != 1 { + return "" + } + field := output.Fields().Get(0) + if field.Name() == "event" && field.Message() != nil && field.Message().FullName() == "mecatl.v1.Event" { + return "event" + } + return "" +} + +func sdkFieldsShareControlOneof(input protoreflect.MessageDescriptor, fields map[string]struct{}) bool { + if len(fields) == 0 { + return true + } + var oneof protoreflect.FullName + for name := range fields { + field := input.Fields().ByName(protoreflect.Name(name)) + if field == nil || field.ContainingOneof() == nil || field.ContainingOneof().IsSynthetic() { + return false + } + if oneof == "" { + oneof = field.ContainingOneof().FullName() + continue + } + if field.ContainingOneof().FullName() != oneof { + return false + } + } + return true +} + func expectedSDKHTTPPathSource(t *testing.T, key, target string, facts sdkHTTPHandlerFacts, input protoreflect.MessageDescriptor) string { t.Helper() if input.Fields().ByName(protoreflect.Name(target)) != nil { @@ -474,6 +518,7 @@ type sdkHTTPHandlerFacts struct { services map[string]struct{} calls map[string]struct{} body bool + rejectsBody bool optionalBody bool sse bool } @@ -601,6 +646,9 @@ func (a *sdkHTTPSourceAnalysis) handlerFacts(handler string) sdkHTTPHandlerFacts return true } facts.calls[called] = struct{}{} + if called == "controlRequestBodyEmpty" { + facts.rejectsBody = true + } if called == "decodeOptionalStrictJSON" { facts.optionalBody = true } diff --git a/sdk/typescript/Taskfile.yml b/sdk/typescript/Taskfile.yml index b3309c6d46..81f97af10d 100644 --- a/sdk/typescript/Taskfile.yml +++ b/sdk/typescript/Taskfile.yml @@ -61,12 +61,17 @@ tasks: e2e: desc: Build the SDK and same-checkout mecated, then run the Node/Bun offline wire suite - deps: [build] + deps: [build, e2e:fixture] cmds: - test -x "${BUN_BIN:-}" || command -v bun >/dev/null 2>&1 || { echo "bun is required for the SDK e2e runtime matrix"; exit 1; } - task --dir ../.. build - pnpm run test:e2e + e2e:fixture: + desc: Build the test-only MCP authorization fixture daemon + cmds: + - CGO_ENABLED=0 go build -o ../../bin/mecatl-sdk-authorization-fixture ./e2e/fixturedaemon + deno: desc: Type-check the package exports and run the Deno.Command local integration deps: [install] diff --git a/sdk/typescript/e2e/fixturedaemon/main.go b/sdk/typescript/e2e/fixturedaemon/main.go new file mode 100644 index 0000000000..fcf9902303 --- /dev/null +++ b/sdk/typescript/e2e/fixturedaemon/main.go @@ -0,0 +1,386 @@ +// Command fixturedaemon serves the real Mecatl wire adapters with an in-process +// OAuth-protected MCP broker. It exists only for the TypeScript SDK E2E suite. +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/oauth2" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" + "github.com/stacklok/mecatl/contracts/sessionaffinity" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/internal/adapter/mcpauthority" + "github.com/stacklok/mecatl/internal/adapter/mcpbroker" + "github.com/stacklok/mecatl/internal/adapter/mockscript" + "github.com/stacklok/mecatl/internal/adapter/permconfig" + "github.com/stacklok/mecatl/internal/adapter/server" + "github.com/stacklok/mecatl/internal/app" +) + +const ( + toolOne = "mcp__fixture__one" + toolTwo = "mcp__fixture__two" +) + +type options struct { + grpcAddr string + httpAddr string + permission string + readyFile string + script string + socketPath string + userModelDir string + workspace string +} + +type helperMarker struct{} + +func (helperMarker) Helper() {} + +type readyDocument struct { + APIMajor int32 `json:"api_major"` + Features []string `json:"features,omitempty"` + FixtureControlURL string `json:"fixture_control_url"` + GRPCAddress string `json:"grpc_address"` + HTTPAddress string `json:"http_address,omitempty"` + PID int `json:"pid"` + Schema string `json:"schema"` + SocketPath string `json:"socket_path,omitempty"` + Transport string `json:"transport"` +} + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +//nolint:gocyclo // The test-only composition root owns listeners, OAuth fixtures, and readiness in one bounded lifecycle. +func run() error { + opts := parseFlags() + if opts.script == "" || opts.readyFile == "" || opts.workspace == "" { + return errors.New("--script, --ready-file, and --workspace are required") + } + provider, err := mockscript.Load(opts.script) + if err != nil { + return err + } + + rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + callbackMux := http.NewServeMux() + callbackServer := httptest.NewUnstartedServer(callbackMux) + callbackServer.StartTLS() + defer callbackServer.Close() + + oauthMux := http.NewServeMux() + oauthServer := httptest.NewUnstartedServer(oauthMux) + oauthServer.StartTLS() + defer oauthServer.Close() + + roots := x509.NewCertPool() + roots.AddCert(callbackServer.Certificate()) + roots.AddCert(oauthServer.Certificate()) + browserClient := trustedClient(roots) + defer browserClient.CloseIdleConnections() + + oauthMux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) { + query := url.Values{"state": {r.URL.Query().Get("state")}} + if r.URL.Query().Get("fixture_decision") == "deny" { + query.Set("error", "access_denied") + } else { + query.Set("code", "fixture-code") + } + http.Redirect(w, r, callbackServer.URL+"/oauth/callback?"+query.Encode(), http.StatusFound) + }) + oauthMux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid token request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"fixture-access-token","token_type":"Bearer","expires_in":3600}`) + }) + + oauthProfile := func() permconfig.MCPAuthProfile { + return permconfig.MCPAuthProfile{Mode: "oauth", OAuth: &permconfig.MCPOAuthProfile{ + Upstream: &permconfig.MCPOAuthUpstreamProfile{Mode: "oauth2", OAuth2: &permconfig.MCPOAuth2UpstreamProfile{ + AuthorizationEndpoint: oauthServer.URL + "/authorize", + TokenEndpoint: oauthServer.URL + "/token", + }}, + Client: permconfig.MCPOAuthClientProfile{Mode: "preregistered", Preregistered: &permconfig.MCPPreregisteredClientProfile{ + ID: "fixture-client", SecretEnv: "MECATL_FIXTURE_CLIENT_SECRET", // #nosec G101 -- trusted environment-variable name, not a credential. + }}, + Scopes: []string{"read"}, + }} + } + declaration := mcpauthority.NewBroker(mcpauthority.BrokerConfig{ + CallbackURL: callbackServer.URL + "/oauth/callback", + Routes: []permconfig.MCPServerProfile{ + {Name: "fixture-one", URL: "https://fixture-one.example/mcp", Auth: oauthProfile()}, + {Name: "fixture-two", URL: "https://fixture-two.example/mcp", Auth: oauthProfile()}, + }, + }) + + built, err := app.Build(rootCtx, app.Config{ + AuthorityEvaluator: "noop", + MCPAuthority: declaration, + MCPBrokerAuthorizedCaller: func(_ context.Context, _ mcpbroker.SessionRef, _ string, call session.ToolCall, tokens oauth2.TokenSource) (session.ToolResult, error) { + if _, err := tokens.Token(); err != nil { + return session.ToolResult{}, err + } + return session.NewToolResult(call.ID, "protected result from "+call.Name), nil + }, + MCPBrokerCaller: func(context.Context, mcpbroker.SessionRef, string, session.ToolCall) (session.ToolResult, error) { + return session.ToolResult{}, errors.New("anonymous protected call reached fixture caller") + }, + MCPBrokerDiscovered: []mcpbroker.ToolDefinition{ + {Backend: "fixture-one", Name: toolOne, Description: "first protected fixture tool", Schema: json.RawMessage(`{"type":"object"}`), ReadOnly: true}, + {Backend: "fixture-two", Name: toolTwo, Description: "second protected fixture tool", Schema: json.RawMessage(`{"type":"object"}`), ReadOnly: true}, + }, + MCPBrokerOptions: []mcpbroker.Option{ + mcpbroker.WithOAuthLoopbackForTest(helperMarker{}, roots), + mcpbroker.WithOAuthLimits(2*time.Minute, 3*time.Second), + mcpbroker.WithOAuthSecretResolver(func(context.Context, string) (string, error) { return "fixture-secret", nil }), + }, + MockProvider: provider, + NoSoul: true, + PermissionConfigs: []string{opts.permission}, + ServerImplementation: "mecated", + UserModelDir: opts.userModelDir, + Workspace: opts.workspace, + }) + if err != nil { + return fmt.Errorf("build fixture app: %w", err) + } + defer built.Close() + if err := built.MountMCPBrokerHandlers(callbackMux); err != nil { + return fmt.Errorf("mount broker callback: %w", err) + } + + controlMux := http.NewServeMux() + controlMux.HandleFunc("POST /complete", func(w http.ResponseWriter, r *http.Request) { + var request struct { + Decision string `json:"decision"` + URL string `json:"url"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&request); err != nil { + http.Error(w, "invalid control request", http.StatusBadRequest) + return + } + if request.Decision != "grant" && request.Decision != "deny" { + http.Error(w, "decision must be grant or deny", http.StatusBadRequest) + return + } + presentation, err := url.Parse(request.URL) + if err != nil || presentation.Scheme != "https" || presentation.Host != strings.TrimPrefix(oauthServer.URL, "https://") || presentation.Path != "/authorize" { + http.Error(w, "presentation URL is outside the fixture authority", http.StatusBadRequest) + return + } + query := presentation.Query() + query.Set("fixture_decision", request.Decision) + presentation.RawQuery = query.Encode() + response, err := browserClient.Get(presentation.String()) + if err != nil { + http.Error(w, "authorization completion failed", http.StatusBadGateway) + return + } + defer func() { _ = response.Body.Close() }() + accepted := response.StatusCode == http.StatusOK || request.Decision == "deny" && response.StatusCode == http.StatusBadRequest + if !accepted { + http.Error(w, "callback rejected authorization", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) + }) + controlListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("listen fixture control: %w", err) + } + defer func() { _ = controlListener.Close() }() + controlServer := &http.Server{Handler: controlMux, ReadHeaderTimeout: 5 * time.Second} + go func() { _ = controlServer.Serve(controlListener) }() + defer func() { _ = controlServer.Close() }() + + grpcListener, transport, socketPath, err := listenGRPC(opts) + if err != nil { + return err + } + defer func() { _ = grpcListener.Close() }() + if socketPath != "" { + defer func() { _ = os.Remove(socketPath) }() + } + grpcServer := grpc.NewServer( + grpc.UnaryInterceptor(unaryHeaders), + grpc.StreamInterceptor(streamHeaders), + ) + mecatlv1.RegisterHarnessServiceServer(grpcServer, server.NewHarnessServer(built.Service)) + grpcErrors := make(chan error, 1) + go func() { grpcErrors <- grpcServer.Serve(grpcListener) }() + defer grpcServer.Stop() + + var httpListener net.Listener + var httpServer *http.Server + if opts.httpAddr != "" { + httpListener, err = net.Listen("tcp", opts.httpAddr) + if err != nil { + return fmt.Errorf("listen HTTP: %w", err) + } + httpServer = &http.Server{Handler: httpHeaders(server.NewHTTPHandler(built.Service)), ReadHeaderTimeout: 5 * time.Second} + go func() { _ = httpServer.Serve(httpListener) }() + defer func() { _ = httpServer.Close() }() + } + + info := built.Service.CompatibilityInfo(context.Background()) + ready := readyDocument{ + APIMajor: info.GetApiMajor(), Features: info.GetFeatures(), + FixtureControlURL: "http://" + controlListener.Addr().String() + "/complete", + GRPCAddress: grpcListener.Addr().String(), PID: os.Getpid(), Schema: "mecated-ready/1", + SocketPath: socketPath, Transport: transport, + } + if httpListener != nil { + ready.HTTPAddress = httpListener.Addr().String() + } + if err := writeReady(opts.readyFile, ready); err != nil { + return err + } + + select { + case <-rootCtx.Done(): + return nil + case err := <-grpcErrors: + if errors.Is(err, grpc.ErrServerStopped) { + return nil + } + return fmt.Errorf("serve gRPC: %w", err) + } +} + +func parseFlags() options { + var opts options + flag.StringVar(&opts.grpcAddr, "grpc-addr", "127.0.0.1:0", "gRPC TCP address") + flag.StringVar(&opts.httpAddr, "http-addr", "", "HTTP/SSE TCP address") + flag.StringVar(&opts.permission, "permission-config", "", "explicit permission config") + flag.StringVar(&opts.readyFile, "ready-file", "", "readiness document path") + flag.StringVar(&opts.script, "script", "", "mock provider script") + flag.StringVar(&opts.socketPath, "grpc-unix-socket", "", "gRPC UDS path") + flag.StringVar(&opts.userModelDir, "user-model-dir", "", "user model directory") + flag.StringVar(&opts.workspace, "workspace", "", "workspace directory") + flag.Parse() + return opts +} + +func listenGRPC(opts options) (net.Listener, string, string, error) { + if opts.socketPath == "" { + listener, err := net.Listen("tcp", opts.grpcAddr) + if err != nil { + return nil, "", "", fmt.Errorf("listen gRPC TCP: %w", err) + } + return listener, "tcp", "", nil + } + if err := os.Remove(opts.socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, "", "", fmt.Errorf("remove stale gRPC socket: %w", err) + } + listener, err := net.Listen("unix", opts.socketPath) + if err != nil { + return nil, "", "", fmt.Errorf("listen gRPC UDS: %w", err) + } + return listener, "unix", opts.socketPath, nil +} + +func trustedClient(roots *x509.CertPool) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} + return &http.Client{Transport: transport, Timeout: 5 * time.Second} +} + +func requestProof(ctx context.Context) metadata.MD { + md, _ := metadata.FromIncomingContext(ctx) + proof := metadata.Pairs("x-e2e-response", "fixture") + if caller := md.Get("x-e2e-caller"); len(caller) == 1 { + proof.Set("x-e2e-caller-seen", caller[0]) + } + if expected := md.Get("x-e2e-session-id"); len(expected) == 1 { + affinity := md.Get(sessionaffinity.HeaderName) + proof.Set("x-e2e-affinity-seen", fmt.Sprint(len(affinity) == 1 && affinity[0] == expected[0])) + } + return proof +} + +func unaryHeaders(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + _ = info + _ = grpc.SetHeader(ctx, requestProof(ctx)) + return handler(ctx, req) +} + +func streamHeaders(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + _ = info + _ = stream.SetHeader(requestProof(stream.Context())) + return handler(srv, stream) +} + +func httpHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("x-e2e-response", "fixture") + if caller := r.Header.Get("x-e2e-caller"); caller != "" { + w.Header().Set("x-e2e-caller-seen", caller) + } + if expected := r.Header.Get("x-e2e-session-id"); expected != "" { + w.Header().Set("x-e2e-affinity-seen", fmt.Sprint(r.Header.Get(sessionaffinity.HeaderName) == expected)) + } + next.ServeHTTP(w, r) + }) +} + +func writeReady(path string, document readyDocument) error { + body, err := json.Marshal(document) + if err != nil { + return fmt.Errorf("marshal readiness document: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create readiness directory: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".authorization-ready-*") + if err != nil { + return fmt.Errorf("create readiness temporary file: %w", err) + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(body); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("publish readiness document: %w", err) + } + return nil +} diff --git a/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-ask.json b/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-ask.json new file mode 100644 index 0000000000..5e31ae87ff --- /dev/null +++ b/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-ask.json @@ -0,0 +1,23 @@ +{ + "turns": [ + { + "tool_calls": [ + { + "id": "disconnect-ask-auth", + "name": "mcp__fixture__one", + "args": { "request": "disconnect-at-ask" } + } + ] + }, + { + "tool_calls": [ + { + "id": "disconnect-ask-write", + "name": "Write", + "args": { "path": "stranded.txt", "content": "must not be written\n" } + } + ] + }, + { "text": "permission continuation ended" } + ] +} diff --git a/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-chain.json b/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-chain.json new file mode 100644 index 0000000000..50ee74b227 --- /dev/null +++ b/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect-chain.json @@ -0,0 +1,23 @@ +{ + "turns": [ + { + "tool_calls": [ + { + "id": "disconnect-chain-first", + "name": "mcp__fixture__one", + "args": { "request": "first" } + } + ] + }, + { + "tool_calls": [ + { + "id": "disconnect-chain-second", + "name": "mcp__fixture__two", + "args": { "request": "second" } + } + ] + }, + { "text": "chained cancellation completed" } + ] +} diff --git a/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect.json b/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect.json new file mode 100644 index 0000000000..051283dd44 --- /dev/null +++ b/sdk/typescript/e2e/fixtures/mcp-authorization-disconnect.json @@ -0,0 +1,14 @@ +{ + "turns": [ + { + "tool_calls": [ + { + "id": "disconnect-call", + "name": "mcp__fixture__one", + "args": { "request": "disconnect" } + } + ] + }, + { "delay_ms": 1000, "text": "continuation survived disconnect" } + ] +} diff --git a/sdk/typescript/e2e/fixtures/mcp-authorization-lifecycle.json b/sdk/typescript/e2e/fixtures/mcp-authorization-lifecycle.json new file mode 100644 index 0000000000..18afad55db --- /dev/null +++ b/sdk/typescript/e2e/fixtures/mcp-authorization-lifecycle.json @@ -0,0 +1,87 @@ +{ + "turns": [ + { + "tool_calls": [ + { "id": "grant-call", "name": "mcp__fixture__one", "args": { "case": "grant" } } + ] + }, + { "text": "authorization grant completed" }, + { + "tool_calls": [{ "id": "deny-call", "name": "mcp__fixture__one", "args": { "case": "deny" } }] + }, + { "text": "authorization denial completed" }, + { + "tool_calls": [ + { "id": "cancel-call", "name": "mcp__fixture__one", "args": { "case": "cancel" } } + ] + }, + { "text": "authorization cancellation completed" }, + { + "tool_calls": [ + { + "id": "permission-allow-auth", + "name": "mcp__fixture__one", + "args": { "case": "permission-allow" } + } + ] + }, + { + "tool_calls": [ + { + "id": "permission-allow-write", + "name": "Write", + "args": { "path": "permission-allowed.txt", "content": "allowed\n" } + } + ] + }, + { "text": "permission allow completed" }, + { + "tool_calls": [ + { + "id": "permission-deny-auth", + "name": "mcp__fixture__one", + "args": { "case": "permission-deny" } + } + ] + }, + { + "tool_calls": [ + { + "id": "permission-deny-write", + "name": "Write", + "args": { "path": "permission-denied.txt", "content": "denied\n" } + } + ] + }, + { "text": "permission deny completed" }, + { + "tool_calls": [ + { + "id": "chain-first", + "name": "mcp__fixture__one", + "args": { "case": "chain-first" } + } + ] + }, + { + "tool_calls": [ + { + "id": "chain-second", + "name": "mcp__fixture__two", + "args": { "case": "chain-second" } + } + ] + }, + { "text": "chained authorization completed" }, + { + "tool_calls": [ + { + "id": "continuation-cancel", + "name": "mcp__fixture__one", + "args": { "case": "continuation-cancel" } + } + ] + }, + { "delay_ms": 2000, "text": "continuation should be cancelled" } + ] +} diff --git a/sdk/typescript/e2e/harness.ts b/sdk/typescript/e2e/harness.ts index 46dccaefff..ee84764d53 100644 --- a/sdk/typescript/e2e/harness.ts +++ b/sdk/typescript/e2e/harness.ts @@ -12,6 +12,7 @@ export const cannedMockReply = export interface ReadyDocument { api_major: number; + fixture_control_url?: string; grpc_address: string; http_address?: string; schema: string; @@ -29,7 +30,12 @@ export interface Daemon { restart(options?: DaemonRestartOptions): Promise; } +export interface AuthorizationDaemon extends Daemon { + completeAuthorization(url: string, decision: "grant" | "deny"): Promise; +} + export interface DaemonOptions { + authorization?: boolean; durable?: boolean; http?: boolean; script?: string; @@ -159,6 +165,34 @@ export async function withDaemon( } } +export async function withAuthorizationDaemon( + options: Omit & { script: string }, + run: (daemon: AuthorizationDaemon) => Promise, +): Promise { + return withDaemon({ ...options, authorization: true }, async (daemon) => { + const controlURL = daemon.ready.fixture_control_url; + if (controlURL === undefined) { + throw new Error("authorization fixture daemon omitted its control URL"); + } + const authorizationDaemon: AuthorizationDaemon = { + ...daemon, + completeAuthorization: async (url, decision) => { + const response = await fetch(controlURL, { + body: JSON.stringify({ decision, url }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + throw new Error( + `authorization fixture control failed (${response.status}): ${await response.text()}`, + ); + } + }, + }; + return run(authorizationDaemon); + }); +} + async function startDaemon( options: DaemonOptions, readyFile: string, @@ -169,21 +203,42 @@ async function startDaemon( userModelDirectory: string, environment: NodeJS.ProcessEnv, ): Promise { - const args = [ - "serve", - "--mock", - "--workspace", - workspace, - "--ready-file", - readyFile, - "--metrics-addr", - "", - "--no-soul", - "--user-model-dir", - userModelDirectory, - "--no-scheduler", - "--flight-recorder=false", - ]; + const authorization = options.authorization === true; + const args = authorization + ? [ + "--workspace", + workspace, + "--ready-file", + readyFile, + "--user-model-dir", + userModelDirectory, + "--script", + options.script ?? "", + "--permission-config", + join(dirname(readyFile), "authorization-permissions.yaml"), + ] + : [ + "serve", + "--mock", + "--workspace", + workspace, + "--ready-file", + readyFile, + "--metrics-addr", + "", + "--no-soul", + "--user-model-dir", + userModelDirectory, + "--no-scheduler", + "--flight-recorder=false", + ]; + if (authorization) { + await writeFile( + join(dirname(readyFile), "authorization-permissions.yaml"), + "permissions:\n allow:\n - mcp__fixture__one\n - mcp__fixture__two\n", + "utf8", + ); + } if (options.durable === true) { args.push("--store-dir", storeDirectory); } @@ -200,9 +255,10 @@ async function startDaemon( options.http === true ? (previousReady?.http_address ?? "127.0.0.1:0") : "", ); } - if (options.script !== undefined) args.push("--mock-script", options.script); + if (!authorization && options.script !== undefined) args.push("--mock-script", options.script); - const child = spawn(join(repositoryRoot, "bin", "mecated"), args, { + const binary = authorization ? "mecatl-sdk-authorization-fixture" : "mecated"; + const child = spawn(join(repositoryRoot, "bin", binary), args, { cwd: repositoryRoot, env: environment, stdio: ["ignore", "ignore", "pipe"], diff --git a/sdk/typescript/e2e/mcp-authorization.e2e.test.ts b/sdk/typescript/e2e/mcp-authorization.e2e.test.ts new file mode 100644 index 0000000000..316ebcff02 --- /dev/null +++ b/sdk/typescript/e2e/mcp-authorization.e2e.test.ts @@ -0,0 +1,412 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { describe, expect, it } from "vitest"; + +import { + type Client, + connect as connectHttp, + type Event, + RunAuthorizationRequiredError, + ServerError, + type Session, +} from "../src/index.js"; +import { connect as connectGrpc } from "../src/node.js"; +import { + type AuthorizationDaemon, + type DaemonOptions, + fixture, + type ReadyDocument, + withAuthorizationDaemon, +} from "./harness.js"; + +interface WireCase { + readonly daemon: Omit; + readonly name: "grpc tcp" | "grpc uds" | "http"; + connect(ready: ReadyDocument): Client; +} + +const wireCases: readonly WireCase[] = [ + { + connect: (ready) => connectGrpc({ baseUrl: `http://${ready.grpc_address}` }), + daemon: {}, + name: "grpc tcp", + }, + { + connect: (ready) => { + if (ready.socket_path === undefined) throw new Error("fixture omitted its UDS path"); + return connectGrpc({ socketPath: ready.socket_path }); + }, + daemon: { uds: true }, + name: "grpc uds", + }, + { + connect: (ready) => { + if (ready.http_address === undefined) throw new Error("fixture omitted its HTTP address"); + return connectHttp({ baseUrl: `http://${ready.http_address}` }); + }, + daemon: { http: true }, + name: "http", + }, +]; + +async function parkedAuthorization( + client: Client, + prompt: string, +): Promise<{ readonly authorizationId: string; readonly session: Session }> { + const session = await client.sessions.create({}); + const run = await session.run(prompt); + const failure = await run.result().catch((error: unknown) => error); + if (!(failure instanceof RunAuthorizationRequiredError)) { + throw new Error(`run did not park for authorization: ${String(failure)}`, { cause: failure }); + } + expect(failure).toBeInstanceOf(RunAuthorizationRequiredError); + const required = failure.outcome; + expect(required).toMatchObject({ outcome: "authorization_required", sessionId: session.id }); + expect(required.authorization).toMatchObject({ + kind: "authorization.required", + payload: { status: "pending" }, + runId: run.id, + }); + return { authorizationId: required.authorization.payload.authorizationId, session }; +} + +async function presentAndComplete( + daemon: AuthorizationDaemon, + session: Session, + authorizationId: string, + decision: "grant" | "deny", +): Promise { + const responseHeaders: Headers[] = []; + const presentation = await session.mcpAuthorization(authorizationId).presentation({ + headers: { "x-e2e-caller": "presentation", "x-e2e-session-id": session.id }, + onHeader: (headers) => responseHeaders.push(headers), + timeoutMs: 10_000, + }); + expect(responseHeaders.at(-1)?.get("x-e2e-response")).toBe("fixture"); + expect(responseHeaders.at(-1)?.get("x-e2e-caller-seen")).toBe("presentation"); + expect(responseHeaders.at(-1)?.get("x-e2e-affinity-seen")).toBe("true"); + await daemon.completeAuthorization(presentation, decision); +} + +async function exerciseLifecycle(testCase: WireCase, daemon: AuthorizationDaemon): Promise { + const client = testCase.connect(daemon.ready); + const transport = testCase.name === "http" ? "http" : "grpc"; + try { + const granted = await parkedAuthorization(client, "grant protected access"); + const grantedHandle = granted.session.mcpAuthorization(granted.authorizationId); + await expect(grantedHandle.recheck().result()).resolves.toMatchObject({ + outcome: "pending", + status: "pending", + }); + await presentAndComplete(daemon, granted.session, granted.authorizationId, "grant"); + const streamHeaders: Headers[] = []; + await expect( + grantedHandle + .recheck(undefined, { + headers: { "x-e2e-caller": "recheck", "x-e2e-session-id": granted.session.id }, + onHeader: (headers) => streamHeaders.push(headers), + timeoutMs: 10_000, + }) + .result(), + ).resolves.toMatchObject({ + continuation: { content: "authorization grant completed" }, + outcome: "completed", + status: "granted", + }); + expect(streamHeaders.at(-1)?.get("x-e2e-caller-seen")).toBe("recheck"); + expect(streamHeaders.at(-1)?.get("x-e2e-affinity-seen")).toBe("true"); + await granted.session.delete(); + + const denied = await parkedAuthorization(client, "deny protected access"); + await presentAndComplete(daemon, denied.session, denied.authorizationId, "deny"); + await expect( + denied.session.mcpAuthorization(denied.authorizationId).recheck().result(), + ).resolves.toMatchObject({ + continuation: { content: "authorization denial completed" }, + outcome: "completed", + status: "denied", + }); + await denied.session.delete(); + + const cancelled = await parkedAuthorization(client, "cancel protected access"); + await expect( + cancelled.session.mcpAuthorization(cancelled.authorizationId).cancel().result(), + ).resolves.toMatchObject({ + continuation: { content: "authorization cancellation completed" }, + outcome: "completed", + status: "cancelled", + }); + await cancelled.session.delete(); + + const permissionAllowed = await parkedAuthorization(client, "allow continuation write"); + await presentAndComplete( + daemon, + permissionAllowed.session, + permissionAllowed.authorizationId, + "grant", + ); + const allowedAsks: string[] = []; + await expect( + permissionAllowed.session + .mcpAuthorization(permissionAllowed.authorizationId) + .recheck({ + onPermissionAsk: (ask) => { + allowedAsks.push(ask.tool); + return "allow_once"; + }, + permissionRequestOptions: { + headers: { "x-e2e-caller": "permission-allow" }, + timeoutMs: 10_000, + }, + }) + .result(), + ).resolves.toMatchObject({ + continuation: { content: "permission allow completed" }, + outcome: "completed", + status: "granted", + }); + expect(allowedAsks).toEqual(["Write"]); + await expect(readFile(join(daemon.workspace, "permission-allowed.txt"), "utf8")).resolves.toBe( + "allowed\n", + ); + await permissionAllowed.session.delete(); + + const permissionDenied = await parkedAuthorization(client, "deny continuation write"); + await presentAndComplete( + daemon, + permissionDenied.session, + permissionDenied.authorizationId, + "grant", + ); + const deniedAsks: string[] = []; + await expect( + permissionDenied.session + .mcpAuthorization(permissionDenied.authorizationId) + .recheck({ + onPermissionAsk: (ask) => { + deniedAsks.push(ask.tool); + return "deny"; + }, + }) + .result(), + ).resolves.toMatchObject({ + continuation: { content: "permission deny completed" }, + outcome: "completed", + status: "granted", + }); + expect(deniedAsks).toEqual(["Write"]); + await expect( + readFile(join(daemon.workspace, "permission-denied.txt"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + await permissionDenied.session.delete(); + + const chained = await parkedAuthorization(client, "chain protected access"); + await presentAndComplete(daemon, chained.session, chained.authorizationId, "grant"); + const first = await chained.session + .mcpAuthorization(chained.authorizationId) + .recheck() + .result(); + expect(first).toMatchObject({ outcome: "authorization_required", status: "granted" }); + if (first.outcome !== "authorization_required") { + throw new Error(`expected chained authorization, got ${first.outcome}`); + } + expect(first.nextAuthorization.payload.authorizationId).not.toBe(chained.authorizationId); + const nextID = first.nextAuthorization.payload.authorizationId; + await presentAndComplete(daemon, chained.session, nextID, "grant"); + await expect( + chained.session.mcpAuthorization(nextID).recheck().result(), + ).resolves.toMatchObject({ + continuation: { content: "chained authorization completed" }, + outcome: "completed", + status: "granted", + }); + await chained.session.delete(); + + const continuationCancelled = await parkedAuthorization(client, "cancel continuation"); + await presentAndComplete( + daemon, + continuationCancelled.session, + continuationCancelled.authorizationId, + "grant", + ); + const cancellationFlow = continuationCancelled.session + .mcpAuthorization(continuationCancelled.authorizationId) + .recheck(); + const cancellationIterator = cancellationFlow[Symbol.asyncIterator](); + const cancellationEvents: Event[] = []; + while (cancellationFlow.continuationRunId === undefined) { + const next = await cancellationIterator.next(); + if (next.done) throw new Error("continuation ended before its run id was observed"); + cancellationEvents.push(next.value); + } + await cancellationFlow.cancelContinuation({ + headers: { "x-e2e-caller": "continuation-cancel" }, + timeoutMs: 10_000, + }); + for (;;) { + const next = await cancellationIterator.next(); + if (next.done) break; + cancellationEvents.push(next.value); + } + expect(cancellationEvents.at(-1)).toMatchObject({ + kind: "result", + payload: { stop: "cancelled" }, + runId: cancellationFlow.continuationRunId, + }); + + const unknown = await continuationCancelled.session + .mcpAuthorization("well-formed-unknown") + .presentation() + .catch((error: unknown) => error); + expect(unknown).toBeInstanceOf(ServerError); + expect(unknown).toMatchObject({ transport }); + expect((unknown as ServerError).code).not.toBe(""); + await continuationCancelled.session.delete(); + } finally { + await client.close(); + } +} + +async function waitForState(session: Session, state: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if ((await session.snapshot()).state === state) return; + await delay(20); + } + throw new Error(`session ${session.id} did not reach ${state}`); +} + +async function disconnectActiveContinuation(testCase: WireCase): Promise { + await withAuthorizationDaemon( + { ...testCase.daemon, script: fixture("mcp-authorization-disconnect.json") }, + async (daemon) => { + const client = testCase.connect(daemon.ready); + try { + const { authorizationId, session } = await parkedAuthorization( + client, + `disconnect active ${testCase.name} continuation`, + ); + await presentAndComplete(daemon, session, authorizationId, "grant"); + const iterator = session + .mcpAuthorization(authorizationId) + .recheck() + [Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { kind: "authorization.resolved", payload: { status: "granted" } }, + }); + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + await waitForState(session, testCase.name === "http" ? "cancelled" : "completed"); + await session.delete(); + } finally { + await client.close(); + } + }, + ); +} + +async function disconnectParkedAsk(testCase: WireCase): Promise { + await withAuthorizationDaemon( + { ...testCase.daemon, script: fixture("mcp-authorization-disconnect-ask.json") }, + async (daemon) => { + const client = testCase.connect(daemon.ready); + try { + const { authorizationId, session } = await parkedAuthorization( + client, + `disconnect ${testCase.name} permission park`, + ); + await presentAndComplete(daemon, session, authorizationId, "grant"); + const iterator = session + .mcpAuthorization(authorizationId) + .recheck() + [Symbol.asyncIterator](); + for (;;) { + const next = await iterator.next(); + if (next.done) throw new Error("continuation ended before the permission park"); + if (next.value.kind === "permission.ask") break; + } + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + await waitForState(session, "cancelled"); + await expect( + readFile(join(daemon.workspace, "stranded.txt"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + await session.delete(); + } finally { + await client.close(); + } + }, + ); +} + +async function disconnectAfterChainedPark(testCase: WireCase): Promise { + await withAuthorizationDaemon( + { ...testCase.daemon, script: fixture("mcp-authorization-disconnect-chain.json") }, + async (daemon) => { + const client = testCase.connect(daemon.ready); + try { + const { authorizationId, session } = await parkedAuthorization( + client, + `disconnect ${testCase.name} after chained park`, + ); + await presentAndComplete(daemon, session, authorizationId, "grant"); + const iterator = session + .mcpAuthorization(authorizationId) + .recheck() + [Symbol.asyncIterator](); + let nextAuthorizationID = ""; + for (;;) { + const next = await iterator.next(); + if (next.done) throw new Error("continuation ended before the chained authorization"); + if (next.value.kind === "authorization.required") { + nextAuthorizationID = next.value.payload.authorizationId; + break; + } + } + expect(nextAuthorizationID).not.toBe(authorizationId); + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + + const nextHandle = session.mcpAuthorization(nextAuthorizationID); + await expect(nextHandle.presentation()).resolves.toMatch(/^https:\/\//u); + await expect(nextHandle.recheck().result()).resolves.toMatchObject({ + outcome: "pending", + status: "pending", + }); + await expect(nextHandle.cancel().result()).resolves.toMatchObject({ + continuation: { content: "chained cancellation completed" }, + outcome: "completed", + status: "cancelled", + }); + await session.delete(); + } finally { + await client.close(); + } + }, + ); +} + +describe("real-wire MCP authorization", () => { + it("MCP authorization works over gRPC TCP UDS and HTTP SSE", async () => { + for (const testCase of wireCases) { + await withAuthorizationDaemon( + { ...testCase.daemon, script: fixture("mcp-authorization-lifecycle.json") }, + async (daemon) => { + try { + await exerciseLifecycle(testCase, daemon); + } catch (cause) { + throw new Error(`${testCase.name} authorization lifecycle failed`, { cause }); + } + }, + ); + } + }, 120_000); + + it("MCP authorization disconnect follows transport and park phase", async () => { + for (const testCase of wireCases) { + await disconnectActiveContinuation(testCase); + await disconnectParkedAsk(testCase); + await disconnectAfterChainedPark(testCase); + } + }, 120_000); +}); diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index fdb8d4c530..b70a5f331a 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -167,6 +167,10 @@ class AuthorizationInput implements AsyncIterable { this.close(); return { done: true, value: undefined }; }, + throw: async (error?: unknown) => { + this.close(); + throw error; + }, }; } } From ba4afb03b0483a7033572a1235519bc94d01444e Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Thu, 17 Sep 2026 23:57:17 +0200 Subject: [PATCH 06/15] feat(sdk): publish MCP authorization lifecycle Signed-off-by: Samuele Verzi --- docs/architecture.md | 28 ++ docs/design/IMPLEMENTATION-NOTES.md | 58 ++- sdk/typescript/etc/mecatl-sdk-deno.api.md | 6 - sdk/typescript/etc/mecatl-sdk-node.api.md | 6 - sdk/typescript/etc/mecatl-sdk.api.md | 6 - sdk/typescript/examples/README.md | 1 + sdk/typescript/examples/mcp-authorization.ts | 70 ++++ sdk/typescript/scripts/generate-api-docs.mjs | 2 + sdk/typescript/src/client.ts | 7 +- sdk/typescript/src/mcp-authorization.ts | 97 ++++- sdk/typescript/src/run.ts | 21 +- sdk/typescript/test/examples.test.ts | 21 ++ sdk/typescript/test/package.test.ts | 224 +++++++++++ .../typescript-sdk/permissions-and-plans.md | 98 +++++ .../typescript-sdk/sessions-and-runs.md | 42 ++- .../reference/typescript-sdk-api/core.md | 355 +++++++++++++++++- 16 files changed, 1001 insertions(+), 41 deletions(-) create mode 100644 sdk/typescript/examples/mcp-authorization.ts diff --git a/docs/architecture.md b/docs/architecture.md index 1905dc6623..ab1c75dba9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -349,6 +349,34 @@ Deno 2.x without unstable resolution flags. See [ADR 0279](adr/0279-typescript-sdk-architecture.md) and [ADR 0339](adr/0339-typescript-sdk-deno.md). +An ordinary `Run` can complete with a terminal result or park on +`authorization.required`. `Run.outcome()` represents both as normal detached values; +event iteration also ends cleanly after the park, while completed-only `Run.result()` +raises `RunAuthorizationRequiredError` with the same handoff. Each remains a mutually +exclusive consumption mode and releases the Session's live-run registration without +changing the server's pending authorization. + +`Session.mcpAuthorization(authorizationId)` binds the handoff to the existing +session-affined operation bag. The reusable handle asserts no state and stores no +credential or lifecycle truth. `presentation()` returns one validated live HTTP(S) URL +for application-owned display without opening or persisting it. Each `recheck()` or +`cancel()` creates a distinct lazy, single-consumption flow. First consumption starts one +exact-affinity control request. The flow validates the authoritative status and optional +continuation into pending, settled, completed, or chained-authorization results. The SDK +does not poll, retry a mutation, reconnect, or choose a permission verdict. Automatic +permission responses use their separately declared request options and the existing +prompt-free exact-run controls. + +Request cancellation releases only SDK-owned resources. The server decides what committed +before disconnect. gRPC detaches and drains ordinary continuation work but cancels a run +stranded on an ordinary permission ask. HTTP requests cancellation of a still-active +continuation and drains it. Both leave a follow-up authorization intact after its park is +committed. An application that observed the continuation run ID may use existing attach or +activity APIs for explicit recovery where storage retains it. Before that correlation is +observed, a lost control response can be unrecoverable, and a new recheck succeeds only if +the original authorization is still pending. See +[ADR 0348](adr/0348-typescript-sdk-mcp-authorization-lifecycle.md). + The `./node` entry point can also own a local daemon through `spawn()`. It resolves an already-installed `mecated` from `binaryPath`, `MECATED_BIN`, then `PATH` without a shell; creates a private per-client runtime directory; and launches the fixed UDS-only, diff --git a/docs/design/IMPLEMENTATION-NOTES.md b/docs/design/IMPLEMENTATION-NOTES.md index 5d5d042560..16c068d3d7 100644 --- a/docs/design/IMPLEMENTATION-NOTES.md +++ b/docs/design/IMPLEMENTATION-NOTES.md @@ -8568,12 +8568,66 @@ the ergonomic client also probes status and maps transport/auth/incompatibility making the probe a second protocol contract. `Session` handles are lightweight views over one client. A handle admits one live run at a time, while separately fetched handles let callers model real server-side races. `Run` is single-consumption: callers choose async event iteration -or `result()`, never both. Server terminal stops — including `cancelled` — resolve as typed -values; transport/protocol/server failures reject. Every approval, cancel, and steer frame +`outcome()`, or `result()`, never more than one. `outcome()` admits either the ordinary terminal +result or a valid final `authorization.required` park. Iteration closes normally after that park, +while completed-only `result()` raises `RunAuthorizationRequiredError` carrying the same detached +handoff. Every path closes the response iterator and releases `SessionImpl`'s live-run ownership +without sending a cancellation or resolving the pending authorization. Server terminal stops, +including `cancelled`, resolve as typed values; transport/protocol/server failures reject. Every +approval, cancel, and steer frame carries `expected_run_id`, so a stale HTTP control becomes typed `stale_run_control` and cannot affect the session's next run. HTTP steer and cancel-steer use their unary routes only when the server advertises `http_steer`; older servers still produce the typed unsupported-feature error. +The MCP authorization resource is `sdk/typescript/src/mcp-authorization.ts` (ADR 0348). +`SessionImpl.mcpAuthorization()` validates the caller-supplied non-empty authorization ID and +returns a lightweight handle over the Session's already-affined operations. Construction performs +no compatibility probe, registration, RPC, or state assertion. `presentation()` performs one +existing presentation RPC, validates an absolute HTTP(S) URL, and returns the original string. +The SDK never opens, copies, caches, renders, or persists that URL, and it stores no authorization +credential or lifecycle truth. + +Each `recheck()` and `cancel()` creates a new `McpAuthorizationFlowImpl`. Flow construction and +iterator acquisition are lazy; first `next()` or `result()` starts the timeout, observes an +already-aborted caller signal, registers one client-owned stream, and invokes the exact existing +descriptor with session affinity. The flow is single-consumption and owns independent abort, +pending-ask, control, and iterator state. Its first event must carry the handle's authorization ID, +an empty run ID, a non-empty call ID, and the closed status vocabulary. Pending pairs only with +`authorization.required`; every terminal status pairs only with `authorization.resolved`. + +Clean EOF after the authoritative event yields `pending` or `settled`. A continuation fixes its +non-empty run ID from the first later event, requires exactly one repeated original resolution, and +ends with either one `RunResult` or a different pending authorization. Those paths yield +`completed` or `authorization_required`. Correlation drift, malformed status pairing, duplicate +resolution or result, and an incomplete continuation fail as `ProtocolError`. The flow exposes +the continuation ID as soon as it is known but creates no `Run`, attachment, activity scan, or +successor handle. + +Continuation permissions route through `Session.controls(continuationRunId)`, giving HTTP and +gRPC the same prompt-free exact-run mutations. `onPermissionAsk` sees only an ordinary observed ask +and uses only `permissionRequestOptions`; manual `resolveAsk()` uses its own options. A +plan-originated ask is yielded but never passed to that responder. `cancelContinuation()` and ask +resolution fail with the existing unsupported-feature error when `prompt_free_controls` is absent. +The flow-level `RequestOptions` stay scoped to the stream and are never reused as mutation +authority. + +Closing a flow aborts only SDK-owned resources and never retries, polls, reconnects, opens a +browser, or claims a server outcome. A fresh recheck after loss is a new one-shot mutation and can +recover only while the same authorization remains pending. Once a prior control clears pending +state, the server's not-found response is final for this lifecycle. An observed continuation ID +can feed the ordinary attachment APIs when retained activity permits it. On disconnect, gRPC +detaches and drains ordinary continuation work but cancels a continuation stranded on an ordinary +permission ask; HTTP requests cancellation for an active continuation and drains it. Both preserve +a chained authorization after its park commits. The server owns terminal races. + +The root barrel exports the handle, flow, statuses, results, Run outcomes, and +`RunAuthorizationRequiredError`; `./node` and `./deno` inherit the same declarations. API +Extractor reports, generated SDK reference, package and example tests, and Deno's declaration +matrix gate entry-point parity. The package-only `sdk/typescript/examples/mcp-authorization.ts` +keeps browser action, recheck cadence, permission policy, chained handoff, and bounded recovery in +application code. Any commit changing `sdk/typescript/` enters the automated changelog generator's +path selection unless it changes only the changelog itself. + `sdk/typescript/src/events.ts` normalizes gRPC protobuf events and HTTP JSON/SSE records into one discriminated union, retaining an explicit unknown-event member for forward compatibility. The Go↔TypeScript kind-parity gate prevents the known vocabulary from drifting. Permission diff --git a/sdk/typescript/etc/mecatl-sdk-deno.api.md b/sdk/typescript/etc/mecatl-sdk-deno.api.md index ff3f0c8c6b..907bd067f1 100644 --- a/sdk/typescript/etc/mecatl-sdk-deno.api.md +++ b/sdk/typescript/etc/mecatl-sdk-deno.api.md @@ -616,11 +616,8 @@ export const MAX_PROMPT_MEDIA_PARTS = 16; export interface McpAuthorization { // (undocumented) readonly authorizationId: string; - // (undocumented) cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; - // (undocumented) presentation(requestOptions?: RequestOptions): Promise; - // (undocumented) recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; // (undocumented) readonly sessionId: string; @@ -630,15 +627,12 @@ export interface McpAuthorization { export interface McpAuthorizationFlow extends AsyncIterable { // (undocumented) readonly authorizationId: string; - // (undocumented) cancelContinuation(requestOptions?: RequestOptions): Promise; // (undocumented) readonly continuationRunId: string | undefined; // (undocumented) readonly operation: McpAuthorizationOperation; - // (undocumented) resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; - // (undocumented) result(): Promise; // (undocumented) readonly sessionId: string; diff --git a/sdk/typescript/etc/mecatl-sdk-node.api.md b/sdk/typescript/etc/mecatl-sdk-node.api.md index e0eecc9e51..2e5a72a5ae 100644 --- a/sdk/typescript/etc/mecatl-sdk-node.api.md +++ b/sdk/typescript/etc/mecatl-sdk-node.api.md @@ -637,11 +637,8 @@ export const MAX_PROMPT_MEDIA_PARTS = 16; export interface McpAuthorization { // (undocumented) readonly authorizationId: string; - // (undocumented) cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; - // (undocumented) presentation(requestOptions?: RequestOptions): Promise; - // (undocumented) recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; // (undocumented) readonly sessionId: string; @@ -651,15 +648,12 @@ export interface McpAuthorization { export interface McpAuthorizationFlow extends AsyncIterable { // (undocumented) readonly authorizationId: string; - // (undocumented) cancelContinuation(requestOptions?: RequestOptions): Promise; // (undocumented) readonly continuationRunId: string | undefined; // (undocumented) readonly operation: McpAuthorizationOperation; - // (undocumented) resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; - // (undocumented) result(): Promise; // (undocumented) readonly sessionId: string; diff --git a/sdk/typescript/etc/mecatl-sdk.api.md b/sdk/typescript/etc/mecatl-sdk.api.md index 524936e4d0..a0717f1534 100644 --- a/sdk/typescript/etc/mecatl-sdk.api.md +++ b/sdk/typescript/etc/mecatl-sdk.api.md @@ -601,11 +601,8 @@ export const MAX_PROMPT_MEDIA_PARTS = 16; export interface McpAuthorization { // (undocumented) readonly authorizationId: string; - // (undocumented) cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; - // (undocumented) presentation(requestOptions?: RequestOptions): Promise; - // (undocumented) recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; // (undocumented) readonly sessionId: string; @@ -615,15 +612,12 @@ export interface McpAuthorization { export interface McpAuthorizationFlow extends AsyncIterable { // (undocumented) readonly authorizationId: string; - // (undocumented) cancelContinuation(requestOptions?: RequestOptions): Promise; // (undocumented) readonly continuationRunId: string | undefined; // (undocumented) readonly operation: McpAuthorizationOperation; - // (undocumented) resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; - // (undocumented) result(): Promise; // (undocumented) readonly sessionId: string; diff --git a/sdk/typescript/examples/README.md b/sdk/typescript/examples/README.md index e8bc0bee24..c85c4b5e70 100644 --- a/sdk/typescript/examples/README.md +++ b/sdk/typescript/examples/README.md @@ -16,6 +16,7 @@ the ESM `./node` entry point. Deno 2.9.3 through Deno 2.x uses `./deno` for gRPC | [`callback-tool.ts`](./callback-tool.ts) | Register one local callback tool before session creation. | | [`browser-bff.ts`](./browser-bff.ts) | Show the browser side of the recommended same-origin BFF deployment. This is guidance, not shipped BFF server code. | | [`permissions.ts`](./permissions.ts) | Resolve permission asks with a narrow callback. | +| [`mcp-authorization.ts`](./mcp-authorization.ts) | Present and explicitly recheck a parked MCP authorization, including chained authorization and bounded recovery. | | [`run-events.ts`](./run-events.ts) | Consume one run as a typed event stream. | | [`multimodal.ts`](./multimodal.ts) | Send text and a local image in one prompt. | | [`durable-attachment.ts`](./durable-attachment.ts) | Resume durable cross-run activity from an application-owned cursor. | diff --git a/sdk/typescript/examples/mcp-authorization.ts b/sdk/typescript/examples/mcp-authorization.ts new file mode 100644 index 0000000000..b3c6f6f718 --- /dev/null +++ b/sdk/typescript/examples/mcp-authorization.ts @@ -0,0 +1,70 @@ +import { connect, TransportError } from "@stacklok-oss/mecatl-sdk/node"; + +async function main(): Promise { + await using client = connect({ + baseUrl: process.env.MECATL_URL ?? "http://127.0.0.1:8080", + }); + const session = await client.sessions.create({}); + const run = await session.run("Use the configured MCP server to inspect the repository"); + const initial = await run.outcome(); + + if (initial.outcome === "completed") { + console.log(initial.result.text); + return; + } + + let authorization = session.mcpAuthorization(initial.authorization.payload.authorizationId); + let recoveryAttempts = 0; + + for (;;) { + const presentationUrl = await authorization.presentation({ timeoutMs: 10_000 }); + console.log("Complete authorization at:", presentationUrl); + await waitForApplicationRecheck(); + + const flow = authorization.recheck( + { + onPermissionAsk: (ask, signal) => { + if (signal.aborted) return undefined; + return ask.tool === "Read" ? "allow_once" : "deny"; + }, + permissionRequestOptions: { timeoutMs: 10_000 }, + }, + { timeoutMs: 30_000 }, + ); + + try { + const result = await flow.result(); + recoveryAttempts = 0; + switch (result.outcome) { + case "pending": + console.log("Authorization is still pending"); + break; + case "settled": + console.log("Authorization ended with status", result.status); + return; + case "completed": + console.log(result.continuation.text); + return; + case "authorization_required": + console.log("Continuation parked on another authorization"); + console.log(result.nextAuthorization.payload.authorizationId); + authorization = session.mcpAuthorization( + result.nextAuthorization.payload.authorizationId, + ); + break; + } + } catch (error) { + if (!(error instanceof TransportError) || recoveryAttempts >= 1) throw error; + // A lost control response is ambiguous. This bounded retry is a new recheck + // and can succeed only if this authorization remains pending on the server. + recoveryAttempts += 1; + } + } +} + +await main(); + +async function waitForApplicationRecheck(): Promise { + console.log("Press Enter to check authorization status"); + await new Promise((resolve) => process.stdin.once("data", () => resolve())); +} diff --git a/sdk/typescript/scripts/generate-api-docs.mjs b/sdk/typescript/scripts/generate-api-docs.mjs index db85bf71f6..b5cd500c40 100644 --- a/sdk/typescript/scripts/generate-api-docs.mjs +++ b/sdk/typescript/scripts/generate-api-docs.mjs @@ -133,6 +133,8 @@ function validateDocumentation(items, entryPoint) { const missing = []; const detailedCallableTypes = new Set([ "AttachedRun", + "McpAuthorization", + "McpAuthorizationFlow", "NodeClient", "PlanResolution", "Run", diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 2657bcbf1c..5a4d774619 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -198,7 +198,12 @@ export interface ClearSessionOptions { /** A durable Mecatl session handle. @public */ export interface Session { readonly id: string; - /** Binds one external authorization ID to this session without performing I/O. */ + /** + * Binds one external authorization ID to this session without performing I/O. + * + * @param authorizationId - Exact non-empty ID from an authorization event. + * @returns A reusable correlation handle that makes no authorization-state assertion. + */ mcpAuthorization(authorizationId: string): McpAuthorization; /** * Reads the current broker connector inventory for this session. diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index b70a5f331a..b6fe55320d 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -20,7 +20,13 @@ import { PLAN_APPROVAL_TOOL } from "./plan.js"; import type { PermissionAskResponder, PermissionVerdict, RunResult } from "./run.js"; import { createRunControls, type RunControls } from "./run-controls.js"; -/** The closed authorization status vocabulary interpreted by the lifecycle helper. @public */ +/** + * The closed authorization status vocabulary interpreted by the lifecycle helper. + * + * The server remains authoritative for every status. An unknown value is a protocol error in + * this lifecycle even though the general event union keeps raw status strings open. + * @public + */ export type McpAuthorizationStatus = | "pending" | "granted" @@ -31,18 +37,30 @@ export type McpAuthorizationStatus = | "failed" | "closed"; -/** The server transition requested by one authorization flow. @public */ +/** The one-shot server transition requested by an authorization flow. @public */ export type McpAuthorizationOperation = "recheck" | "cancel"; -/** Application-owned behavior for one authorization continuation. @public */ +/** + * Application-owned permission behavior for one authorization continuation. + * + * These options never choose an authorization status or browser policy. Automatic permission + * replies use only `permissionRequestOptions`, independently of the flow request options. + * @public + */ export interface McpAuthorizationFlowOptions { - /** Automatically answers ordinary permission asks observed on the continuation. */ + /** Automatically answers only ordinary permission asks observed on the continuation. */ onPermissionAsk?: PermissionAskResponder; /** Request options used only for automatic permission replies. */ permissionRequestOptions?: RequestOptions; } -/** The authoritative result of one authorization recheck or cancellation. @public */ +/** + * The authoritative result of one authorization recheck or cancellation. + * + * `pending` and `settled` have no continuation. `completed` carries one ordinary run result. + * `authorization_required` hands off a different authorization parked by the continuation. + * @public + */ export type McpAuthorizationResult = | { readonly outcome: "pending"; @@ -69,30 +87,95 @@ export type McpAuthorizationResult = readonly nextAuthorization: EventOf<"authorization.required">; }; -/** One lazy, single-consumption authorization control and optional continuation. @public */ +/** + * One lazy, single-consumption authorization control and optional continuation. + * + * Calling `recheck()` or `cancel()` creates this flow without I/O. The first iterator `next()` or + * `result()` performs the one control request with exact session affinity. Iteration and + * `result()` are mutually exclusive. Request cancellation releases SDK-owned resources but does + * not determine whether the server committed the control. The SDK does not poll, retry, reconnect, + * or scan durable activity automatically. + * @public + */ export interface McpAuthorizationFlow extends AsyncIterable { readonly sessionId: string; readonly authorizationId: string; readonly operation: McpAuthorizationOperation; readonly continuationRunId: string | undefined; + /** + * Resolves one observed ordinary permission ask on the exact continuation run. + * + * @param askId - ID of a pending ask already observed on this flow. + * @param verdict - Application-owned permission decision to send unchanged. + * @param requestOptions - Options used only for this permission mutation. + * @returns A promise that resolves after the server accepts the decision. + * @throws `InvalidStateError` when the ask is unknown, no longer pending, or plan-originated. + * @throws `UnsupportedFeatureError` when the server lacks `prompt_free_controls`. + */ resolveAsk( askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions, ): Promise; + /** + * Requests cancellation of the exact continuation run already observed by this flow. + * + * @param requestOptions - Options used only for this cancellation mutation. + * @returns A promise that resolves after the server accepts the request. + * @throws `InvalidStateError` before a continuation run is observed. + * @throws `UnsupportedFeatureError` when the server lacks `prompt_free_controls`. + */ cancelContinuation(requestOptions?: RequestOptions): Promise; + /** + * Starts and drains this flow as its single consumption mode. + * + * @returns A pending, settled, completed, or chained-authorization result. + * @throws `InvalidStateError` when the flow is already being consumed. + * @throws `ProtocolError` when the server stream violates lifecycle correlation or grammar. + */ result(): Promise; } -/** A reusable session-bound correlation handle for one server-owned authorization. @public */ +/** + * A reusable session-bound correlation handle for one server-owned authorization. + * + * Construction stores exact correlation only. It performs no I/O and makes no state or authority + * claim. The handle does not persist credentials or lifecycle truth. Every presentation lookup and + * control request receives automatic session affinity. + * @public + */ export interface McpAuthorization { readonly sessionId: string; readonly authorizationId: string; + /** + * Reads the live presentation URL for this authorization. + * + * The application owns display and browser policy. The SDK validates and returns the HTTP(S) + * string without opening, copying, caching, rendering, or persisting it. + * + * @param requestOptions - Request headers, callbacks, cancellation signal, and deadline. + * @returns The server's current absolute HTTP(S) presentation URL. + * @throws `ProtocolError` when the response has no valid absolute HTTP(S) URL. + */ presentation(requestOptions?: RequestOptions): Promise; + /** + * Creates one lazy authorization recheck flow. + * + * @param options - Permission handling for a possible continuation. + * @param requestOptions - Options used only when this flow starts. + * @returns A distinct, transport-lazy, single-consumption flow. + */ recheck( options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions, ): McpAuthorizationFlow; + /** + * Creates one lazy authorization cancellation flow. + * + * @param options - Permission handling for a possible continuation. + * @param requestOptions - Options used only when this flow starts. + * @returns A distinct, transport-lazy, single-consumption flow. + */ cancel( options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions, diff --git a/sdk/typescript/src/run.ts b/sdk/typescript/src/run.ts index d4d043893e..d770de0e2d 100644 --- a/sdk/typescript/src/run.ts +++ b/sdk/typescript/src/run.ts @@ -56,13 +56,18 @@ export interface RunResult { readonly rawEvent: EventOf<"result">; } -/** A normally completed run outcome. @public */ +/** A normally completed run outcome returned by `Run.outcome()`. @public */ export interface RunCompletedOutcome { readonly outcome: "completed"; readonly result: RunResult; } -/** A run that handed off one pending external authorization. @public */ +/** + * A run that handed off one pending external authorization. + * + * This detached value carries correlation only. The server retains lifecycle ownership. + * @public + */ export interface RunAuthorizationRequiredOutcome { readonly outcome: "authorization_required"; readonly sessionId: string; @@ -70,10 +75,15 @@ export interface RunAuthorizationRequiredOutcome { readonly authorization: EventOf<"authorization.required">; } -/** The closed set of normal outcomes from Run.outcome(). @public */ +/** The closed set of completion and authorization-park outcomes from `Run.outcome()`. @public */ export type RunOutcome = RunCompletedOutcome | RunAuthorizationRequiredOutcome; -/** Run.result() consumed a valid authorization handoff instead of a completed result. @public */ +/** + * `Run.result()` consumed a valid authorization handoff instead of a completed result. + * + * Read `outcome` to create `Session.mcpAuthorization()` with the exact authorization ID. + * @public + */ export class RunAuthorizationRequiredError extends InvalidStateError { readonly outcome: RunAuthorizationRequiredOutcome; @@ -127,10 +137,11 @@ export interface Run extends AsyncIterable { */ outcome(): Promise; /** - * Drains all remaining events and returns the typed terminal outcome. + * Drains all remaining events and returns the completed terminal result. * * @returns The terminal result for this run. * @throws `InvalidStateError` when the run is already being consumed. + * @throws `RunAuthorizationRequiredError` when the run parks on external authorization. */ result(): Promise; } diff --git a/sdk/typescript/test/examples.test.ts b/sdk/typescript/test/examples.test.ts index 577f84e618..4195c3bae2 100644 --- a/sdk/typescript/test/examples.test.ts +++ b/sdk/typescript/test/examples.test.ts @@ -16,6 +16,7 @@ const conciseExamples = [ "deno-local.ts", "deno-remote.ts", "local-spawn.ts", + "mcp-authorization.ts", "multimodal.ts", "one-shot-query.ts", "permissions.ts", @@ -133,6 +134,26 @@ test("the Deno examples cover remote connect and Deno.Command-backed local spawn expect(local).toContain("spawn("); }); +test("MCP authorization example uses only the public lifecycle", () => { + const source = readFileSync(join(examplesRoot, "mcp-authorization.ts"), "utf8"); + expect(importSpecifiers(source)).toEqual(["@stacklok-oss/mecatl-sdk/node"]); + expect(source).toContain("await run.outcome()"); + expect(source).toContain("authorization.presentation("); + expect(source).toContain("const flow = authorization.recheck("); + expect(source).toContain("onPermissionAsk:"); + expect(source).toContain('case "pending"'); + expect(source).toContain('case "settled"'); + expect(source).toContain('case "completed"'); + expect(source).toContain('case "authorization_required"'); + expect(source).toContain("nextAuthorization.payload.authorizationId"); + expect(source).toContain("A lost control response is ambiguous"); + expect(source).not.toMatch(/\b(?:globalThis\.)?open\s*\(/u); + expect(source).not.toMatch(/\bwindow\./u); + expect(source).not.toContain("process.exit("); + expect(source).not.toMatch(/mecatui|setTimeout|setInterval/u); + expect(source).not.toMatch(/(?:^|\/)src\//u); +}); + test("the Deno gate runs the local Deno.Command lifecycle", () => { const taskfile = readFileSync(join(packageRoot, "Taskfile.yml"), "utf8"); expect(taskfile).toContain("node scripts/run-deno-integration.mjs"); diff --git a/sdk/typescript/test/package.test.ts b/sdk/typescript/test/package.test.ts index 0365165717..cad5cf162e 100644 --- a/sdk/typescript/test/package.test.ts +++ b/sdk/typescript/test/package.test.ts @@ -665,6 +665,230 @@ if (JSON.stringify(SessionMode) !== JSON.stringify({ Unspecified: 0, Default: 1, expect(guide).toContain(operation); }); +test("MCP authorization lifecycle is exported documented and API reviewed", () => { + const consumer = join(consumerRoot, "mcp-authorization-lifecycle.mts"); + writeFileSync( + consumer, + ` +import { + RunAuthorizationRequiredError, + type EventOf, + type McpAuthorization, + type McpAuthorizationFlow, + type McpAuthorizationFlowOptions, + type McpAuthorizationOperation, + type McpAuthorizationResult, + type McpAuthorizationStatus, + type RequestOptions, + type Run, + type RunAuthorizationRequiredOutcome, + type RunCompletedOutcome, + type RunOutcome, + type Session, +} from "@stacklok-oss/mecatl-sdk"; +import { + RunAuthorizationRequiredError as NodeRunAuthorizationRequiredError, + type McpAuthorization as NodeMcpAuthorization, + type McpAuthorizationFlow as NodeMcpAuthorizationFlow, + type McpAuthorizationFlowOptions as NodeMcpAuthorizationFlowOptions, + type McpAuthorizationOperation as NodeMcpAuthorizationOperation, + type McpAuthorizationResult as NodeMcpAuthorizationResult, + type McpAuthorizationStatus as NodeMcpAuthorizationStatus, + type RunAuthorizationRequiredOutcome as NodeRunAuthorizationRequiredOutcome, + type RunCompletedOutcome as NodeRunCompletedOutcome, + type RunOutcome as NodeRunOutcome, +} from "@stacklok-oss/mecatl-sdk/node"; +import { + RunAuthorizationRequiredError as DenoRunAuthorizationRequiredError, + type McpAuthorization as DenoMcpAuthorization, + type McpAuthorizationFlow as DenoMcpAuthorizationFlow, + type McpAuthorizationFlowOptions as DenoMcpAuthorizationFlowOptions, + type McpAuthorizationOperation as DenoMcpAuthorizationOperation, + type McpAuthorizationResult as DenoMcpAuthorizationResult, + type McpAuthorizationStatus as DenoMcpAuthorizationStatus, + type RunAuthorizationRequiredOutcome as DenoRunAuthorizationRequiredOutcome, + type RunCompletedOutcome as DenoRunCompletedOutcome, + type RunOutcome as DenoRunOutcome, +} from "@stacklok-oss/mecatl-sdk/deno"; + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) ? true : false; +type Assert = Value; + +declare const session: Session; +declare const run: Run; +const authorization: McpAuthorization = session.mcpAuthorization("authorization-1"); +const presentation: Promise = authorization.presentation({ timeoutMs: 1_000 }); +const flow: McpAuthorizationFlow = authorization.recheck( + { onPermissionAsk: () => "allow_once", permissionRequestOptions: { timeoutMs: 500 } }, + { timeoutMs: 2_000 }, +); +const outcome: Promise = run.outcome(); +const completedOnly = run.result(); +type Handle = Assert McpAuthorization +>>; +type Presentation = Assert Promise +>>; +type Recheck = Assert McpAuthorizationFlow +>>; +type Cancel = Assert McpAuthorizationFlow +>>; +type Status = Assert>; +type Operation = Assert>; +type Pending = Extract; +type Chained = Extract; +type PendingEvent = Assert>>; +type ChainedEvent = Assert>>; +type RunModes = Assert>; +type RuntimeParity = Assert> & Assert>; +type NodeParity = Assert>; +type DenoParity = Assert>; +void [authorization, presentation, flow, outcome, completedOnly]; +export type { + Cancel, + ChainedEvent, + DenoParity, + Handle, + NodeParity, + Operation, + PendingEvent, + Presentation, + Recheck, + RunModes, + RuntimeParity, + Status, +}; +`, + ); + + const typecheck = spawnSync( + process.execPath, + [ + join(packageRoot, "node_modules", "typescript", "bin", "tsc"), + "--noEmit", + "--strict", + "--target", + "ES2022", + "--lib", + "ESNext,DOM,DOM.Iterable", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "--types", + "node", + "--typeRoots", + join(packageRoot, "node_modules", "@types"), + consumer, + ], + { cwd: consumerRoot, encoding: "utf8" }, + ); + expect(typecheck.stderr).toBe(""); + expect(typecheck.stdout).toBe(""); + expect(typecheck.status).toBe(0); + + const symbols = [ + "McpAuthorization", + "McpAuthorizationFlow", + "McpAuthorizationFlowOptions", + "McpAuthorizationOperation", + "McpAuthorizationResult", + "McpAuthorizationStatus", + "RunAuthorizationRequiredError", + "RunAuthorizationRequiredOutcome", + "RunCompletedOutcome", + "RunOutcome", + ]; + for (const report of ["mecatl-sdk.api.md", "mecatl-sdk-node.api.md", "mecatl-sdk-deno.api.md"]) { + const api = readFileSync(join(packageRoot, "etc", report), "utf8"); + for (const symbol of symbols) expect(api, `${report} exports ${symbol}`).toContain(symbol); + } + + const referenceRoot = resolve(packageRoot, "../../user-docs/reference/typescript-sdk-api"); + const coreReference = readFileSync(join(referenceRoot, "core.md"), "utf8"); + for (const symbol of symbols) { + expect(coreReference).toContain(`id="api-${symbol.toLowerCase()}-`); + } + for (const entrypoint of ["node.md", "deno.md"]) { + const reference = readFileSync(join(referenceRoot, entrypoint), "utf8"); + expect(reference).toContain("The entry point also exports the [shared core API](./core.md)."); + } + + const releaseWorkflow = readFileSync( + resolve(packageRoot, "../../.github/workflows/create-sdk-typescript-release-pr.yml"), + "utf8", + ); + expect(releaseWorkflow).toContain('"sdk/typescript",'); + expect(releaseWorkflow).toContain('":(exclude)sdk/typescript/CHANGELOG.md",'); +}); + test("run controls are exported documented and api reviewed", () => { const consumer = join(consumerRoot, "run-controls.mts"); writeFileSync( diff --git a/user-docs/building/typescript-sdk/permissions-and-plans.md b/user-docs/building/typescript-sdk/permissions-and-plans.md index e2945762bc..4adb367e36 100644 --- a/user-docs/building/typescript-sdk/permissions-and-plans.md +++ b/user-docs/building/typescript-sdk/permissions-and-plans.md @@ -61,6 +61,102 @@ cancellation, or deadline after dispatch can reject the promise after the server accepts the verdict. Reconcile that ambiguous case from the session's durable activity before retrying. +## Continue after MCP authorization + +Create a session-bound authorization handle from the handoff returned by +`Run.outcome()`. The handle stores correlation only and performs no request or +state check during construction: + +```ts +const outcome = await run.outcome(); +if (outcome.outcome !== 'authorization_required') { + console.log(outcome.result.text); +} else { + const authorization = session.mcpAuthorization( + outcome.authorization.payload.authorizationId + ); + const url = await authorization.presentation({ timeoutMs: 10_000 }); + renderAuthorizationLink(url); +} +``` + +`presentation()` returns the server's current absolute HTTP(S) URL. Treat it as +live sensitive data. Your application chooses how to display it and whether to +open a browser. The SDK does not open, copy, cache, render, or persist the URL. +The server also keeps presentation URLs out of stored events and snapshots. + +After the person completes the external flow, create one explicit recheck: + +```ts +const flow = authorization.recheck( + { + onPermissionAsk: (ask) => + ask.tool === 'Read' ? 'allow_once' : 'deny', + permissionRequestOptions: { timeoutMs: 10_000 }, + }, + { signal: recheckSignal, timeoutMs: 30_000 } +); +const result = await flow.result(); +``` + +`recheck()` and `cancel()` each return a new lazy, +single-consumption `McpAuthorizationFlow`. The operation starts when the first +iterator `next()` or `result()` consumes the flow, not when your application +creates the flow or requests its iterator. Request headers, callbacks, signals, +and deadlines apply only to that flow, and the SDK adds exact session affinity. + +The result discriminant defines the next application action: + +|`outcome`|Meaning| +|-|-| +|`pending`|The same authorization remains pending. Its status is `pending`.| +|`settled`|The authorization ended without a continuation. Its status is `granted`, `denied`, `cancelled`, `expired`, `interrupted`, `failed`, or `closed`.| +|`completed`|A continuation finished with one ordinary `RunResult`.| +|`authorization_required`|The continuation parked on a different authorization. Create a new handle from `nextAuthorization` and present its live URL.| + +Iteration yields the same decoded events in wire order. Choose iteration or +`result()` once for each flow. An unknown status, mismatched session or +authorization correlation, changed continuation run ID, or malformed terminal +sequence throws `ProtocolError`. + +The application owns permission policy. `onPermissionAsk` receives only an +ordinary permission ask observed on the continuation. Its verdict uses +`permissionRequestOptions`, while manual `resolveAsk()` uses only the options +passed to that method. Both controls address the exact observed continuation +run. A plan-originated ask remains in the event stream and requires the +separate plan workflow or explicit continuation cancellation. Servers need the +`prompt_free_controls` feature for permission replies and +`cancelContinuation()`; status-only flows do not require that feature. + +### Bound polling and recovery + +Choose the recheck cadence and its stopping condition in your application. +The SDK performs no polling, mutation retry, transparent reconnect, durable +watch, browser action, or authorization-state persistence. Cancelling a request +releases the SDK's stream and controls, but the server remains authoritative +for any transition committed before cancellation reached it. + +A response lost before your application observes the status or +`continuationRunId` can be unrecoverable through this lifecycle. A later +`recheck()` is a new one-shot mutation that succeeds only while the same +authorization remains pending. If the earlier control cleared that pending +state, the server returns its not-found error instead of replaying the result. +Bound any deliberate retry and let that refusal surface. + +After observing `continuationRunId`, you can use `session.attach(runId)` or +`session.activity()` where the deployment retains the needed activity. The +lifecycle does not search that activity or guarantee retention. + +Disconnect effects depend on the continuation phase and transport. gRPC +detaches and drains ordinary continuation work, but it cancels a continuation +stranded on an ordinary permission ask. HTTP requests cancellation for a +still-active continuation and drains it. After a continuation commits a later +`authorization.required` park, either transport preserves that new pending +authorization. Terminal races remain server-authoritative. + +For a complete package-export-only workflow, see +[`mcp-authorization.ts`](https://github.com/stacklok/mecatl/blob/main/sdk/typescript/examples/mcp-authorization.ts). + ## Resolve a plan during a live run Plan approval is separate from ordinary permission approval. Pass @@ -108,6 +204,8 @@ needs the durable timeline across both run IDs. ## Next steps +- [Work with sessions and runs](./sessions-and-runs.md) to consume completed or + authorization-parked runs. - [Resume durable activity](./durable-activity.md) to observe approved plans across both runs. - [Permissions and posture](/features/permissions-and-posture.md) for the diff --git a/user-docs/building/typescript-sdk/sessions-and-runs.md b/user-docs/building/typescript-sdk/sessions-and-runs.md index 42a19eed37..e4dda3fb3f 100644 --- a/user-docs/building/typescript-sdk/sessions-and-runs.md +++ b/user-docs/building/typescript-sdk/sessions-and-runs.md @@ -134,7 +134,8 @@ while adding its session-affinity hint when the session ID can be represented. ## Choose one run-consumption mode -Call `result()` when the application needs only the terminal outcome: +Call `result()` when the application expects the run to complete with a terminal +result: ```ts const run = await session.run('Summarize this repository'); @@ -156,10 +157,41 @@ for await (const event of run) { } ``` -A run can be iterated or drained with `result()`, once. Calling both is an -invalid local lifecycle operation. Server-declared terminal outcomes such as -cancellation, limits, or budget exhaustion resolve as `RunResult` values. -Transport and protocol failures throw typed SDK errors. +A run can be iterated, drained with `result()`, or drained with `outcome()`, +once. Calling more than one of these methods is an invalid local lifecycle +operation. Server-declared terminal outcomes such as cancellation, limits, or +budget exhaustion resolve as `RunResult` values. Transport and protocol +failures throw typed SDK errors. + +## Handle a run parked for MCP authorization + +Use `outcome()` when an MCP server can require external authorization. It +returns either the completed result or a detached authorization handoff: + +```ts +const run = await session.run('Use the configured MCP server'); +const outcome = await run.outcome(); + +if (outcome.outcome === 'completed') { + console.log(outcome.result.text); +} else { + const authorization = session.mcpAuthorization( + outcome.authorization.payload.authorizationId + ); + console.log(await authorization.presentation()); +} +``` + +An authorization park is a normal run outcome. Event iteration yields the +final `authorization.required` event and then ends. The completed-only +`result()` method throws `RunAuthorizationRequiredError`; its `outcome` +property carries the same handoff. All three paths release the SDK's live run +ownership without cancelling or resolving the pending authorization. + +The handoff contains the exact session, run, call, and authorization +correlation. It does not contain the presentation URL or transfer lifecycle +authority to the SDK. Continue the workflow as described in +[Handle permissions and plans](./permissions-and-plans.md#continue-after-mcp-authorization). ## Send controls to a live run diff --git a/user-docs/reference/typescript-sdk-api/core.md b/user-docs/reference/typescript-sdk-api/core.md index f642997055..96eda9c341 100644 --- a/user-docs/reference/typescript-sdk-api/core.md +++ b/user-docs/reference/typescript-sdk-api/core.md @@ -80,6 +80,12 @@ This reference describes the declarations exported by `@stacklok-oss/mecatl-sdk` | [`MAX_MEDIA_PART_BYTES`](#api-max-media-part-bytes-variable) | Variable | | [`MAX_PROMPT_MEDIA_BYTES`](#api-max-prompt-media-bytes-variable) | Variable | | [`MAX_PROMPT_MEDIA_PARTS`](#api-max-prompt-media-parts-variable) | Variable | +| [`McpAuthorization`](#api-mcpauthorization-interface) | Interface | +| [`McpAuthorizationFlow`](#api-mcpauthorizationflow-interface) | Interface | +| [`McpAuthorizationFlowOptions`](#api-mcpauthorizationflowoptions-interface) | Interface | +| [`McpAuthorizationOperation`](#api-mcpauthorizationoperation-typealias) | Type alias | +| [`McpAuthorizationResult`](#api-mcpauthorizationresult-typealias) | Type alias | +| [`McpAuthorizationStatus`](#api-mcpauthorizationstatus-typealias) | Type alias | | [`McpConnectorAvailability`](#api-mcpconnectoravailability-typealias) | Type alias | | [`McpConnectorAvailability`](#api-mcpconnectoravailability-variable) | Variable | | [`McpConnectorCatalogueState`](#api-mcpconnectorcataloguestate-typealias) | Type alias | @@ -124,8 +130,12 @@ This reference describes the declarations exported by `@stacklok-oss/mecatl-sdk` | [`ResultEventPayload`](#api-resulteventpayload-interface) | Interface | | [`RetryDisposition`](#api-retrydisposition-typealias) | Type alias | | [`Run`](#api-run-interface) | Interface | +| [`RunAuthorizationRequiredError`](#api-runauthorizationrequirederror-class) | Class | +| [`RunAuthorizationRequiredOutcome`](#api-runauthorizationrequiredoutcome-interface) | Interface | +| [`RunCompletedOutcome`](#api-runcompletedoutcome-interface) | Interface | | [`RunControls`](#api-runcontrols-interface) | Interface | | [`RunOptions`](#api-runoptions-interface) | Interface | +| [`RunOutcome`](#api-runoutcome-typealias) | Type alias | | [`RunResult`](#api-runresult-interface) | Interface | | [`RunSteerAcknowledgement`](#api-runsteeracknowledgement-interface) | Interface | | [`RunSteerCancellationAcknowledgement`](#api-runsteercancellationacknowledgement-interface) | Interface | @@ -574,6 +584,35 @@ Parameters: - `message` (`string`) - `options` (`Omit`) +RunAuthorizationRequiredError + +`Run.result()` consumed a valid authorization handoff instead of a completed result. Read `outcome` to create `Session.mcpAuthorization()` with the exact authorization ID. + +```ts +export declare class RunAuthorizationRequiredError extends InvalidStateError +``` + +Callable members: [`constructor`](#api-runauthorizationrequirederror-constructor-constructor) + +RunAuthorizationRequiredError.constructor + +Constructs a new instance of the `RunAuthorizationRequiredError` class + +```ts +constructor(outcome: RunAuthorizationRequiredOutcome, options: Omit); +``` + +Parameters: + +- `outcome` (`RunAuthorizationRequiredOutcome`) +- `options` (`Omit`) + +RunAuthorizationRequiredError.outcome + +```ts +readonly outcome: RunAuthorizationRequiredOutcome; +``` + ServerError A typed domain failure returned by the Mecatl server. @@ -2504,6 +2543,184 @@ readonly projectMemory?: DreamTargetCapability; readonly userModel?: DreamTargetCapability; ``` +McpAuthorization + +A reusable session-bound correlation handle for one server-owned authorization. Construction stores exact correlation only. It performs no I/O and makes no state or authority claim. The handle does not persist credentials or lifecycle truth. Every presentation lookup and control request receives automatic session affinity. + +```ts +export interface McpAuthorization +``` + +Callable members: [`cancel()`](#api-mcpauthorization-cancel-methodsignature), [`presentation()`](#api-mcpauthorization-presentation-methodsignature), [`recheck()`](#api-mcpauthorization-recheck-methodsignature) + +McpAuthorization.authorizationId + +```ts +readonly authorizationId: string; +``` + +McpAuthorization.cancel + +Creates one lazy authorization cancellation flow. + +```ts +cancel(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; +``` + +Parameters: + +- `options` (`McpAuthorizationFlowOptions`, optional): Permission handling for a possible continuation. +- `requestOptions` (`RequestOptions`, optional): Options used only when this flow starts. + +Returns: `McpAuthorizationFlow`: A distinct, transport-lazy, single-consumption flow. + +McpAuthorization.presentation + +Reads the live presentation URL for this authorization. The application owns display and browser policy. The SDK validates and returns the HTTP(S) string without opening, copying, caching, rendering, or persisting it. + +```ts +presentation(requestOptions?: RequestOptions): Promise; +``` + +Parameters: + +- `requestOptions` (`RequestOptions`, optional): Request headers, callbacks, cancellation signal, and deadline. + +Returns: `Promise`: The server's current absolute HTTP(S) presentation URL. + +Throws: `ProtocolError` when the response has no valid absolute HTTP(S) URL. + +McpAuthorization.recheck + +Creates one lazy authorization recheck flow. + +```ts +recheck(options?: McpAuthorizationFlowOptions, requestOptions?: RequestOptions): McpAuthorizationFlow; +``` + +Parameters: + +- `options` (`McpAuthorizationFlowOptions`, optional): Permission handling for a possible continuation. +- `requestOptions` (`RequestOptions`, optional): Options used only when this flow starts. + +Returns: `McpAuthorizationFlow`: A distinct, transport-lazy, single-consumption flow. + +McpAuthorization.sessionId + +```ts +readonly sessionId: string; +``` + +McpAuthorizationFlow + +One lazy, single-consumption authorization control and optional continuation. Calling `recheck()` or `cancel()` creates this flow without I/O. The first iterator `next()` or `result()` performs the one control request with exact session affinity. Iteration and `result()` are mutually exclusive. Request cancellation releases SDK-owned resources but does not determine whether the server committed the control. The SDK does not poll, retry, reconnect, or scan durable activity automatically. + +```ts +export interface McpAuthorizationFlow extends AsyncIterable +``` + +Callable members: [`cancelContinuation()`](#api-mcpauthorizationflow-cancelcontinuation-methodsignature), [`resolveAsk()`](#api-mcpauthorizationflow-resolveask-methodsignature), [`result()`](#api-mcpauthorizationflow-result-methodsignature) + +McpAuthorizationFlow.authorizationId + +```ts +readonly authorizationId: string; +``` + +McpAuthorizationFlow.cancelContinuation + +Requests cancellation of the exact continuation run already observed by this flow. + +```ts +cancelContinuation(requestOptions?: RequestOptions): Promise; +``` + +Parameters: + +- `requestOptions` (`RequestOptions`, optional): Options used only for this cancellation mutation. + +Returns: `Promise`: A promise that resolves after the server accepts the request. + +Throws: `InvalidStateError` before a continuation run is observed. + +Throws: `UnsupportedFeatureError` when the server lacks `prompt_free_controls`. + +McpAuthorizationFlow.continuationRunId + +```ts +readonly continuationRunId: string | undefined; +``` + +McpAuthorizationFlow.operation + +```ts +readonly operation: McpAuthorizationOperation; +``` + +McpAuthorizationFlow.resolveAsk + +Resolves one observed ordinary permission ask on the exact continuation run. + +```ts +resolveAsk(askId: string, verdict: PermissionVerdict, requestOptions?: RequestOptions): Promise; +``` + +Parameters: + +- `askId` (`string`): ID of a pending ask already observed on this flow. +- `verdict` (`PermissionVerdict`): Application-owned permission decision to send unchanged. +- `requestOptions` (`RequestOptions`, optional): Options used only for this permission mutation. + +Returns: `Promise`: A promise that resolves after the server accepts the decision. + +Throws: `InvalidStateError` when the ask is unknown, no longer pending, or plan-originated. + +Throws: `UnsupportedFeatureError` when the server lacks `prompt_free_controls`. + +McpAuthorizationFlow.result + +Starts and drains this flow as its single consumption mode. + +```ts +result(): Promise; +``` + +Returns: `Promise`: A pending, settled, completed, or chained-authorization result. + +Throws: `InvalidStateError` when the flow is already being consumed. + +Throws: `ProtocolError` when the server stream violates lifecycle correlation or grammar. + +McpAuthorizationFlow.sessionId + +```ts +readonly sessionId: string; +``` + +McpAuthorizationFlowOptions + +Application-owned permission behavior for one authorization continuation. These options never choose an authorization status or browser policy. Automatic permission replies use only `permissionRequestOptions`, independently of the flow request options. + +```ts +export interface McpAuthorizationFlowOptions +``` + +McpAuthorizationFlowOptions.onPermissionAsk + +Automatically answers only ordinary permission asks observed on the continuation. + +```ts +onPermissionAsk?: PermissionAskResponder; +``` + +McpAuthorizationFlowOptions.permissionRequestOptions + +Request options used only for automatic permission replies. + +```ts +permissionRequestOptions?: RequestOptions; +``` + McpConnectorInventory A current, nonhistorical snapshot of broker connector publication. @@ -3214,7 +3431,7 @@ One accepted server run and its single-consumption event stream. export interface Run extends AsyncIterable ``` -Callable members: [`approve()`](#api-run-approve-methodsignature), [`cancel()`](#api-run-cancel-methodsignature), [`resolveAsk()`](#api-run-resolveask-methodsignature), [`result()`](#api-run-result-methodsignature), [`steer()`](#api-run-steer-methodsignature) +Callable members: [`approve()`](#api-run-approve-methodsignature), [`cancel()`](#api-run-cancel-methodsignature), [`outcome()`](#api-run-outcome-methodsignature), [`resolveAsk()`](#api-run-resolveask-methodsignature), [`result()`](#api-run-result-methodsignature), [`steer()`](#api-run-steer-methodsignature) Run.approve @@ -3249,6 +3466,18 @@ Returns: `Promise`: A promise that resolves after the cancellation request readonly id: string; ``` +Run.outcome + +Drains all remaining events and returns either completion or an authorization handoff. + +```ts +outcome(): Promise; +``` + +Returns: `Promise`: The normal terminal outcome for this run. + +Throws: `InvalidStateError` when the run is already being consumed. + Run.resolveAsk Resolves one pending ask on this run with the server's string verdict vocabulary. @@ -3270,7 +3499,7 @@ Throws: `InvalidStateError` when used for a plan-approval ask. Run.result -Drains all remaining events and returns the typed terminal outcome. +Drains all remaining events and returns the completed terminal result. ```ts result(): Promise; @@ -3280,6 +3509,8 @@ Returns: `Promise`: The terminal result for this run. Throws: `InvalidStateError` when the run is already being consumed. +Throws: `RunAuthorizationRequiredError` when the run parks on external authorization. + Run.sessionId ```ts @@ -3300,6 +3531,58 @@ Parameters: Returns: `Promise`: A promise that resolves after the steering request is sent. +RunAuthorizationRequiredOutcome + +A run that handed off one pending external authorization. This detached value carries correlation only. The server retains lifecycle ownership. + +```ts +export interface RunAuthorizationRequiredOutcome +``` + +RunAuthorizationRequiredOutcome.authorization + +```ts +readonly authorization: EventOf<"authorization.required">; +``` + +RunAuthorizationRequiredOutcome.outcome + +```ts +readonly outcome: "authorization_required"; +``` + +RunAuthorizationRequiredOutcome.runId + +```ts +readonly runId: string; +``` + +RunAuthorizationRequiredOutcome.sessionId + +```ts +readonly sessionId: string; +``` + +RunCompletedOutcome + +A normally completed run outcome returned by `Run.outcome()`. + +```ts +export interface RunCompletedOutcome +``` + +RunCompletedOutcome.outcome + +```ts +readonly outcome: "completed"; +``` + +RunCompletedOutcome.result + +```ts +readonly result: RunResult; +``` + RunControls Prompt-free controls bound to one exact session run. Construct this resource with `Session.controls`. It does not attach, subscribe, or keep a run alive. Every method requires the server's `prompt_free_controls` feature, addresses `runId` exactly, performs one unary request without automatic retry, and accepts ordinary `RequestOptions`. A server that lacks the feature raises `UnsupportedFeatureError` before a control RPC is sent. Ended, cancelling, replaced, or otherwise stale runs fail with the server's typed `stale_run_control` error. A transport failure, caller cancellation, or deadline after dispatch can reject the promise after the server accepted the operation. Reconcile that ambiguous case from the authoritative session or activity state before deciding whether to retry. @@ -4060,7 +4343,7 @@ A durable Mecatl session handle. export interface Session ``` -Callable members: [`activity()`](#api-session-activity-methodsignature), [`attach()`](#api-session-attach-methodsignature), [`cancelWorkspaceEnrollment()`](#api-session-cancelworkspaceenrollment-methodsignature), [`clear()`](#api-session-clear-methodsignature), [`close()`](#api-session-close-methodsignature), [`compact()`](#api-session-compact-methodsignature), [`connectWorkspaceServices()`](#api-session-connectworkspaceservices-methodsignature), [`controls()`](#api-session-controls-methodsignature), [`delete()`](#api-session-delete-methodsignature), [`listMcpConnectors()`](#api-session-listmcpconnectors-methodsignature), [`rename()`](#api-session-rename-methodsignature), [`resolvePlan()`](#api-session-resolveplan-methodsignature), [`retry()`](#api-session-retry-methodsignature), [`retryWorkspaceEnrollment()`](#api-session-retryworkspaceenrollment-methodsignature), [`run()`](#api-session-run-methodsignature), [`setMode()`](#api-session-setmode-methodsignature), [`snapshot()`](#api-session-snapshot-methodsignature), [`transcript()`](#api-session-transcript-methodsignature) +Callable members: [`activity()`](#api-session-activity-methodsignature), [`attach()`](#api-session-attach-methodsignature), [`cancelWorkspaceEnrollment()`](#api-session-cancelworkspaceenrollment-methodsignature), [`clear()`](#api-session-clear-methodsignature), [`close()`](#api-session-close-methodsignature), [`compact()`](#api-session-compact-methodsignature), [`connectWorkspaceServices()`](#api-session-connectworkspaceservices-methodsignature), [`controls()`](#api-session-controls-methodsignature), [`delete()`](#api-session-delete-methodsignature), [`listMcpConnectors()`](#api-session-listmcpconnectors-methodsignature), [`mcpAuthorization()`](#api-session-mcpauthorization-methodsignature), [`rename()`](#api-session-rename-methodsignature), [`resolvePlan()`](#api-session-resolveplan-methodsignature), [`retry()`](#api-session-retry-methodsignature), [`retryWorkspaceEnrollment()`](#api-session-retryworkspaceenrollment-methodsignature), [`run()`](#api-session-run-methodsignature), [`setMode()`](#api-session-setmode-methodsignature), [`snapshot()`](#api-session-snapshot-methodsignature), [`transcript()`](#api-session-transcript-methodsignature) Session.activity @@ -4221,6 +4504,20 @@ Parameters: Returns: `Promise`: A detached SDK-owned connector inventory projection. +Session.mcpAuthorization + +Binds one external authorization ID to this session without performing I/O. + +```ts +mcpAuthorization(authorizationId: string): McpAuthorization; +``` + +Parameters: + +- `authorizationId` (`string`): Exact non-empty ID from an authorization event. + +Returns: `McpAuthorization`: A reusable correlation handle that makes no authorization-state assertion. + Session.rename Replaces the title of an eligible session. @@ -6471,6 +6768,50 @@ A wire event kind currently understood by this SDK. export type KnownEventKind = (typeof MECATL_EVENT_KINDS)[number]; ``` +McpAuthorizationOperation + +The one-shot server transition requested by an authorization flow. + +```ts +export type McpAuthorizationOperation = "recheck" | "cancel"; +``` + +McpAuthorizationResult + +The authoritative result of one authorization recheck or cancellation. `pending` and `settled` have no continuation. `completed` carries one ordinary run result. `authorization_required` hands off a different authorization parked by the continuation. + +```ts +export type McpAuthorizationResult = { + readonly outcome: "pending"; + readonly status: "pending"; + readonly authorization: EventOf<"authorization.required">; +} | { + readonly outcome: "settled"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; +} | { + readonly outcome: "completed"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly continuation: RunResult; +} | { + readonly outcome: "authorization_required"; + readonly status: Exclude; + readonly authorization: EventOf<"authorization.resolved">; + readonly continuationRunId: string; + readonly nextAuthorization: EventOf<"authorization.required">; +}; +``` + +McpAuthorizationStatus + +The closed authorization status vocabulary interpreted by the lifecycle helper. The server remains authoritative for every status. An unknown value is a protocol error in this lifecycle even though the general event union keeps raw status strings open. + +```ts +export type McpAuthorizationStatus = "pending" | "granted" | "denied" | "cancelled" | "expired" | "interrupted" | "failed" | "closed"; +``` + McpConnectorAvailability One broker-snapshot availability value. @@ -6575,6 +6916,14 @@ Retry classification fields carried by model-retry and result payloads. export type RetryDisposition = 0 | 1 | 2 | 3; ``` +RunOutcome + +The closed set of completion and authorization-park outcomes from `Run.outcome()`. + +```ts +export type RunOutcome = RunCompletedOutcome | RunAuthorizationRequiredOutcome; +``` + SdkCursor A serializable cursor issued by a durable SDK attachment. From 726c638e03ebbe61f85a059050a143238360528f Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 00:25:35 +0200 Subject: [PATCH 07/15] docs: mark SDK authorization lifecycle landed Signed-off-by: Samuele Verzi --- docs/acceptance/sdk-mcp-authorization-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/sdk-mcp-authorization-lifecycle.md b/docs/acceptance/sdk-mcp-authorization-lifecycle.md index 434d87e52d..504ac172db 100644 --- a/docs/acceptance/sdk-mcp-authorization-lifecycle.md +++ b/docs/acceptance/sdk-mcp-authorization-lifecycle.md @@ -4,7 +4,7 @@ **Work classification:** Architectural — this adds durable public TypeScript SDK resource, stream, status, result, and control contracts for a stateful session-bound authorization workflow. **Decision record:** [ADR 0348](../adr/0348-typescript-sdk-mcp-authorization-lifecycle.md) **Phase:** ergonomic TypeScript SDK MCP authorization lifecycle -**Status:** in-progress, 2026-09-18. Implementation proceeds as a sequential stack layer above open Plan / Interface PR #1687 by explicit directing-user instruction and includes the attachment, termination, and correlation clarifications requested during review. +**Status:** landed, 2026-09-18. The stacked implementation candidate satisfies the reviewed attachment, termination, and correlation contract; this transition becomes authoritative when the Implementation PR merges. **Delivery:** Split. The public SDK object model, Run parking contract, single-consumption stream grammar, control routing, and disconnect semantics require Plan / Interface review before implementation. **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1469](https://github.com/stacklok/mecatl/issues/1469) From efb5799e2f49b375f3c3b7bb905c47762d5ab468 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 00:39:39 +0200 Subject: [PATCH 08/15] docs(acceptance): update MCP authorization Vitest locators Signed-off-by: Samuele Verzi --- .../sdk-mcp-authorization-lifecycle.md | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/acceptance/sdk-mcp-authorization-lifecycle.md b/docs/acceptance/sdk-mcp-authorization-lifecycle.md index 504ac172db..ebfac4e023 100644 --- a/docs/acceptance/sdk-mcp-authorization-lifecycle.md +++ b/docs/acceptance/sdk-mcp-authorization-lifecycle.md @@ -162,13 +162,13 @@ stream; see [architecture](../architecture.md#typescript-sdk). **Acceptance:** - AC1.1: `Run.outcome()` drains one claimed Run and returns `RunCompletedOutcome` for exactly one terminal `result`, or `RunAuthorizationRequiredOutcome` when the final event is `authorization.required` with status `pending`, a non-empty authorization/call ID, and the Run's exact non-empty session/run correlation. - - verify: vitest:sdk/typescript/test/run.test.ts — `Run outcome discriminates completion from authorization parking` + - verify: vitest:sdk/typescript/test/run.test.ts#UnVuIG91dGNvbWUgZGlzY3JpbWluYXRlcyBjb21wbGV0aW9uIGZyb20gYXV0aG9yaXphdGlvbiBwYXJraW5n — `sdk/typescript/test/run.test.ts :: "Run outcome discriminates completion from authorization parking"` - AC1.2: Event iteration yields the valid final `authorization.required` and then closes normally; the completed-only `Run.result()` releases the stream and throws `RunAuthorizationRequiredError` carrying the same detached outcome, never `ProtocolError`. - - verify: vitest:sdk/typescript/test/run.test.ts — `authorization parked Run iteration and result use normal handoff semantics` + - verify: vitest:sdk/typescript/test/run.test.ts#YXV0aG9yaXphdGlvbiBwYXJrZWQgUnVuIGl0ZXJhdGlvbiBhbmQgcmVzdWx0IHVzZSBub3JtYWwgaGFuZG9mZiBzZW1hbnRpY3M — `sdk/typescript/test/run.test.ts :: "authorization parked Run iteration and result use normal handoff semantics"` - AC1.3: Authorization-park EOF, `outcome()`, `result()`, and iterator return after the parked event close the underlying response iterator, unregister the Run, clear `SessionImpl`'s busy state, and do not cancel or resolve the server's pending authorization; the same Session can immediately create its lifecycle handle. - - verify: vitest:sdk/typescript/test/run.test.ts — `authorization parked Run releases SDK ownership without cancelling authorization` + - verify: vitest:sdk/typescript/test/run.test.ts#YXV0aG9yaXphdGlvbiBwYXJrZWQgUnVuIHJlbGVhc2VzIFNESyBvd25lcnNoaXAgd2l0aG91dCBjYW5jZWxsaW5nIGF1dGhvcml6YXRpb24 — `sdk/typescript/test/run.test.ts :: "authorization parked Run releases SDK ownership without cancelling authorization"` - AC1.4: `outcome()`, `result()`, and iteration are mutually exclusive single-consumption modes; completed runs retain their existing `RunResult`, and EOF without either a terminal `result` or final valid authorization requirement remains `ProtocolError`. - - verify: vitest:sdk/typescript/test/run.test.ts — `Run outcome preserves completed and malformed stream behavior` + - verify: vitest:sdk/typescript/test/run.test.ts#UnVuIG91dGNvbWUgcHJlc2VydmVzIGNvbXBsZXRlZCBhbmQgbWFsZm9ybWVkIHN0cmVhbSBiZWhhdmlvcg — `sdk/typescript/test/run.test.ts :: "Run outcome preserves completed and malformed stream behavior"` ### Scenario 2 — a session-bound handle presents one live authorization @@ -177,11 +177,11 @@ without importing mecatui state or widening the thin MCP inventory namespace. **Acceptance:** - AC2.1: `session.mcpAuthorization(authorizationId)` rejects an empty authorization ID locally; otherwise it synchronously returns an `McpAuthorization` with exact readonly session/authorization IDs and performs no compatibility probe, RPC, stream open, durable watch, or state assertion during construction. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization handle binds exact correlation without I/O` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gaGFuZGxlIGJpbmRzIGV4YWN0IGNvcnJlbGF0aW9uIHdpdGhvdXQgSS9P — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization handle binds exact correlation without I/O"` - AC2.2: `presentation(requestOptions?)` makes one existing presentation RPC with automatic exact session affinity, preserves caller headers, callbacks, signal, and deadline, and returns the server's absolute HTTP(S) URL string without caching, opening, copying, rendering, or persisting it. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization presentation is live validated and application owned` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gcHJlc2VudGF0aW9uIGlzIGxpdmUgdmFsaWRhdGVkIGFuZCBhcHBsaWNhdGlvbiBvd25lZA — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization presentation is live validated and application owned"` - AC2.3: An absent, relative, non-HTTP(S), or otherwise malformed presentation URL raises `ProtocolError`; server ownership, unknown/past authorization, expiry, lease, and availability refusals retain their normalized typed errors without exposing another session or credential. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization presentation preserves protocol and server failures` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gcHJlc2VudGF0aW9uIHByZXNlcnZlcyBwcm90b2NvbCBhbmQgc2VydmVyIGZhaWx1cmVz — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization presentation preserves protocol and server failures"` ### Scenario 3 — recheck and cancel are lazy correlated single-consumption flows @@ -191,15 +191,15 @@ namespace response or TUI phase machine. **Acceptance:** - AC3.1: `recheck()` and `cancel()` synchronously return distinct flows with exact immutable session/authorization/operation values but perform no compatibility probe, registration, RPC, mutation, or timer start until the first iterator `next()` or `result()`; merely requesting an iterator claims it but remains transport-lazy. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization operations start only on first consumption` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gb3BlcmF0aW9ucyBzdGFydCBvbmx5IG9uIGZpcnN0IGNvbnN1bXB0aW9u — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization operations start only on first consumption"` - AC3.2: First consumption registers one client-owned stream, starts `timeoutMs`, observes an already-aborted caller signal before transport work, and issues exactly the named existing descriptor with one initial gRPC frame carrying only the handle's session/authorization IDs or the equivalent bodyless HTTP route. Establishment/server errors and header callbacks surface from that consuming `next()` or `result()`, not from flow construction. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization flow start preserves request timing and exact control` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gZmxvdyBzdGFydCBwcmVzZXJ2ZXMgcmVxdWVzdCB0aW1pbmcgYW5kIGV4YWN0IGNvbnRyb2w — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization flow start preserves request timing and exact control"` - AC3.3: The first authoritative frame is a known authorization event with empty `runId`, the exact authorization ID, non-empty call ID, a known status, and required-`pending` versus resolved-terminal pairing; every mismatch, missing payload, unknown status, or malformed frame raises `ProtocolError`. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization flow validates the authoritative control result` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gZmxvdyB2YWxpZGF0ZXMgdGhlIGF1dGhvcml0YXRpdmUgY29udHJvbCByZXN1bHQ — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization flow validates the authoritative control result"` - AC3.4: Clean EOF after only the authoritative event returns discriminated `pending` or `settled`; denial, cancellation, expiry, interruption, failure, and closure are typed values rather than exceptions, and impossible event/status/result combinations are not representable by `McpAuthorizationResult`. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization status-only results are discriminated values` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gc3RhdHVzLW9ubHkgcmVzdWx0cyBhcmUgZGlzY3JpbWluYXRlZCB2YWx1ZXM — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization status-only results are discriminated values"` - AC3.5: Iteration and `result()` are mutually exclusive and claim the flow once; a second iterator, a second `result()`, or cross-mode consumption raises `InvalidStateError` without opening or consuming another stream. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization flow is single consumption` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gZmxvdyBpcyBzaW5nbGUgY29uc3VtcHRpb24 — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization flow is single consumption"` ### Scenario 4 — a continuation completes or hands off one chained authorization @@ -209,17 +209,17 @@ ergonomic behavior over gRPC and HTTP. **Acceptance:** - AC4.1: The first post-status event fixes one non-empty `continuationRunId`; every continuation event retains it, and the continuation contains exactly one repeated copy of the original resolved authorization payload before it closes. A changed run ID, missing/duplicate original resolution, second result, or event after a terminal result is `ProtocolError`. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization continuation validates run and repeated resolution grammar` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gY29udGludWF0aW9uIHZhbGlkYXRlcyBydW4gYW5kIHJlcGVhdGVkIHJlc29sdXRpb24gZ3JhbW1hcg — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization continuation validates run and repeated resolution grammar"` - AC4.2: Clean continuation EOF after exactly one terminal `result` returns `outcome: "completed"` with the ordinary `RunResult`; iteration yields every decoded event in wire order, and the lifecycle fabricates no `Run`, attachment, cursor, or successor. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization continuation returns one ordinary completed result` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gY29udGludWF0aW9uIHJldHVybnMgb25lIG9yZGluYXJ5IGNvbXBsZXRlZCByZXN1bHQ — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization continuation returns one ordinary completed result"` - AC4.3: A continuation may instead end with one later `authorization.required` carrying status `pending`, a non-empty call ID, the same continuation run ID, and an authorization ID different from the control's original ID. Clean EOF then returns `outcome: "authorization_required"` with that exact `nextAuthorization`; EOF with neither a result nor this chained park remains `ProtocolError`. - - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts — `MCP authorization continuation hands off a chained authorization` + - verify: vitest:sdk/typescript/test/mcp-authorization.test.ts#TUNQIGF1dGhvcml6YXRpb24gY29udGludWF0aW9uIGhhbmRzIG9mZiBhIGNoYWluZWQgYXV0aG9yaXphdGlvbg — `sdk/typescript/test/mcp-authorization.test.ts :: "MCP authorization continuation hands off a chained authorization"` - AC4.4: `onPermissionAsk` receives only an observed ordinary ask plus a lifecycle-bound signal. Its explicit verdict uses only `permissionRequestOptions`; omission or abstention leaves the ask pending. Manual `resolveAsk()` uses only its own request options. Both address the exact observed ask/run through `RunControls`, while unknown, resolved, retracted, plan-originated, or mismatched asks are never guessed. - - verify: vitest:sdk/typescript/test/mcp-authorization-controls.test.ts — `MCP authorization permission decisions and request options remain application owned` + - verify: vitest:sdk/typescript/test/mcp-authorization-controls.test.ts#TUNQIGF1dGhvcml6YXRpb24gcGVybWlzc2lvbiBkZWNpc2lvbnMgYW5kIHJlcXVlc3Qgb3B0aW9ucyByZW1haW4gYXBwbGljYXRpb24gb3duZWQ — `sdk/typescript/test/mcp-authorization-controls.test.ts :: "MCP authorization permission decisions and request options remain application owned"` - AC4.5: `cancelContinuation()` addresses only the observed continuation run through `RunControls.cancel`; either manual control before its required run/ask is observed fails locally, and absent `prompt_free_controls` fails with the existing typed feature refusal without cancelling or resolving something else. - - verify: vitest:sdk/typescript/test/mcp-authorization-controls.test.ts — `MCP authorization continuation controls are exact run and feature gated` + - verify: vitest:sdk/typescript/test/mcp-authorization-controls.test.ts#TUNQIGF1dGhvcml6YXRpb24gY29udGludWF0aW9uIGNvbnRyb2xzIGFyZSBleGFjdCBydW4gYW5kIGZlYXR1cmUgZ2F0ZWQ — `sdk/typescript/test/mcp-authorization-controls.test.ts :: "MCP authorization continuation controls are exact run and feature gated"` - AC4.6: Stream, manual-control, and automatic-control request options independently preserve headers, callbacks, signals, per-request deadlines, session affinity, client-close state, normalized errors, and no-retry behavior on both transports; a plan-originated ask is yielded but requires an existing separate plan workflow or explicit continuation cancellation. - - verify: vitest:sdk/typescript/test/mcp-authorization-controls.test.ts — `MCP authorization request options and unsupported plan asks stay separated` + - verify: vitest:sdk/typescript/test/mcp-authorization-controls.test.ts#TUNQIGF1dGhvcml6YXRpb24gcmVxdWVzdCBvcHRpb25zIGFuZCB1bnN1cHBvcnRlZCBwbGFuIGFza3Mgc3RheSBzZXBhcmF0ZWQ — `sdk/typescript/test/mcp-authorization-controls.test.ts :: "MCP authorization request options and unsupported plan asks stay separated"` ### Scenario 5 — concurrency, cancellation, and recovery remain explicit @@ -245,15 +245,15 @@ termination fails locally without starting an RPC. **Acceptance:** - AC5.1: Concurrent flows from one or more handles own independent iterators, request controls, pending-ask maps, registrations, and abort lifetimes. Session ownership is server-enforced through each session-affined request. The SDK rejects an authoritative event whose authorization ID differs from the handle, learns its non-empty original call ID from that frame, requires the repeated original resolution to retain both original IDs, pins one non-empty continuation run ID for every continuation frame, and accepts a chained pending authorization only when it has a different authorization ID and its own non-empty call ID. Controls address only observed continuation-run and ask IDs. No flow consumes another flow's iterator or pending ask. - - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts — `concurrent MCP authorization flows cannot cross consume or correlate` + - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts#Y29uY3VycmVudCBNQ1AgYXV0aG9yaXphdGlvbiBmbG93cyBjYW5ub3QgY3Jvc3MgY29uc3VtZSBvciBjb3JyZWxhdGU — `sdk/typescript/test/mcp-authorization-recovery.test.ts :: "concurrent MCP authorization flows cannot cross consume or correlate"` - AC5.2: Valid EOF, caller signal, deadline, iterator return while `next()` is pending, transport loss, and client close follow the termination matrix exactly. Every path aborts pending responders and flow-owned automatic controls, suppresses late verdicts, rejects post-terminal `resolveAsk()` and `cancelContinuation()` locally without another RPC, preserves the caller-owned lifetime of a manual control admitted before termination, releases registration and transport resources once, and performs no automatic recheck, mutation replay, browser action, credential persistence, or claim about committed server state. - - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts — `MCP authorization flow cancellation releases only SDK owned resources` + - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts#TUNQIGF1dGhvcml6YXRpb24gZmxvdyBjYW5jZWxsYXRpb24gcmVsZWFzZXMgb25seSBTREsgb3duZWQgcmVzb3VyY2Vz — `sdk/typescript/test/mcp-authorization-recovery.test.ts :: "MCP authorization flow cancellation releases only SDK owned resources"` - AC5.3: A fresh recheck after loss is a new one-shot mutation, not replay or guaranteed recovery: it can proceed only while the same authorization remains pending. If the lost control committed and cleared pending state before the caller observed its status or continuation ID, the server's not-found refusal is preserved and this lifecycle alone cannot reconstruct the lost outcome. - - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts — `MCP authorization recovery never overpromises replay` + - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts#TUNQIGF1dGhvcml6YXRpb24gcmVjb3ZlcnkgbmV2ZXIgb3ZlcnByb21pc2VzIHJlcGxheQ — `sdk/typescript/test/mcp-authorization-recovery.test.ts :: "MCP authorization recovery never overpromises replay"` - AC5.4: A caller that observed `continuationRunId`, or a deployment retaining suitable session activity, may explicitly inspect activity and attach to a still-observable run. After disconnect, exact-run attachment treats a replayed `authorization.required` as a valid park only when it carries the exact attached run ID, `pending` status, and non-empty authorization and call IDs. Attachment yields and checkpoints that event, marks `live` false, clears pending asks, and closes without waiting for a nonexistent `result`; session-wide activity remains open. The lifecycle itself never opens a durable watch, scans activity, reconnects, or guarantees event-log retention. - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts — `disconnect attach replays chained authorization park as terminal` - AC5.5: Real-server tests pin phase-specific disconnect behavior: gRPC detaches and drains ordinary continuation work but cancels a run stranded on an ordinary permission ask; HTTP requests cancellation for a still-active continuation and drains it; neither path destroys a follow-up authorization after its `authorization.required` park has committed, and terminal races remain server-authoritative. - - verify: vitest:sdk/typescript/e2e/mcp-authorization.e2e.test.ts — `MCP authorization disconnect follows transport and park phase` + - verify: vitest:sdk/typescript/e2e/mcp-authorization.e2e.test.ts#TUNQIGF1dGhvcml6YXRpb24gZGlzY29ubmVjdCBmb2xsb3dzIHRyYW5zcG9ydCBhbmQgcGFyayBwaGFzZQ — `sdk/typescript/e2e/mcp-authorization.e2e.test.ts :: "MCP authorization disconnect follows transport and park phase"` ### Scenario 6 — public and real-wire coverage makes the workflow usable @@ -262,11 +262,11 @@ and must work against the same-checkout daemon, not only injected transports. **Acceptance:** - AC6.1: The HTTP RPC catalog sends no body for recheck/cancel, declares response field `event`, and the generic HTTP SSE decoder wraps each bare server event for both descriptors. Injected plus real-wire gRPC TCP, gRPC UDS, and HTTP/SSE tests cover initial Run parking, presentation, pending recheck, granted/denied/cancelled resolution, completed and chained continuations, permission allow/deny, explicit continuation cancellation, request options, typed errors, and exact affinity with equivalent high-level results where server semantics coincide. - - verify: vitest:sdk/typescript/e2e/mcp-authorization.e2e.test.ts — `MCP authorization works over gRPC TCP UDS and HTTP SSE` + - verify: vitest:sdk/typescript/e2e/mcp-authorization.e2e.test.ts#TUNQIGF1dGhvcml6YXRpb24gd29ya3Mgb3ZlciBnUlBDIFRDUCBVRFMgYW5kIEhUVFAgU1NF — `sdk/typescript/e2e/mcp-authorization.e2e.test.ts :: "MCP authorization works over gRPC TCP UDS and HTTP SSE"` - AC6.2: Root, Node, and Deno declarations export the exact interface contract; API Extractor reports, package tests, the generated SDK reference, and the runtime import matrix prevent an entry point or type from drifting. - - verify: vitest:sdk/typescript/test/package.test.ts — `MCP authorization lifecycle is exported documented and API reviewed` + - verify: vitest:sdk/typescript/test/package.test.ts#TUNQIGF1dGhvcml6YXRpb24gbGlmZWN5Y2xlIGlzIGV4cG9ydGVkIGRvY3VtZW50ZWQgYW5kIEFQSSByZXZpZXdlZA — `sdk/typescript/test/package.test.ts :: "MCP authorization lifecycle is exported documented and API reviewed"` - AC6.3: A concise package-export-only example shows initial Run handoff, application-owned URL handling, explicit recheck cadence, permission response, all discriminated results, chained authorization, and bounded recovery without opening a browser or copying mecatui policy. - - verify: vitest:sdk/typescript/test/examples.test.ts — `MCP authorization example uses only the public lifecycle` + - verify: vitest:sdk/typescript/test/examples.test.ts#TUNQIGF1dGhvcml6YXRpb24gZXhhbXBsZSB1c2VzIG9ubHkgdGhlIHB1YmxpYyBsaWZlY3ljbGU — `sdk/typescript/test/examples.test.ts :: "MCP authorization example uses only the public lifecycle"` - AC6.4: TSDoc, TypeScript SDK permissions/sessions guidance, architecture, implementation notes, generated reference, and the automated SDK changelog describe ownership, Run parking, states, single consumption, lazy dispatch, affinity, request cancellation, no-retry polling, recovery limits, phase-specific HTTP/gRPC disconnect behavior, permission authority, and URL secrecy. - verify: inspection — public documentation and generated API/changelog artifacts are content/build outputs rather than runtime behavior From d6cbf89046247963e640f556be0b0270a637759d Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 01:15:43 +0200 Subject: [PATCH 09/15] fix(sdk): bind authorization control lifetimes Signed-off-by: Samuele Verzi --- sdk/typescript/src/mcp-authorization.ts | 22 ++++++- .../test/mcp-authorization-control-fixture.ts | 8 ++- .../test/mcp-authorization-controls.test.ts | 15 ++++- .../test/mcp-authorization-recovery.test.ts | 65 ++++++++++++++++++- sdk/typescript/test/mcp-authorization.test.ts | 8 +++ 5 files changed, 111 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index b6fe55320d..3993ada817 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -528,7 +528,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { askId, verdict, pending, - this.#flowOptions.permissionRequestOptions, + this.#automaticControlOptions(this.#flowOptions.permissionRequestOptions), ); } catch (error) { if (!this.#ended) this.#rejectControlFailure(error); @@ -558,6 +558,20 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { await controls.resolveAsk(askId, verdict, requestOptions); } + #automaticControlOptions(requestOptions: RequestOptions | undefined): RequestOptions { + const lifetimeSignal = this.#abort?.signal; + if (lifetimeSignal === undefined) { + throw this.#protocol("The authorization flow has no active request lifetime"); + } + return { + ...requestOptions, + signal: + requestOptions?.signal === undefined + ? lifetimeSignal + : AbortSignal.any([requestOptions.signal, lifetimeSignal]), + }; + } + #retireAsk(askId: string): void { const pending = this.#pendingAsks.get(askId); if (pending === undefined) return; @@ -581,6 +595,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { } const status = event.payload.status; if ( + event.payload.authorizationId === "" || event.payload.authorizationId !== this.authorizationId || event.payload.callId === "" || !authorizationStatuses.has(status as McpAuthorizationStatus) @@ -770,5 +785,10 @@ export function createMcpAuthorization( authorizationId: string, operations: McpAuthorizationOperations, ): McpAuthorization { + if (authorizationId === "") { + throw new InvalidStateError("The MCP authorization ID must be non-empty", { + transport: "local", + }); + } return new McpAuthorizationImpl(sessionId, authorizationId, operations); } diff --git a/sdk/typescript/test/mcp-authorization-control-fixture.ts b/sdk/typescript/test/mcp-authorization-control-fixture.ts index 938549a130..db3e9e987f 100644 --- a/sdk/typescript/test/mcp-authorization-control-fixture.ts +++ b/sdk/typescript/test/mcp-authorization-control-fixture.ts @@ -32,7 +32,11 @@ export interface StreamPlan { export interface HarnessOptions { readonly features?: readonly string[]; readonly streams?: readonly StreamPlan[]; - readonly unary?: (method: string, input: Record) => unknown; + readonly unary?: ( + method: string, + input: Record, + signal: AbortSignal | undefined, + ) => unknown | Promise; } export class LifecycleTransport implements Transport { @@ -65,7 +69,7 @@ export class LifecycleTransport implements Transport { signal, timeoutMs, }); - const supplied = this.#unary?.(method.name, input as Record); + const supplied = await this.#unary?.(method.name, input as Record, signal); if (supplied instanceof Error) throw supplied; let value: unknown = supplied; if (value === undefined) { diff --git a/sdk/typescript/test/mcp-authorization-controls.test.ts b/sdk/typescript/test/mcp-authorization-controls.test.ts index 24264affdf..165fe29e0e 100644 --- a/sdk/typescript/test/mcp-authorization-controls.test.ts +++ b/sdk/typescript/test/mcp-authorization-controls.test.ts @@ -28,6 +28,9 @@ describe("MCP authorization continuation controls", () => { streams: [{ events: continuation(ask("ask-auto"), result()) }], }); const seen: string[] = []; + const automaticController = new AbortController(); + const automaticHeaders: string[] = []; + const automaticTrailers: string[] = []; const flow = automatic.session.mcpAuthorization(authorizationId).recheck( { onPermissionAsk: async (permission, signal) => { @@ -35,7 +38,13 @@ describe("MCP authorization continuation controls", () => { seen.push(permission.askId); return "allow_once" as const; }, - permissionRequestOptions: { headers: { "x-authority": "automatic" }, timeoutMs: 91 }, + permissionRequestOptions: { + headers: { "x-authority": "automatic" }, + onHeader: (headers) => automaticHeaders.push(headers.get("x-fixture-response") ?? ""), + onTrailer: (headers) => automaticTrailers.push(headers.get("x-fixture-trailer") ?? ""), + signal: automaticController.signal, + timeoutMs: 91, + }, }, { headers: { "x-authority": "stream" }, timeoutMs: 90 }, ); @@ -51,6 +60,10 @@ describe("MCP authorization continuation controls", () => { timeoutMs: 91, }); expect(automaticCall?.headers.get("x-authority")).toBe("automatic"); + expect(automaticHeaders).toEqual(["ResolveRunAsk"]); + expect(automaticTrailers).toEqual(["ResolveRunAsk"]); + automaticController.abort(new Error("caller finished")); + expect(automaticCall?.signal?.aborted).toBe(true); const manual = await harness({ streams: [ diff --git a/sdk/typescript/test/mcp-authorization-recovery.test.ts b/sdk/typescript/test/mcp-authorization-recovery.test.ts index 38b8e6a5ce..59810a2b92 100644 --- a/sdk/typescript/test/mcp-authorization-recovery.test.ts +++ b/sdk/typescript/test/mcp-authorization-recovery.test.ts @@ -47,12 +47,71 @@ describe("MCP authorization recovery boundaries", () => { }); it("MCP authorization flow cancellation releases only SDK owned resources", async () => { - const returned = await harness({ streams: [{ events: continuation(), hold: true }] }); - const returnedFlow = returned.session.mcpAuthorization(authorizationId).recheck(); + let markControlStarted: () => void = () => undefined; + const controlStarted = new Promise((resolve) => { + markControlStarted = resolve; + }); + let markControlAborted: () => void = () => undefined; + const controlAborted = new Promise((resolve) => { + markControlAborted = resolve; + }); + const returned = await harness({ + streams: [{ events: continuation(ask("ask-auto")), hold: true }], + unary: (method, _input, signal) => { + if (method !== "ResolveRunAsk") return undefined; + markControlStarted(); + return new Promise((_resolve, reject) => { + const abort = () => { + markControlAborted(); + reject(signal?.reason ?? new Error("automatic control aborted")); + }; + if (signal?.aborted === true) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }, + }); + const permissionController = new AbortController(); + const streamController = new AbortController(); + const returnedFlow = returned.session.mcpAuthorization(authorizationId).recheck( + { + onPermissionAsk: () => "allow_once", + permissionRequestOptions: { + headers: { "x-lifetime": "automatic-control" }, + signal: permissionController.signal, + timeoutMs: 501, + }, + }, + { + headers: { "x-lifetime": "flow-stream" }, + signal: streamController.signal, + timeoutMs: 500, + }, + ); const iterator = returnedFlow[Symbol.asyncIterator](); await iterator.next(); await iterator.next(); - await iterator.return?.(); + await iterator.next(); + await controlStarted; + const streamCall = returned.transport.calls.find( + (call) => call.method === "RecheckMcpAuthorization", + ); + const controlCall = returned.transport.calls.find((call) => call.method === "ResolveRunAsk"); + expect(streamCall?.headers.get("x-lifetime")).toBe("flow-stream"); + expect(streamCall?.timeoutMs).toBe(500); + expect(controlCall?.headers.get("x-lifetime")).toBe("automatic-control"); + expect(controlCall?.timeoutMs).toBe(501); + expect(controlCall?.signal?.aborted).toBe(false); + expect(controlCall?.signal).not.toBe(streamCall?.signal); + + const returnedResult = iterator.return?.(); + const controlAbortedWithFlow = controlCall?.signal?.aborted === true; + const permissionCallerStayedLive = !permissionController.signal.aborted; + if (!controlAbortedWithFlow) permissionController.abort(new Error("test cleanup")); + await controlAborted; + await returnedResult; + expect(controlAbortedWithFlow).toBe(true); + expect(permissionCallerStayedLive).toBe(true); + expect(streamController.signal.aborted).toBe(false); expect(returned.transport.activeStreams).toBe(0); expect(returned.transport.closedStreams).toBe(1); expect( diff --git a/sdk/typescript/test/mcp-authorization.test.ts b/sdk/typescript/test/mcp-authorization.test.ts index 099f8e1f50..0153367b07 100644 --- a/sdk/typescript/test/mcp-authorization.test.ts +++ b/sdk/typescript/test/mcp-authorization.test.ts @@ -243,6 +243,14 @@ describe("MCP authorization lifecycle", () => { const handle = session.mcpAuthorization(authorizationId); expect(handle).toMatchObject({ authorizationId, sessionId }); + let emptyIdError: unknown; + try { + session.mcpAuthorization(""); + } catch (error) { + emptyIdError = error; + } + expect(emptyIdError).toBeInstanceOf(InvalidStateError); + expect(emptyIdError).toMatchObject({ code: "invalid_state", transport: "local" }); expect(transport.calls).toHaveLength(before); expectTypeOf(handle).toEqualTypeOf(); await client.close(); From cf14f107647cc466c7df2769d5ae0f883bf3cb67 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 01:42:11 +0200 Subject: [PATCH 10/15] fix(sdk): end paused authorization lifetimes Co-authored-by: Codex Signed-off-by: Samuele Verzi --- sdk/typescript/src/mcp-authorization.ts | 39 +++- .../test/mcp-authorization-recovery.test.ts | 190 +++++++++++------- 2 files changed, 154 insertions(+), 75 deletions(-) diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index 3993ada817..60b39d739a 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -5,7 +5,7 @@ import type { MessageInitShape, MessageShape, } from "@bufbuild/protobuf"; -import type { CallOptions } from "@connectrpc/connect"; +import { type CallOptions, Code, ConnectError } from "@connectrpc/connect"; import { InvalidStateError, normalizeError, ProtocolError, type TransportKind } from "./errors.js"; import { decodeEvent, type Event, type EventOf } from "./events.js"; @@ -275,6 +275,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { #ended = false; #events: AsyncIterator | undefined; #input: AuthorizationInput | undefined; + #releaseRequestLifetime: (() => void) | undefined; #nextAuthorization: EventOf<"authorization.required"> | undefined; readonly #knownAsks = new Set(); readonly #pendingAsks = new Map(); @@ -384,8 +385,11 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { } const abort = new AbortController(); - const signal = - callerSignal === undefined ? abort.signal : AbortSignal.any([callerSignal, abort.signal]); + this.#abort = abort; + this.#bindRequestLifetime(abort, callerSignal, this.#requestOptions?.timeoutMs); + if (abort.signal.aborted) { + throw normalizeError(abort.signal.reason, this.#operations.transportKind); + } const input = new AuthorizationInput({ authorizationId: this.authorizationId, sessionId: this.sessionId, @@ -397,9 +401,8 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { const stream = this.#operations.stream( method as typeof HarnessService.method.recheckMcpAuthorization, input, - { ...this.#requestOptions, signal }, + { ...this.#requestOptions, signal: abort.signal }, ); - this.#abort = abort; this.#input = input; this.#events = stream[Symbol.asyncIterator]() as AsyncIterator; let released = false; @@ -412,6 +415,31 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { unregister = this.#operations.registerRun(() => this.#close()); } + #bindRequestLifetime( + abort: AbortController, + callerSignal: AbortSignal | undefined, + timeoutMs: number | undefined, + ): void { + let deadline: ReturnType | undefined; + const expire = (reason: unknown) => { + if (abort.signal.aborted) return; + abort.abort(reason); + void this.#close(); + }; + const callerAbort = () => expire(callerSignal?.reason); + callerSignal?.addEventListener("abort", callerAbort, { once: true }); + this.#releaseRequestLifetime = () => { + callerSignal?.removeEventListener("abort", callerAbort); + if (deadline !== undefined) clearTimeout(deadline); + this.#releaseRequestLifetime = undefined; + }; + if (timeoutMs === undefined) return; + const deadlineExceeded = () => + expire(new ConnectError("the operation timed out", Code.DeadlineExceeded)); + if (timeoutMs <= 0) deadlineExceeded(); + else deadline = setTimeout(deadlineExceeded, timeoutMs); + } + async #next(): Promise> { this.#operations.assertOpen(); if (this.#ended) return { done: true, value: undefined }; @@ -669,6 +697,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { async #close(): Promise { if (this.#ended) return; this.#ended = true; + this.#releaseRequestLifetime?.(); this.#retireAllAsks(); this.#abort?.abort(); this.#input?.close(); diff --git a/sdk/typescript/test/mcp-authorization-recovery.test.ts b/sdk/typescript/test/mcp-authorization-recovery.test.ts index 59810a2b92..d3f5554970 100644 --- a/sdk/typescript/test/mcp-authorization-recovery.test.ts +++ b/sdk/typescript/test/mcp-authorization-recovery.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { InvalidStateError, ProtocolError, ServerError } from "../src/index.js"; import { @@ -14,6 +14,10 @@ import { } from "./mcp-authorization-control-fixture.js"; describe("MCP authorization recovery boundaries", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("concurrent MCP authorization flows cannot cross consume or correlate", async () => { const instance = await harness({ streams: [ @@ -47,77 +51,124 @@ describe("MCP authorization recovery boundaries", () => { }); it("MCP authorization flow cancellation releases only SDK owned resources", async () => { - let markControlStarted: () => void = () => undefined; - const controlStarted = new Promise((resolve) => { - markControlStarted = resolve; + vi.useFakeTimers(); + + async function pausedFlowLifetime( + trigger: (context: { + readonly controller: AbortController; + readonly iterator: AsyncIterator; + }) => Promise, + ) { + let releaseLateVerdict: (verdict: "deny") => void = () => undefined; + const lateVerdict = new Promise<"deny">((resolve) => { + releaseLateVerdict = resolve; + }); + let lateResponderSignal: AbortSignal | undefined; + let markControlStarted: () => void = () => undefined; + const controlStarted = new Promise((resolve) => { + markControlStarted = resolve; + }); + let markControlAborted: () => void = () => undefined; + const controlAborted = new Promise((resolve) => { + markControlAborted = resolve; + }); + const instance = await harness({ + streams: [{ events: continuation(ask("ask-late"), ask("ask-auto")), hold: true }], + unary: (method, _input, signal) => { + if (method !== "ResolveRunAsk") return undefined; + markControlStarted(); + return new Promise((_resolve, reject) => { + const abort = () => { + markControlAborted(); + reject(signal?.reason ?? new Error("automatic control aborted")); + }; + if (signal?.aborted === true) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }, + }); + const permissionController = new AbortController(); + const streamController = new AbortController(); + const flow = instance.session.mcpAuthorization(authorizationId).recheck( + { + onPermissionAsk: (permission, signal) => { + if (permission.askId === "ask-late") { + lateResponderSignal = signal; + return lateVerdict; + } + return "allow_once"; + }, + permissionRequestOptions: { + headers: { "x-lifetime": "automatic-control" }, + signal: permissionController.signal, + timeoutMs: 501, + }, + }, + { + headers: { "x-lifetime": "flow-stream" }, + signal: streamController.signal, + timeoutMs: 500, + }, + ); + const iterator = flow[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); + await iterator.next(); + await iterator.next(); + await controlStarted; + const streamCall = instance.transport.calls.find( + (call) => call.method === "RecheckMcpAuthorization", + ); + const controlCall = instance.transport.calls.find((call) => call.method === "ResolveRunAsk"); + expect(streamCall?.headers.get("x-lifetime")).toBe("flow-stream"); + expect(streamCall?.timeoutMs).toBe(500); + expect(controlCall?.headers.get("x-lifetime")).toBe("automatic-control"); + expect(controlCall?.timeoutMs).toBe(501); + expect(controlCall?.signal?.aborted).toBe(false); + expect(lateResponderSignal?.aborted).toBe(false); + expect(controlCall?.signal).not.toBe(streamCall?.signal); + + await trigger({ controller: streamController, iterator }); + const controlAbortedWithFlow = controlCall?.signal?.aborted === true; + const responderAbortedWithFlow = lateResponderSignal?.aborted === true; + const permissionCallerStayedLive = !permissionController.signal.aborted; + if (!controlAbortedWithFlow) permissionController.abort(new Error("test cleanup")); + if (!responderAbortedWithFlow) await iterator.return?.(); + await controlAborted; + releaseLateVerdict("deny"); + await Promise.resolve(); + await Promise.resolve(); + + expect(controlAbortedWithFlow).toBe(true); + expect(responderAbortedWithFlow).toBe(true); + expect(permissionCallerStayedLive).toBe(true); + expect(instance.transport.activeStreams).toBe(0); + expect(instance.transport.closedStreams).toBe(1); + expect( + instance.transport.calls.filter((call) => call.method === "ResolveRunAsk"), + ).toHaveLength(1); + expect( + instance.transport.calls.filter((call) => call.method === "RecheckMcpAuthorization"), + ).toHaveLength(1); + expect(instance.transport.calls.some((call) => call.method === "CancelRun")).toBe(false); + await instance.client.close(); + } + + await pausedFlowLifetime(async ({ controller }) => { + controller.abort(new Error("caller cancelled while consumption was paused")); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(0); }); - let markControlAborted: () => void = () => undefined; - const controlAborted = new Promise((resolve) => { - markControlAborted = resolve; + await pausedFlowLifetime(async ({ controller }) => { + await vi.advanceTimersByTimeAsync(500); + expect(controller.signal.aborted).toBe(false); + expect(vi.getTimerCount()).toBe(0); }); - const returned = await harness({ - streams: [{ events: continuation(ask("ask-auto")), hold: true }], - unary: (method, _input, signal) => { - if (method !== "ResolveRunAsk") return undefined; - markControlStarted(); - return new Promise((_resolve, reject) => { - const abort = () => { - markControlAborted(); - reject(signal?.reason ?? new Error("automatic control aborted")); - }; - if (signal?.aborted === true) abort(); - else signal?.addEventListener("abort", abort, { once: true }); - }); - }, + await pausedFlowLifetime(async ({ controller, iterator }) => { + await iterator.return?.(); + expect(controller.signal.aborted).toBe(false); + expect(vi.getTimerCount()).toBe(0); }); - const permissionController = new AbortController(); - const streamController = new AbortController(); - const returnedFlow = returned.session.mcpAuthorization(authorizationId).recheck( - { - onPermissionAsk: () => "allow_once", - permissionRequestOptions: { - headers: { "x-lifetime": "automatic-control" }, - signal: permissionController.signal, - timeoutMs: 501, - }, - }, - { - headers: { "x-lifetime": "flow-stream" }, - signal: streamController.signal, - timeoutMs: 500, - }, - ); - const iterator = returnedFlow[Symbol.asyncIterator](); - await iterator.next(); - await iterator.next(); - await iterator.next(); - await controlStarted; - const streamCall = returned.transport.calls.find( - (call) => call.method === "RecheckMcpAuthorization", - ); - const controlCall = returned.transport.calls.find((call) => call.method === "ResolveRunAsk"); - expect(streamCall?.headers.get("x-lifetime")).toBe("flow-stream"); - expect(streamCall?.timeoutMs).toBe(500); - expect(controlCall?.headers.get("x-lifetime")).toBe("automatic-control"); - expect(controlCall?.timeoutMs).toBe(501); - expect(controlCall?.signal?.aborted).toBe(false); - expect(controlCall?.signal).not.toBe(streamCall?.signal); - - const returnedResult = iterator.return?.(); - const controlAbortedWithFlow = controlCall?.signal?.aborted === true; - const permissionCallerStayedLive = !permissionController.signal.aborted; - if (!controlAbortedWithFlow) permissionController.abort(new Error("test cleanup")); - await controlAborted; - await returnedResult; - expect(controlAbortedWithFlow).toBe(true); - expect(permissionCallerStayedLive).toBe(true); - expect(streamController.signal.aborted).toBe(false); - expect(returned.transport.activeStreams).toBe(0); - expect(returned.transport.closedStreams).toBe(1); - expect( - returned.transport.calls.filter((call) => call.method === "RecheckMcpAuthorization"), - ).toHaveLength(1); - expect(returned.transport.calls.some((call) => call.method === "CancelRun")).toBe(false); const lost = await harness({ streams: [{ error: new Error("wire lost") }] }); await expect( @@ -133,7 +184,6 @@ describe("MCP authorization recovery boundaries", () => { await closed.client.close(); await expect(closeResult).rejects.toBeDefined(); expect(closed.transport.activeStreams).toBe(0); - await returned.client.close(); await lost.client.close(); }); From 5bf616fcd0c672e87db6f48fc119ff0ae22f3aa9 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 02:06:03 +0200 Subject: [PATCH 11/15] fix(sdk): preserve authorization abort outcomes Co-authored-by: Codex Signed-off-by: Samuele Verzi --- sdk/typescript/src/mcp-authorization.ts | 21 +++++++++++-- .../test/mcp-authorization-recovery.test.ts | 30 +++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index 60b39d739a..af2ea53084 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -7,7 +7,13 @@ import type { } from "@bufbuild/protobuf"; import { type CallOptions, Code, ConnectError } from "@connectrpc/connect"; -import { InvalidStateError, normalizeError, ProtocolError, type TransportKind } from "./errors.js"; +import { + InvalidStateError, + type MecatlError, + normalizeError, + ProtocolError, + type TransportKind, +} from "./errors.js"; import { decodeEvent, type Event, type EventOf } from "./events.js"; import { HarnessService, @@ -283,6 +289,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { #repeatSeen = false; #result: McpAuthorizationResult | undefined; #runControls: RunControls | undefined; + #terminalError: MecatlError | undefined; constructor( sessionId: string, @@ -423,6 +430,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { let deadline: ReturnType | undefined; const expire = (reason: unknown) => { if (abort.signal.aborted) return; + this.#terminalError = normalizeError(reason, this.#operations.transportKind); abort.abort(reason); void this.#close(); }; @@ -442,6 +450,8 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { async #next(): Promise> { this.#operations.assertOpen(); + const terminalError = this.#takeTerminalError(); + if (terminalError !== undefined) throw terminalError; if (this.#ended) return { done: true, value: undefined }; try { await this.#start(); @@ -454,11 +464,18 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { this.#observe(event); return { done: false, value: event }; } catch (error) { + const retainedError = this.#takeTerminalError(); await this.#close(); - throw error; + throw retainedError ?? error; } } + #takeTerminalError(): MecatlError | undefined { + const error = this.#terminalError; + this.#terminalError = undefined; + return error; + } + #observe(event: Event): void { if (this.#authorization === undefined) { this.#authorization = this.#validateAuthoritative(event); diff --git a/sdk/typescript/test/mcp-authorization-recovery.test.ts b/sdk/typescript/test/mcp-authorization-recovery.test.ts index d3f5554970..f0689e4536 100644 --- a/sdk/typescript/test/mcp-authorization-recovery.test.ts +++ b/sdk/typescript/test/mcp-authorization-recovery.test.ts @@ -1,6 +1,7 @@ +import { Code } from "@connectrpc/connect"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { InvalidStateError, ProtocolError, ServerError } from "../src/index.js"; +import { InvalidStateError, ProtocolError, ServerError, TransportError } from "../src/index.js"; import { ask, authorization, @@ -154,18 +155,37 @@ describe("MCP authorization recovery boundaries", () => { await instance.client.close(); } - await pausedFlowLifetime(async ({ controller }) => { - controller.abort(new Error("caller cancelled while consumption was paused")); + await pausedFlowLifetime(async ({ controller, iterator }) => { + const reason = new Error("caller cancelled while consumption was paused"); + controller.abort(reason); await Promise.resolve(); + const failure = await iterator.next().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(TransportError); + expect(failure).toMatchObject({ + cause: reason, + code: "transport", + transport: "grpc", + }); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); expect(vi.getTimerCount()).toBe(0); }); - await pausedFlowLifetime(async ({ controller }) => { + await pausedFlowLifetime(async ({ controller, iterator }) => { await vi.advanceTimersByTimeAsync(500); expect(controller.signal.aborted).toBe(false); + const failure = await iterator.next().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ServerError); + expect(failure).toMatchObject({ + code: "unknown", + status: Code.DeadlineExceeded, + transport: "grpc", + }); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); expect(vi.getTimerCount()).toBe(0); }); await pausedFlowLifetime(async ({ controller, iterator }) => { - await iterator.return?.(); + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }); + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); expect(controller.signal.aborted).toBe(false); expect(vi.getTimerCount()).toBe(0); }); From b39bf85e15cf0c2c26642b57f3b713118224a979 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 12:25:30 +0200 Subject: [PATCH 12/15] fix(sdk): harden authorization recovery lifecycle Signed-off-by: Samuele Verzi --- .../sdk-mcp-authorization-lifecycle.md | 2 +- sdk/typescript/src/mcp-authorization.ts | 46 ++- sdk/typescript/src/watch.ts | 20 +- .../test/mcp-authorization-control-fixture.ts | 13 +- .../test/mcp-authorization-recovery.test.ts | 308 +++++++++++++++++- sdk/typescript/test/run.test.ts | 7 + .../typescript-sdk/permissions-and-plans.md | 6 +- .../reference/typescript-sdk-api/core.md | 2 +- 8 files changed, 378 insertions(+), 26 deletions(-) diff --git a/docs/acceptance/sdk-mcp-authorization-lifecycle.md b/docs/acceptance/sdk-mcp-authorization-lifecycle.md index ebfac4e023..13b2b9db5f 100644 --- a/docs/acceptance/sdk-mcp-authorization-lifecycle.md +++ b/docs/acceptance/sdk-mcp-authorization-lifecycle.md @@ -251,7 +251,7 @@ termination fails locally without starting an RPC. - AC5.3: A fresh recheck after loss is a new one-shot mutation, not replay or guaranteed recovery: it can proceed only while the same authorization remains pending. If the lost control committed and cleared pending state before the caller observed its status or continuation ID, the server's not-found refusal is preserved and this lifecycle alone cannot reconstruct the lost outcome. - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts#TUNQIGF1dGhvcml6YXRpb24gcmVjb3ZlcnkgbmV2ZXIgb3ZlcnByb21pc2VzIHJlcGxheQ — `sdk/typescript/test/mcp-authorization-recovery.test.ts :: "MCP authorization recovery never overpromises replay"` - AC5.4: A caller that observed `continuationRunId`, or a deployment retaining suitable session activity, may explicitly inspect activity and attach to a still-observable run. After disconnect, exact-run attachment treats a replayed `authorization.required` as a valid park only when it carries the exact attached run ID, `pending` status, and non-empty authorization and call IDs. Attachment yields and checkpoints that event, marks `live` false, clears pending asks, and closes without waiting for a nonexistent `result`; session-wide activity remains open. The lifecycle itself never opens a durable watch, scans activity, reconnects, or guarantees event-log retention. - - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts — `disconnect attach replays chained authorization park as terminal` + - verify: vitest:sdk/typescript/test/mcp-authorization-recovery.test.ts#ZGlzY29ubmVjdCBhdHRhY2ggcmVwbGF5cyBjaGFpbmVkIGF1dGhvcml6YXRpb24gcGFyayBhcyB0ZXJtaW5hbA — `sdk/typescript/test/mcp-authorization-recovery.test.ts :: "disconnect attach replays chained authorization park as terminal"` - AC5.5: Real-server tests pin phase-specific disconnect behavior: gRPC detaches and drains ordinary continuation work but cancels a run stranded on an ordinary permission ask; HTTP requests cancellation for a still-active continuation and drains it; neither path destroys a follow-up authorization after its `authorization.required` park has committed, and terminal races remain server-authoritative. - verify: vitest:sdk/typescript/e2e/mcp-authorization.e2e.test.ts#TUNQIGF1dGhvcml6YXRpb24gZGlzY29ubmVjdCBmb2xsb3dzIHRyYW5zcG9ydCBhbmQgcGFyayBwaGFzZQ — `sdk/typescript/e2e/mcp-authorization.e2e.test.ts :: "MCP authorization disconnect follows transport and park phase"` diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index af2ea53084..e92030cbd3 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -209,6 +209,7 @@ export interface McpAuthorizationOperations { type ConsumptionMode = "events" | "result"; type TerminalStatus = Exclude; type PendingAsk = { readonly controller: AbortController; readonly plan: boolean }; +const flowClosed = Symbol("mcp-authorization-flow-closed"); const authorizationStatuses = new Set([ "pending", @@ -273,6 +274,8 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { readonly #requestOptions: RequestOptions | undefined; readonly #controlFailure: Promise; readonly #rejectControlFailure: (error: unknown) => void; + readonly #closed: Promise; + readonly #resolveClosed: () => void; #abort: AbortController | undefined; #authorization: EventOf<"authorization.required"> | EventOf<"authorization.resolved"> | undefined; #consumption: ConsumptionMode | undefined; @@ -311,6 +314,11 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { }); void this.#controlFailure.catch(() => undefined); this.#rejectControlFailure = rejectControlFailure; + let resolveClosed: () => void = () => undefined; + this.#closed = new Promise((resolve) => { + resolveClosed = () => resolve(flowClosed); + }); + this.#resolveClosed = resolveClosed; } get continuationRunId(): string | undefined { @@ -344,7 +352,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { verdict: PermissionVerdict, requestOptions?: RequestOptions, ): Promise { - this.#operations.assertOpen(); + this.#assertManualControlOpen(); const pending = this.#pendingAsks.get(askId); if (pending === undefined) { throw new InvalidStateError( @@ -362,7 +370,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { } async cancelContinuation(requestOptions?: RequestOptions): Promise { - this.#operations.assertOpen(); + this.#assertManualControlOpen(); const controls = this.#runControls; if (controls === undefined) { throw new InvalidStateError("The authorization continuation run has not been observed", { @@ -419,7 +427,15 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { released = true; unregister(); }; - unregister = this.#operations.registerRun(() => this.#close()); + unregister = this.#operations.registerRun(() => { + let reason: unknown; + try { + this.#operations.assertOpen(); + } catch (error) { + reason = error; + } + return this.#close(reason); + }); } #bindRequestLifetime( @@ -449,13 +465,18 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { } async #next(): Promise> { - this.#operations.assertOpen(); const terminalError = this.#takeTerminalError(); if (terminalError !== undefined) throw terminalError; if (this.#ended) return { done: true, value: undefined }; + this.#operations.assertOpen(); try { await this.#start(); - const next = await Promise.race([this.#events?.next(), this.#controlFailure]); + const next = await Promise.race([this.#events?.next(), this.#controlFailure, this.#closed]); + if (next === flowClosed) { + const closeError = this.#takeTerminalError(); + if (closeError !== undefined) throw closeError; + return { done: true, value: undefined }; + } if (next === undefined || next.done) return await this.#finishEOF(); const raw = next.value.event; if (raw === undefined) @@ -476,6 +497,15 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { return error; } + #assertManualControlOpen(): void { + if (this.#ended) { + throw new InvalidStateError("The MCP authorization flow has ended", { + transport: this.#operations.transportKind, + }); + } + this.#operations.assertOpen(); + } + #observe(event: Event): void { if (this.#authorization === undefined) { this.#authorization = this.#validateAuthoritative(event); @@ -711,9 +741,13 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { return { done: true, value: undefined }; } - async #close(): Promise { + async #close(reason?: unknown): Promise { if (this.#ended) return; + if (reason !== undefined) { + this.#terminalError = normalizeError(reason, this.#operations.transportKind); + } this.#ended = true; + this.#resolveClosed(); this.#releaseRequestLifetime?.(); this.#retireAllAsks(); this.#abort?.abort(); diff --git a/sdk/typescript/src/watch.ts b/sdk/typescript/src/watch.ts index 6afab0566b..69eafd725b 100644 --- a/sdk/typescript/src/watch.ts +++ b/sdk/typescript/src/watch.ts @@ -100,7 +100,7 @@ export interface SessionActivity extends AsyncIterable, AsyncDisp /** A durable activity stream bound to one run. @public */ export interface AttachedRun extends SessionActivity { readonly runId: string; - /** True until this attachment observes its run's terminal result. */ + /** True until this attachment observes its run's terminal result or valid authorization park. */ readonly live: boolean; /** * Cancels the attached run using its exact run ID. @@ -347,6 +347,20 @@ function envelopeEvent(envelope: WatchEnvelope): Event | undefined { return envelope.kind === "event" || envelope.kind === "unknown" ? envelope.event : undefined; } +function isAuthorizationPark(event: Event | undefined, runId: string): boolean { + return ( + event?.kind === "authorization.required" && + event.runId === runId && + event.payload.status === "pending" && + event.payload.authorizationId !== "" && + event.payload.callId !== "" + ); +} + +function isAttachedRunTerminal(event: Event | undefined, runId: string): boolean { + return event?.runId === runId && (event.kind === "result" || isAuthorizationPark(event, runId)); +} + async function requireWatchFeature(operations: AttachmentOperations): Promise { const features = await operations.features(); if (!features.has(WATCH_SESSION_EVENTS_FEATURE)) { @@ -588,7 +602,7 @@ class SessionActivityImpl implements SessionActivity { yield envelope; if (envelope.kind === "gap") throw new ActivityGapError(); this.#checkpoint(envelope); - if (this.#runId !== undefined && event?.runId === this.#runId && event.kind === "result") { + if (this.#runId !== undefined && isAttachedRunTerminal(event, this.#runId)) { return; } } @@ -668,7 +682,7 @@ class AttachedRunImpl extends SessionActivityImpl implements AttachedRun { if (event.kind === "permission.retract" || event.kind === "approval") { liveState.pendingAsks.delete(event.payload.askId); } - if (event.kind === "result") { + if (isAttachedRunTerminal(event, runId)) { liveState.value = false; liveState.pendingAsks.clear(); } diff --git a/sdk/typescript/test/mcp-authorization-control-fixture.ts b/sdk/typescript/test/mcp-authorization-control-fixture.ts index db3e9e987f..0c171b7b5c 100644 --- a/sdk/typescript/test/mcp-authorization-control-fixture.ts +++ b/sdk/typescript/test/mcp-authorization-control-fixture.ts @@ -32,6 +32,7 @@ export interface StreamPlan { export interface HarnessOptions { readonly features?: readonly string[]; readonly streams?: readonly StreamPlan[]; + readonly watchStreams?: readonly StreamPlan[]; readonly unary?: ( method: string, input: Record, @@ -43,14 +44,17 @@ export class LifecycleTransport implements Transport { readonly calls: RecordedCall[] = []; activeStreams = 0; closedStreams = 0; + maxActiveStreams = 0; readonly #features: readonly string[]; readonly #streams: StreamPlan[]; readonly #unary: HarnessOptions["unary"]; + readonly #watchStreams: StreamPlan[]; constructor(options: HarnessOptions = {}) { this.#features = options.features ?? ["prompt_free_controls", "watch_session_events"]; this.#streams = [...(options.streams ?? [])]; this.#unary = options.unary; + this.#watchStreams = [...(options.watchStreams ?? [])]; } async unary( @@ -122,13 +126,18 @@ export class LifecycleTransport implements Transport { signal, timeoutMs, }); - const plan = this.#streams.shift() ?? { events: [] }; + const watch = method.name === "WatchSessionEvents"; + const plan = (watch ? this.#watchStreams : this.#streams).shift() ?? { events: [] }; const owner = this; const messages = (async function* () { owner.activeStreams += 1; + owner.maxActiveStreams = Math.max(owner.maxActiveStreams, owner.activeStreams); try { for (const event of plan.events ?? []) { - yield create(method.output, { event } as unknown as MessageInitShape); + yield create( + method.output, + (watch ? event : { event }) as unknown as MessageInitShape, + ); } if (plan.error !== undefined) throw plan.error; if (plan.hold === true) { diff --git a/sdk/typescript/test/mcp-authorization-recovery.test.ts b/sdk/typescript/test/mcp-authorization-recovery.test.ts index f0689e4536..39ef19f223 100644 --- a/sdk/typescript/test/mcp-authorization-recovery.test.ts +++ b/sdk/typescript/test/mcp-authorization-recovery.test.ts @@ -1,6 +1,5 @@ -import { Code } from "@connectrpc/connect"; +import { Code, ConnectError } from "@connectrpc/connect"; import { afterEach, describe, expect, it, vi } from "vitest"; - import { InvalidStateError, ProtocolError, ServerError, TransportError } from "../src/index.js"; import { ask, @@ -20,9 +19,37 @@ describe("MCP authorization recovery boundaries", () => { }); it("concurrent MCP authorization flows cannot cross consume or correlate", async () => { + const peerAuthorizationId = "authorization-peer"; + const peerCallId = "authorization-call-peer"; + const peerRunId = "run-authorization-peer"; const instance = await harness({ streams: [ - { events: continuation(ask("shared"), result()) }, + { + events: continuation( + ask("shared"), + authorization("authorization.required", "pending", { + authorizationId: "authorization-chained", + callId: "authorization-call-chained", + runId: continuationRunId, + }), + ), + hold: true, + }, + { + events: [ + authorization("authorization.resolved", "granted", { + authorizationId: peerAuthorizationId, + callId: peerCallId, + }), + authorization("authorization.resolved", "granted", { + authorizationId: peerAuthorizationId, + callId: peerCallId, + runId: peerRunId, + }), + ask("shared", { runId: peerRunId }), + ], + hold: true, + }, { events: [ authorization("authorization.resolved", "granted", { authorizationId: "other" }), @@ -31,23 +58,71 @@ describe("MCP authorization recovery boundaries", () => { ], }); const first = instance.session.mcpAuthorization(authorizationId).recheck(); - const second = instance.session.mcpAuthorization(authorizationId).recheck(); + const second = instance.session.mcpAuthorization(peerAuthorizationId).recheck(); const firstIterator = first[Symbol.asyncIterator](); const secondIterator = second[Symbol.asyncIterator](); + + const [firstStatus, secondStatus] = await Promise.all([ + firstIterator.next(), + secondIterator.next(), + ]); + expect(firstStatus).toMatchObject({ + done: false, + value: { payload: { authorizationId }, runId: "" }, + }); + expect(secondStatus).toMatchObject({ + done: false, + value: { payload: { authorizationId: peerAuthorizationId }, runId: "" }, + }); + expect(instance.transport.maxActiveStreams).toBe(2); + await firstIterator.next(); await firstIterator.next(); - await firstIterator.next(); - await expect(secondIterator.next()).rejects.toBeInstanceOf(ProtocolError); - await expect(second.resolveAsk("shared", "deny")).rejects.toBeInstanceOf(InvalidStateError); + await secondIterator.next(); + await secondIterator.next(); await expect(first.resolveAsk("shared", "deny")).resolves.toBeUndefined(); - expect(instance.transport.calls.find((call) => call.method === "ResolveRunAsk")?.input).toEqual( + await expect(second.resolveAsk("shared", "allow_once")).resolves.toBeUndefined(); + await expect(firstIterator.next()).resolves.toMatchObject({ + done: false, + value: { + payload: { + authorizationId: "authorization-chained", + callId: "authorization-call-chained", + }, + runId: continuationRunId, + }, + }); + expect( + instance.transport.calls + .filter((call) => call.method === "RecheckMcpAuthorization") + .map((call) => call.input), + ).toEqual([ + { authorizationId, sessionId }, + { authorizationId: peerAuthorizationId, sessionId }, + ]); + expect( + instance.transport.calls + .filter((call) => call.method === "ResolveRunAsk") + .map((call) => call.input), + ).toEqual([ { askId: "shared", expectedRunId: continuationRunId, sessionId, verdict: 1, }, - ); + { + askId: "shared", + expectedRunId: peerRunId, + sessionId, + verdict: 2, + }, + ]); + await firstIterator.return?.(); + await secondIterator.return?.(); + + const mismatched = instance.session.mcpAuthorization(authorizationId).recheck(); + await expect(mismatched.result()).rejects.toBeInstanceOf(ProtocolError); await instance.client.close(); }); @@ -183,13 +258,24 @@ describe("MCP authorization recovery boundaries", () => { expect(vi.getTimerCount()).toBe(0); }); await pausedFlowLifetime(async ({ controller, iterator }) => { - await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }); + const pendingRead = iterator.next(); + await Promise.resolve(); + const returned = iterator.return?.(); + await expect(returned).resolves.toEqual({ done: true, value: undefined }); + await expect(pendingRead).resolves.toEqual({ done: true, value: undefined }); await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }); await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); expect(controller.signal.aborted).toBe(false); expect(vi.getTimerCount()).toBe(0); }); + const supplied = new TransportError("caller-owned cancellation", { transport: "local" }); + await pausedFlowLifetime(async ({ controller, iterator }) => { + controller.abort(supplied); + await expect(iterator.next()).rejects.toBe(supplied); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + }); + const lost = await harness({ streams: [{ error: new Error("wire lost") }] }); await expect( lost.session.mcpAuthorization(authorizationId).recheck().result(), @@ -199,11 +285,84 @@ describe("MCP authorization recovery boundaries", () => { ).toHaveLength(1); const closed = await harness({ streams: [{ events: continuation(), hold: true }] }); - const closeResult = closed.session.mcpAuthorization(authorizationId).recheck().result(); + const closedFlow = closed.session.mcpAuthorization(authorizationId).recheck(); + const closeResult = closedFlow.result(); await Promise.resolve(); await closed.client.close(); - await expect(closeResult).rejects.toBeDefined(); + await expect(closeResult).rejects.toBeInstanceOf(InvalidStateError); + const closedCalls = closed.transport.calls.length; + await expect(closedFlow.cancelContinuation()).rejects.toBeInstanceOf(InvalidStateError); + await expect(closedFlow.resolveAsk("unknown", "deny")).rejects.toBeInstanceOf( + InvalidStateError, + ); + expect(closed.transport.calls).toHaveLength(closedCalls); expect(closed.transport.activeStreams).toBe(0); + expect(closed.transport.closedStreams).toBe(1); + + const completed = await harness({ streams: [{ events: continuation(result()) }] }); + const completedFlow = completed.session.mcpAuthorization(authorizationId).recheck(); + await expect(completedFlow.result()).resolves.toMatchObject({ + continuationRunId, + outcome: "completed", + }); + const completedCalls = completed.transport.calls.length; + await expect(completedFlow.cancelContinuation()).rejects.toBeInstanceOf(InvalidStateError); + await expect(completedFlow.resolveAsk("unknown", "deny")).rejects.toBeInstanceOf( + InvalidStateError, + ); + expect(completed.transport.calls).toHaveLength(completedCalls); + expect(completed.transport.closedStreams).toBe(1); + + const statusOnly = await harness({ + streams: [{ events: [authorization("authorization.required", "pending")] }], + }); + const statusOnlyFlow = statusOnly.session.mcpAuthorization(authorizationId).recheck(); + const statusOnlyIterator = statusOnlyFlow[Symbol.asyncIterator](); + await expect(statusOnlyIterator.next()).resolves.toMatchObject({ + done: false, + value: { kind: "authorization.required" }, + }); + await expect(statusOnlyIterator.next()).resolves.toEqual({ done: true, value: undefined }); + await expect(statusOnlyIterator.next()).resolves.toEqual({ done: true, value: undefined }); + await expect(statusOnlyIterator.return?.()).resolves.toEqual({ + done: true, + value: undefined, + }); + + let releaseManual: () => void = () => undefined; + let manualStarted: () => void = () => undefined; + const manualStart = new Promise((resolve) => { + manualStarted = resolve; + }); + const manualResponse = new Promise((resolve) => { + releaseManual = resolve; + }); + let admittedSignal: AbortSignal | undefined; + const admitted = await harness({ + streams: [{ events: continuation(ask("manual")), hold: true }], + unary: async (method, _input, signal) => { + if (method !== "ResolveRunAsk") return undefined; + admittedSignal = signal; + manualStarted(); + await manualResponse; + return { askId: "manual", runId: continuationRunId }; + }, + }); + const admittedFlow = admitted.session.mcpAuthorization(authorizationId).recheck(); + const admittedIterator = admittedFlow[Symbol.asyncIterator](); + await admittedIterator.next(); + await admittedIterator.next(); + await admittedIterator.next(); + const manualControl = admittedFlow.resolveAsk("manual", "deny"); + await manualStart; + await admittedIterator.return?.(); + expect(admittedSignal?.aborted).toBe(false); + releaseManual(); + await expect(manualControl).resolves.toBeUndefined(); + expect(admitted.transport.closedStreams).toBe(1); + await admitted.client.close(); + await statusOnly.client.close(); + await completed.client.close(); await lost.client.close(); }); @@ -254,4 +413,129 @@ describe("MCP authorization recovery boundaries", () => { await iterator.return?.(); await instance.client.close(); }); + + it("disconnect attach replays chained authorization park as terminal", async () => { + const nextAuthorizationId = "authorization-after-disconnect"; + const instance = await harness({ + streams: [ + { + error: new ConnectError("authorization response disconnected", Code.Unavailable), + events: [ + authorization("authorization.resolved", "granted"), + authorization("authorization.resolved", "granted", { + runId: continuationRunId, + }), + ], + }, + ], + watchStreams: [ + { events: [{ cursor: "activity-boundary", phase: "live" }], hold: true }, + { + events: [ + { + cursor: "wrong-run", + event: authorization("authorization.required", "pending", { + authorizationId: "wrong-run-authorization", + callId: "wrong-run-call", + runId: "wrong-run", + }), + phase: "replay", + }, + { + cursor: "wrong-status", + event: authorization("authorization.required", "granted", { + authorizationId: nextAuthorizationId, + callId: "next-call", + runId: continuationRunId, + }), + phase: "replay", + }, + { + cursor: "empty-authorization", + event: authorization("authorization.required", "pending", { + authorizationId: "", + callId: "next-call", + runId: continuationRunId, + }), + phase: "replay", + }, + { + cursor: "empty-call", + event: authorization("authorization.required", "pending", { + authorizationId: nextAuthorizationId, + callId: "", + runId: continuationRunId, + }), + phase: "replay", + }, + { + cursor: "valid-park", + event: authorization("authorization.required", "pending", { + authorizationId: nextAuthorizationId, + callId: "next-call", + runId: continuationRunId, + }), + phase: "replay", + }, + ], + hold: true, + }, + ], + }); + const flow = instance.session.mcpAuthorization(authorizationId).recheck(); + const flowIterator = flow[Symbol.asyncIterator](); + await flowIterator.next(); + await flowIterator.next(); + await expect(flowIterator.next()).rejects.toBeInstanceOf(TransportError); + expect(flow.continuationRunId).toBe(continuationRunId); + + const activity = await instance.session.activity(); + const activityIterator = activity[Symbol.asyncIterator](); + await expect(activityIterator.next()).resolves.toMatchObject({ + done: false, + value: { kind: "boundary" }, + }); + + const attached = await instance.session.attach(flow.continuationRunId); + const iterator = attached[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { event: { payload: { status: "granted" } } }, + }); + expect(attached.live).toBe(true); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { event: { payload: { authorizationId: "", status: "pending" } } }, + }); + expect(attached.live).toBe(true); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { event: { payload: { callId: "", status: "pending" } } }, + }); + expect(attached.live).toBe(true); + const terminalPark = await iterator.next(); + expect(terminalPark).toMatchObject({ + done: false, + value: { + event: { + payload: { + authorizationId: nextAuthorizationId, + callId: "next-call", + status: "pending", + }, + }, + }, + }); + if (terminalPark.done || terminalPark.value.kind !== "event") { + throw new Error("expected the replayed authorization park"); + } + expect(attached.live).toBe(false); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + expect(attached.cursor).toBe(terminalPark.value.cursor); + expect(instance.transport.activeStreams).toBe(1); + + await activity.close(); + expect(instance.transport.activeStreams).toBe(0); + await instance.client.close(); + }); }); diff --git a/sdk/typescript/test/run.test.ts b/sdk/typescript/test/run.test.ts index 3327924c43..b1930c6095 100644 --- a/sdk/typescript/test/run.test.ts +++ b/sdk/typescript/test/run.test.ts @@ -202,6 +202,13 @@ describe("run choreography", () => { }); await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + const authorization = session.mcpAuthorization("authorization-4"); + expect(authorization).toMatchObject({ + authorizationId: "authorization-4", + sessionId: "session-release", + }); + expect(sequence).toBe(4); + const afterPark = await session.run("after park"); await afterPark.outcome(); expect(responseClosed.has("run-5")).toBe(true); diff --git a/user-docs/building/typescript-sdk/permissions-and-plans.md b/user-docs/building/typescript-sdk/permissions-and-plans.md index 4adb367e36..669af10e06 100644 --- a/user-docs/building/typescript-sdk/permissions-and-plans.md +++ b/user-docs/building/typescript-sdk/permissions-and-plans.md @@ -145,7 +145,11 @@ Bound any deliberate retry and let that refusal surface. After observing `continuationRunId`, you can use `session.attach(runId)` or `session.activity()` where the deployment retains the needed activity. The -lifecycle does not search that activity or guarantee retention. +lifecycle does not search that activity or guarantee retention. An exact-run +attachment ends after it replays a pending `authorization.required` event with +the attached run ID and non-empty authorization and call IDs. It yields and +checkpoints that event, then sets `live` to `false`. A session-wide activity +stream remains open. Disconnect effects depend on the continuation phase and transport. gRPC detaches and drains ordinary continuation work, but it cancels a continuation diff --git a/user-docs/reference/typescript-sdk-api/core.md b/user-docs/reference/typescript-sdk-api/core.md index 96eda9c341..a5a9916915 100644 --- a/user-docs/reference/typescript-sdk-api/core.md +++ b/user-docs/reference/typescript-sdk-api/core.md @@ -1019,7 +1019,7 @@ Returns: `Promise`: A promise that resolves after the cancellation request AttachedRun.live -True until this attachment observes its run's terminal result. +True until this attachment observes its run's terminal result or valid authorization park. ```ts readonly live: boolean; From 8330ea67f425adf24b71da04af844533e5e3dbb3 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 12:48:57 +0200 Subject: [PATCH 13/15] docs(sdk): align attachment terminal guidance Co-Authored-By: Codex Signed-off-by: Samuele Verzi --- docs/architecture.md | 19 +++++++++++-------- docs/design/IMPLEMENTATION-NOTES.md | 15 +++++++++------ .../typescript-sdk/permissions-and-plans.md | 7 ++++--- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ab1c75dba9..f7c70580fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -522,8 +522,9 @@ the local `NoRunsError`, including the deliberately documented interval where a already stamped on a running session but has emitted no durable event. Unknown or foreign sessions, unsupported watch deployments, missing logs, and delegation-child ids remain distinct typed server refusals. `AttachedRun.live` reflects events observed -through that attachment and becomes false when its selected run's terminal `result` is -delivered. +through that attachment and becomes false when its selected run delivers either a +terminal `result` or a valid pending `authorization.required` park with exact run +correlation and non-empty authorization and call IDs. `Session.activity()` keeps both the server filter and cursor run binding empty, so one ordered stream spans every run and also includes run-less `schedule.*` records. A run's @@ -536,10 +537,11 @@ its existing immediate typed-gap termination. The attachment is one replay-then-follow operation: it yields the selected run's durable replay in append order, announces the live boundary once, follows new appends, and completes -at that run's terminal `result`. A run that already finished therefore completes from replay -without parking. `attach(runId, { from: "now" })` still opens the ordinary watch with an -empty wire cursor and receives the replay, but discards replay envelopes client-side before -yielding the live boundary; the mode is rejected locally when no explicit run id is supplied. +at either that run's terminal `result` or a valid pending `authorization.required` park. A +run that already reached either terminal therefore completes from replay without following. +`attach(runId, { from: "now" })` still opens the ordinary watch with an empty wire cursor and +receives the replay, but discards replay envelopes client-side before yielding the live +boundary; the mode is rejected locally when no explicit run id is supplied. Ergonomic checkpoints are opaque, serializable `sdkcur/1` strings that wrap the server token with the view's run binding and effective server filter. The SDK validates that envelope and @@ -565,8 +567,9 @@ attachment checkpoint under the same filter. The client invalidates and re-probes cached compatibility before each reconnect, so a replacement daemon's feature set is authoritative on the first attempt. The closed permanent-code set ends the view; ordinary mutations, prompts, permission verdicts, and owned run streams remain one-shot. An -`AttachedRun` stops after its own `result`, while session activity treats every clean EOF as a -reconnect point. Reconnected watches do not re-announce the replay-to-live boundary. An optional +`AttachedRun` stops after its own `result` or valid pending authorization park, while session +activity treats every clean EOF as a reconnect point. Reconnected watches do not re-announce +the replay-to-live boundary. An optional `AttachOptions.signal`, iterator release, explicit disposal, or `Client.close()` aborts backoff and releases the current watch without cancelling the run. diff --git a/docs/design/IMPLEMENTATION-NOTES.md b/docs/design/IMPLEMENTATION-NOTES.md index 16c068d3d7..7ae57e7d66 100644 --- a/docs/design/IMPLEMENTATION-NOTES.md +++ b/docs/design/IMPLEMENTATION-NOTES.md @@ -8944,14 +8944,16 @@ missing advertised `watch_session_events` feature is the existing local `no_event_log`, and delegation-child `invalid_argument` errors pass through the shared server-error normalization unchanged. Scheduled-fire session ids (`sched--*`) are not client-rejected. `AttachedRun.live` is backed by iterator state, not captured at -construction: delivery of that run's decoded `result` flips the getter to false and -ends the attached iterator. +construction: delivery of that run's decoded `result` or a valid pending +`authorization.required` park with exact run correlation and non-empty authorization and +call IDs flips the getter to false and ends the attached iterator. The lifecycle remains one `WatchSessionEvents` request and one iterator in `sdk/typescript/src/watch.ts`: replay envelopes, the replay-to-live boundary, live appends, -and the terminal `result` are consumed in wire order. Encountering that terminal in replay -ends an already-finished attachment immediately; no follow read is requested. `AttachOptions` -adds `from: "start" | "now" | SdkCursor` plus `includeLogOnly`, and +and the terminal `result` or valid pending authorization park are consumed in wire order. +Encountering either terminal in replay ends an already-finished attachment immediately; no +follow read is requested. `AttachOptions` adds `from: "start" | "now" | SdkCursor` plus +`includeLogOnly`, and `Session.activity(options)` accepts the same checkpoint input. The opt-in bypasses only the derived event-kind filter, so it adds records without changing existing order or cursor values. The `now` arm is deliberately a yield-time client filter, not a @@ -9026,7 +9028,8 @@ cannot publish `offline` between a resumable failure and `WatchConnection` takin re-probes still update the request input, and precedence prevents their success from masking a retrying peer. A terminal compatibility floor also updates the request input so the deployment fact survives automatic iterator cleanup until a later successful exchange clears it. Removing the -attachment entry on close cannot cancel a run. +attachment entry on close cannot cancel a run. A valid pending `authorization.required` for +the attachment's exact run ends the attachment without ending session activity. Attachment entries do not participate in `ConnectionStatusStore.subscribe` accounting. Only the first real status subscriber installs the browser visibility listener and schedules the 30-second diff --git a/user-docs/building/typescript-sdk/permissions-and-plans.md b/user-docs/building/typescript-sdk/permissions-and-plans.md index 669af10e06..68987cd58e 100644 --- a/user-docs/building/typescript-sdk/permissions-and-plans.md +++ b/user-docs/building/typescript-sdk/permissions-and-plans.md @@ -115,9 +115,10 @@ The result discriminant defines the next application action: |`authorization_required`|The continuation parked on a different authorization. Create a new handle from `nextAuthorization` and present its live URL.| Iteration yields the same decoded events in wire order. Choose iteration or -`result()` once for each flow. An unknown status, mismatched session or -authorization correlation, changed continuation run ID, or malformed terminal -sequence throws `ProtocolError`. +`result()` once for each flow. An unknown status, mismatched authorization ID, +changed original call or continuation run ID, or malformed terminal sequence +throws `ProtocolError`. The server enforces session ownership through the session +affinity on the request. The application owns permission policy. `onPermissionAsk` receives only an ordinary permission ask observed on the continuation. Its verdict uses From 179b1969675450bbf190f7b2bd3e9d0d357cc0ca Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 18 Sep 2026 14:34:19 +0200 Subject: [PATCH 14/15] fix(sdk): close authorization lifecycle races Co-Authored-By: Codex Signed-off-by: Samuele Verzi --- internal/adapter/server/grpc.go | 26 +++--- .../mcp_authorization_lifecycle_test.go | 12 ++- .../mcp_authorization_transport_test.go | 88 +++++++++++++++++++ sdk/typescript/src/mcp-authorization.ts | 50 +++++++++-- .../test/mcp-authorization-control-fixture.ts | 10 +++ .../test/mcp-authorization-controls.test.ts | 53 +++++++++++ sdk/typescript/test/mcp-authorization.test.ts | 46 +++++++++- sdk/typescript/test/run.test.ts | 24 ++++- 8 files changed, 286 insertions(+), 23 deletions(-) diff --git a/internal/adapter/server/grpc.go b/internal/adapter/server/grpc.go index 5e8a395abb..80582ba6e1 100644 --- a/internal/adapter/server/grpc.go +++ b/internal/adapter/server/grpc.go @@ -1696,12 +1696,14 @@ func (h *HarnessServer) relayMCPAuthorizationControl(ctx context.Context, id ses // caller-visible, keep draining into the log, and let the run finish. // // The one exception is a run this dead stream has stranded: while parked on a - // permission ask, the run emits nothing and only an approval frame — which no - // longer has a channel to arrive on — can move it. Cancel that, and only that. + // non-plan permission ask, the run emits nothing and only an approval frame — + // which no longer has a channel to arrive on — can move it. Plan asks retain + // their separate durable approval workflow. Cancel only the ordinary ask. sendErr := send(toProto(result.Event)) - parkedOnAsk := false + controlEOF := false + parkedOnOrdinaryAsk := false strand := func() { - if sendErr != nil && parkedOnAsk { + if (sendErr != nil || controlEOF) && parkedOnOrdinaryAsk { h.svc.cancelRegisteredRun(id, result.Run) } } @@ -1735,8 +1737,9 @@ func (h *HarnessServer) relayMCPAuthorizationControl(ctx context.Context, id ses select { case err := <-controlDone: controlDone = nil - if errors.Is(err, io.EOF) && parkedOnAsk { - h.svc.cancelRegisteredRun(id, result.Run) + if errors.Is(err, io.EOF) { + controlEOF = true + strand() } if err != nil && !errors.Is(err, io.EOF) { if sendErr == nil { @@ -1753,7 +1756,7 @@ func (h *HarnessServer) relayMCPAuthorizationControl(ctx context.Context, id ses // continuation. The control result above is its durable record, so // forward the repeat without appending it again while draining. if sameMCPAuthorizationControlEvent(result.Event, ev) { - parkedOnAsk = false + parkedOnOrdinaryAsk = false if sendErr == nil { if err := send(toProto(ev)); err != nil { sendErr = err @@ -1762,9 +1765,12 @@ func (h *HarnessServer) relayMCPAuthorizationControl(ctx context.Context, id ses } continue } - // A parked run emits nothing, so an ask being the most recent event is - // what "parked awaiting approval" looks like from here. - parkedOnAsk = ev.Type == session.EvPermissionAsk + // A parked run emits nothing, so an ordinary ask being the most recent + // event is what "stranded without its control stream" looks like here. + // A plan-originated ask has a separate durable approval workflow. + parkedOnOrdinaryAsk = ev.Type == session.EvPermissionAsk && + ev.Ask != nil && ev.Ask.Origin() != session.AskOriginPlan + strand() if sendErr != nil { recorder.Observe(ev) strand() diff --git a/internal/adapter/server/mcp_authorization_lifecycle_test.go b/internal/adapter/server/mcp_authorization_lifecycle_test.go index 3da8b9d681..faaae7e817 100644 --- a/internal/adapter/server/mcp_authorization_lifecycle_test.go +++ b/internal/adapter/server/mcp_authorization_lifecycle_test.go @@ -270,6 +270,14 @@ func newLifecycleFixtureWithTurns(t *testing.T, status session.AuthorizationStat } func newLifecycleFixtureWithMode(t *testing.T, status session.AuthorizationStatus, attachErr error, now func() time.Time, timer AuthorizationTimerFactory, mode session.PermissionMode, turns ...mockllm.Turn) lifecycleFixture { + return newLifecycleFixtureConfigured(t, status, attachErr, now, timer, mode, false, turns...) +} + +func newInteractiveLifecycleFixtureWithMode(t *testing.T, status session.AuthorizationStatus, attachErr error, now func() time.Time, timer AuthorizationTimerFactory, mode session.PermissionMode, turns ...mockllm.Turn) lifecycleFixture { + return newLifecycleFixtureConfigured(t, status, attachErr, now, timer, mode, true, turns...) +} + +func newLifecycleFixtureConfigured(t *testing.T, status session.AuthorizationStatus, attachErr error, now func() time.Time, timer AuthorizationTimerFactory, mode session.PermissionMode, interactive bool, turns ...mockllm.Turn) lifecycleFixture { t.Helper() store := memstore.New() mutation := &lifecycleTool{} @@ -280,14 +288,14 @@ func newLifecycleFixtureWithMode(t *testing.T, status session.AuthorizationStatu for _, one := range tools { catalog.MustRegister(one) } - return agent.NewEngine(agent.Deps{LLM: mockllm.New(turns...), Catalog: catalog, Policy: permpolicy.NewPolicy(nil, nil), Store: store, Model: "mock"}) + return agent.NewEngine(agent.Deps{LLM: mockllm.New(turns...), Catalog: catalog, Policy: permpolicy.NewPolicy(nil, nil), Store: store, Model: "mock", Interactive: interactive}) } shared := buildEngine(nil) var builtTools [][]string var builtSpecs [][]mcp.ServerConfig cfg := Config{ Engine: shared, Store: store, PlacementProvider: lifecyclePlacementProvider{}, PlacementScope: "test", - MCPBroker: broker, Now: now, AuthorizationTimer: timer, + MCPBroker: broker, Now: now, AuthorizationTimer: timer, Interactive: interactive, } // Avoid spelling the MCP config type in the fixture closure by assigning the // correctly typed factory separately. diff --git a/internal/adapter/server/mcp_authorization_transport_test.go b/internal/adapter/server/mcp_authorization_transport_test.go index 3523076f3c..11f50baead 100644 --- a/internal/adapter/server/mcp_authorization_transport_test.go +++ b/internal/adapter/server/mcp_authorization_transport_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -18,8 +19,10 @@ import ( mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" "github.com/stacklok/mecatl/engine/adapter/memstore" "github.com/stacklok/mecatl/engine/adapter/mockllm" + "github.com/stacklok/mecatl/engine/agent" "github.com/stacklok/mecatl/engine/port" "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" ) type recheckAuthorizationStream struct { @@ -375,6 +378,91 @@ func TestMCPAuthorizationGRPCControlEOFCancelsStrandedPermissionContinuation(t * } } +func TestMCPAuthorizationGRPCControlEOFBeforeAskCancelsWhenContinuationLaterStrands(t *testing.T) { + followup := session.NewToolCall("followup-call", "protected", nil) + f := newLifecycleFixtureWithTurns(t, session.AuthorizationGranted, nil, time.Now, nil, + mockllm.ToolCallTurn(followup), mockllm.TextTurn("must not continue after a stranded ask")) + recvErred := make(chan struct{}) + releaseWork := make(chan struct{}) + f.attach.tool.hold = func(ctx context.Context) { + if f.attach.tool.calls.Load() < 2 { + return + } + select { + case <-releaseWork: + case <-ctx.Done(): + } + } + stream := &recheckAuthorizationStream{ + ctx: t.Context(), + recvErr: io.EOF, + recvErred: recvErred, + requests: []*mecatlv1.RecheckMcpAuthorizationRequest{{ + SessionId: "authorization-session", AuthorizationId: f.pending.Authorization.ID, + }}, + } + done := make(chan error, 1) + go func() { done <- NewHarnessServer(f.svc).RecheckMcpAuthorization(stream) }() + <-recvErred + // The authorized tool is still held, so EOF is the relay's only ready input. + // Give that select turn a bounded scheduling window before the later ask exists. + time.Sleep(25 * time.Millisecond) + close(releaseWork) + + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + if run, live := f.svc.LookupRun("authorization-session"); live { + f.svc.cancelRegisteredRun("authorization-session", run) + } + <-done + t.Fatal("control EOF observed before the ask left the later permission continuation stranded") + } + if got := stream.responses[len(stream.responses)-1].GetEvent().GetResult().GetStop(); got != "cancelled" { + t.Fatalf("terminal stop = %q, want cancelled", got) + } +} + +func TestMCPAuthorizationGRPCControlEOFDoesNotCancelPlanApprovalContinuation(t *testing.T) { + planCall := session.NewToolCall("plan-call", "PresentPlan", json.RawMessage(`{"plan":"inspect the change"}`)) + f := newInteractiveLifecycleFixtureWithMode(t, session.AuthorizationGranted, nil, time.Now, nil, + session.ModePlan, mockllm.ToolCallTurn(planCall)) + f.attach.refreshTools = []tool.Tool{agent.NewPresentPlanTool()} + stream := &recheckAuthorizationStream{ + ctx: t.Context(), + eofAfterAsk: make(chan struct{}), + requests: []*mecatlv1.RecheckMcpAuthorizationRequest{{ + SessionId: "authorization-session", AuthorizationId: f.pending.Authorization.ID, + }}, + } + done := make(chan error, 1) + go func() { done <- NewHarnessServer(f.svc).RecheckMcpAuthorization(stream) }() + select { + case <-stream.eofAfterAsk: + case err := <-done: + t.Fatalf("continuation ended before plan approval ask: %v; responses=%+v", err, stream.responses) + case <-time.After(time.Second): + t.Fatal("continuation did not reach plan approval ask") + } + + select { + case err := <-done: + t.Fatalf("plan approval continuation ended on control EOF: %v", err) + case <-time.After(100 * time.Millisecond): + } + run, live := f.svc.LookupRun("authorization-session") + if !live { + t.Fatal("plan approval continuation was not retained for the dedicated plan workflow") + } + f.svc.cancelRegisteredRun("authorization-session", run) + if err := <-done; err != nil { + t.Fatal(err) + } +} + // A broken control stream is not a cancellation (H-K5). The stream is a one-shot // RPC on a context deliberately detached from the continuation; cancelling the // run on a transport fault is what destroyed a follow-up authorization park mid diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index e92030cbd3..00ac650786 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -272,6 +272,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { readonly #operations: McpAuthorizationOperations; readonly #flowOptions: McpAuthorizationFlowOptions; readonly #requestOptions: RequestOptions | undefined; + readonly #automaticControls = new Map(); readonly #controlFailure: Promise; readonly #rejectControlFailure: (error: unknown) => void; readonly #closed: Promise; @@ -524,7 +525,14 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { features: (options) => this.#operations.features(options), promptCapabilities: () => this.#operations.promptCapabilities(), transportKind: this.#operations.transportKind, - unary: (method, input, options) => this.#operations.unary(method, input, options), + unary: (method, input, options) => { + if (options?.signal?.aborted === true) { + return Promise.reject( + normalizeError(options.signal.reason, this.#operations.transportKind), + ); + } + return this.#operations.unary(method, input, options); + }, }); } if (event.runId !== this.#continuationRunId) { @@ -598,15 +606,22 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { return; } if (verdict === undefined || pending.controller.signal.aborted) return; + const automaticControl = new AbortController(); try { await this.#resolvePendingAsk( askId, verdict, pending, - this.#automaticControlOptions(this.#flowOptions.permissionRequestOptions), + this.#automaticControlOptions( + this.#flowOptions.permissionRequestOptions, + automaticControl.signal, + ), + automaticControl, ); } catch (error) { - if (!this.#ended) this.#rejectControlFailure(error); + if (!this.#ended && !automaticControl.signal.aborted) { + this.#rejectControlFailure(error); + } } })(); } @@ -616,6 +631,7 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { verdict: PermissionVerdict, pending: PendingAsk, requestOptions: RequestOptions | undefined, + automaticControl?: AbortController, ): Promise { if (this.#pendingAsks.get(askId) !== pending || pending.plan) { throw new InvalidStateError(`Permission ask ${askId} is no longer pending`, { @@ -630,10 +646,23 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { } this.#pendingAsks.delete(askId); pending.controller.abort(); - await controls.resolveAsk(askId, verdict, requestOptions); + if (automaticControl !== undefined) this.#automaticControls.set(askId, automaticControl); + try { + await controls.resolveAsk(askId, verdict, requestOptions); + } finally { + if ( + automaticControl !== undefined && + this.#automaticControls.get(askId) === automaticControl + ) { + this.#automaticControls.delete(askId); + } + } } - #automaticControlOptions(requestOptions: RequestOptions | undefined): RequestOptions { + #automaticControlOptions( + requestOptions: RequestOptions | undefined, + askSignal: AbortSignal, + ): RequestOptions { const lifetimeSignal = this.#abort?.signal; if (lifetimeSignal === undefined) { throw this.#protocol("The authorization flow has no active request lifetime"); @@ -642,12 +671,17 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { ...requestOptions, signal: requestOptions?.signal === undefined - ? lifetimeSignal - : AbortSignal.any([requestOptions.signal, lifetimeSignal]), + ? AbortSignal.any([askSignal, lifetimeSignal]) + : AbortSignal.any([requestOptions.signal, askSignal, lifetimeSignal]), }; } #retireAsk(askId: string): void { + const automaticControl = this.#automaticControls.get(askId); + if (automaticControl !== undefined) { + this.#automaticControls.delete(askId); + automaticControl.abort(); + } const pending = this.#pendingAsks.get(askId); if (pending === undefined) return; this.#pendingAsks.delete(askId); @@ -655,6 +689,8 @@ class McpAuthorizationFlowImpl implements McpAuthorizationFlow { } #retireAllAsks(): void { + for (const control of this.#automaticControls.values()) control.abort(); + this.#automaticControls.clear(); for (const pending of this.#pendingAsks.values()) pending.controller.abort(); this.#pendingAsks.clear(); } diff --git a/sdk/typescript/test/mcp-authorization-control-fixture.ts b/sdk/typescript/test/mcp-authorization-control-fixture.ts index 0c171b7b5c..df29a38fc9 100644 --- a/sdk/typescript/test/mcp-authorization-control-fixture.ts +++ b/sdk/typescript/test/mcp-authorization-control-fixture.ts @@ -30,6 +30,11 @@ export interface StreamPlan { } export interface HarnessOptions { + readonly beforeUnaryDispatch?: ( + method: string, + input: Record, + signal: AbortSignal | undefined, + ) => void | Promise; readonly features?: readonly string[]; readonly streams?: readonly StreamPlan[]; readonly watchStreams?: readonly StreamPlan[]; @@ -46,11 +51,13 @@ export class LifecycleTransport implements Transport { closedStreams = 0; maxActiveStreams = 0; readonly #features: readonly string[]; + readonly #beforeUnaryDispatch: HarnessOptions["beforeUnaryDispatch"]; readonly #streams: StreamPlan[]; readonly #unary: HarnessOptions["unary"]; readonly #watchStreams: StreamPlan[]; constructor(options: HarnessOptions = {}) { + this.#beforeUnaryDispatch = options.beforeUnaryDispatch; this.#features = options.features ?? ["prompt_free_controls", "watch_session_events"]; this.#streams = [...(options.streams ?? [])]; this.#unary = options.unary; @@ -66,6 +73,9 @@ export class LifecycleTransport implements Transport { contextValues?: ContextValues, ): Promise> { void contextValues; + await this.#beforeUnaryDispatch?.(method.name, input as Record, signal); + if (signal?.aborted === true) + throw signal.reason ?? new Error("request aborted before dispatch"); this.calls.push({ headers: new Headers(headers), input, diff --git a/sdk/typescript/test/mcp-authorization-controls.test.ts b/sdk/typescript/test/mcp-authorization-controls.test.ts index 165fe29e0e..0f577774a8 100644 --- a/sdk/typescript/test/mcp-authorization-controls.test.ts +++ b/sdk/typescript/test/mcp-authorization-controls.test.ts @@ -90,6 +90,15 @@ describe("MCP authorization continuation controls", () => { ).resolves.toBeUndefined(); const manualCall = manual.transport.calls.find((call) => call.method === "ResolveRunAsk"); expect(manualCall?.headers.get("x-authority")).toBe("manual"); + const resolvedCallCount = manual.transport.calls.filter( + (call) => call.method === "ResolveRunAsk", + ).length; + await expect(manualFlow.resolveAsk("ask-manual", "allow_once")).rejects.toBeInstanceOf( + InvalidStateError, + ); + expect(manual.transport.calls.filter((call) => call.method === "ResolveRunAsk")).toHaveLength( + resolvedCallCount, + ); await iter.next(); await iter.next(); await expect(manualFlow.resolveAsk("ask-retracted", "deny")).rejects.toBeInstanceOf( @@ -102,6 +111,50 @@ describe("MCP authorization continuation controls", () => { await manual.client.close(); }); + it("MCP authorization retirement suppresses an admitted automatic verdict", async () => { + let releaseDispatch: () => void = () => undefined; + const dispatchGate = new Promise((resolve) => { + releaseDispatch = resolve; + }); + let markSetup: () => void = () => undefined; + const setup = new Promise((resolve) => { + markSetup = resolve; + }); + let admittedSignal: AbortSignal | undefined; + const instance = await harness({ + beforeUnaryDispatch: async (method, _input, signal) => { + if (method !== "ResolveRunAsk") return; + admittedSignal = signal; + markSetup(); + await dispatchGate; + }, + streams: [{ events: continuation(ask("ask-retired"), retract("ask-retired"), result()) }], + }); + const flow = instance.session.mcpAuthorization(authorizationId).recheck({ + onPermissionAsk: () => "allow_once", + }); + const iterator = flow[Symbol.asyncIterator](); + await iterator.next(); + await iterator.next(); + await iterator.next(); + await setup; + expect(admittedSignal?.aborted).toBe(false); + + await iterator.next(); + expect(admittedSignal?.aborted).toBe(true); + releaseDispatch(); + await flush(); + expect(instance.transport.calls.filter((call) => call.method === "ResolveRunAsk")).toHaveLength( + 0, + ); + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { kind: "result" }, + }); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + await instance.client.close(); + }); + it("MCP authorization continuation controls are exact run and feature gated", async () => { const active = await harness({ streams: [{ events: continuation(result()) }] }); const flow = active.session.mcpAuthorization(authorizationId).recheck(); diff --git a/sdk/typescript/test/mcp-authorization.test.ts b/sdk/typescript/test/mcp-authorization.test.ts index 0153367b07..a125680a74 100644 --- a/sdk/typescript/test/mcp-authorization.test.ts +++ b/sdk/typescript/test/mcp-authorization.test.ts @@ -272,6 +272,7 @@ describe("MCP authorization lifecycle", () => { await expect(handle.presentation(requestOptions)).resolves.toBe( "https://identity.example/authorize", ); + await expect(handle.presentation()).resolves.toBe("https://identity.example/authorize"); const call = transport.calls.find( (candidate) => candidate.method === "GetMcpAuthorizationPresentation", ); @@ -286,7 +287,7 @@ describe("MCP authorization lifecycle", () => { expect(responseTrailers).toEqual(["presentation-trailer"]); expect( transport.calls.filter((candidate) => candidate.method === "GetMcpAuthorizationPresentation"), - ).toHaveLength(1); + ).toHaveLength(2); await client.close(); }); @@ -486,6 +487,22 @@ describe("MCP authorization lifecycle", () => { const cases: WireEvent[][] = [ [original, repeated, message("changed-run"), terminal("changed-run")], [original, message(), terminal()], + [ + original, + authorization("authorization.resolved", "granted", { + authorizationId: "authorization-changed", + runId: continuationRunId, + }), + terminal(), + ], + [ + original, + authorization("authorization.resolved", "granted", { + callId: "call-changed", + runId: continuationRunId, + }), + terminal(), + ], [original, repeated, repeated, terminal()], [original, repeated, terminal(), terminal()], [original, repeated, terminal(), message()], @@ -496,6 +513,33 @@ describe("MCP authorization lifecycle", () => { runId: continuationRunId, }), ], + [ + original, + repeated, + authorization("authorization.required", "pending", { + authorizationId: "", + callId: "next-call", + runId: continuationRunId, + }), + ], + [ + original, + repeated, + authorization("authorization.required", "pending", { + authorizationId: "authorization-next", + callId: "", + runId: continuationRunId, + }), + ], + [ + original, + repeated, + authorization("authorization.required", "granted", { + authorizationId: "authorization-next", + callId: "next-call", + runId: continuationRunId, + }), + ], ]; const { authorization: handle, client } = await grpcSession(cases); for (const _events of cases) { diff --git a/sdk/typescript/test/run.test.ts b/sdk/typescript/test/run.test.ts index b1930c6095..8a887ae2b9 100644 --- a/sdk/typescript/test/run.test.ts +++ b/sdk/typescript/test/run.test.ts @@ -262,15 +262,27 @@ describe("run choreography", () => { if (sequence === 3) { yield { event: { - authorization: { authorizationId: "", callId: "", status: "granted" }, + authorization: { + authorizationId: "authorization-3", + callId: "call-3", + status: "granted", + }, runId, type: "authorization.required", }, }; return; } + if (sequence === 4) { + yield authorizationRequired(runId, "", "call-4"); + return; + } + if (sequence === 5) { + yield authorizationRequired(runId, "authorization-5", ""); + return; + } yield terminal(runId); - if (sequence === 4) yield terminal(runId, "error", "duplicate"); + if (sequence === 6) yield terminal(runId, "error", "duplicate"); }, }); }); @@ -290,7 +302,13 @@ describe("run choreography", () => { await expect(truncated.outcome()).rejects.toBeInstanceOf(ProtocolError); await expect(truncated.result()).rejects.toBeInstanceOf(InvalidStateError); - await expect(session.run("malformed authorization")).rejects.toBeInstanceOf(ProtocolError); + for (const prompt of [ + "wrong authorization status", + "empty authorization ID", + "empty call ID", + ]) { + await expect(session.run(prompt)).rejects.toBeInstanceOf(ProtocolError); + } const duplicate = await session.run("duplicate terminal"); await expect(duplicate.outcome()).rejects.toBeInstanceOf(ProtocolError); From c4580cdd280d569ef080328f78d9afa7968628b0 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Mon, 21 Sep 2026 10:45:37 +0200 Subject: [PATCH 15/15] docs(sdk): distinguish MCP authorization scopes Co-Authored-By: Codex Signed-off-by: Samuele Verzi --- docs/architecture.md | 3 ++- sdk/typescript/src/client.ts | 3 +++ sdk/typescript/src/mcp-authorization.ts | 3 ++- user-docs/building/typescript-sdk/permissions-and-plans.md | 5 +++++ user-docs/building/typescript-sdk/sessions-and-runs.md | 4 ++++ user-docs/features/mcp-oauth-and-credentials.md | 5 +++++ user-docs/reference/typescript-sdk-api/core.md | 4 ++-- 7 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f7c70580fa..361ae9dec4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -365,7 +365,8 @@ exact-affinity control request. The flow validates the authoritative status and continuation into pending, settled, completed, or chained-authorization results. The SDK does not poll, retry a mutation, reconnect, or choose a permission verdict. Automatic permission responses use their separately declared request options and the existing -prompt-free exact-run controls. +prompt-free exact-run controls. This lifecycle is limited to session-scoped ToolHive broker +handoffs; direct and global profiles remain host-local administration through `mecated mcp`. Request cancellation releases only SDK-owned resources. The server decides what committed before disconnect. gRPC detaches and drains ordinary continuation work but cancels a run diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 5a4d774619..b2737bf095 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -201,6 +201,9 @@ export interface Session { /** * Binds one external authorization ID to this session without performing I/O. * + * This handle consumes session-scoped ToolHive broker authorization handoffs. + * It does not administer direct or global MCP profiles or their credentials. + * * @param authorizationId - Exact non-empty ID from an authorization event. * @returns A reusable correlation handle that makes no authorization-state assertion. */ diff --git a/sdk/typescript/src/mcp-authorization.ts b/sdk/typescript/src/mcp-authorization.ts index 00ac650786..aa2db6326a 100644 --- a/sdk/typescript/src/mcp-authorization.ts +++ b/sdk/typescript/src/mcp-authorization.ts @@ -147,7 +147,8 @@ export interface McpAuthorizationFlow extends AsyncIterable { * * Construction stores exact correlation only. It performs no I/O and makes no state or authority * claim. The handle does not persist credentials or lifecycle truth. Every presentation lookup and - * control request receives automatic session affinity. + * control request receives automatic session affinity. It consumes session-scoped ToolHive broker + * authorization handoffs; it does not administer direct or global MCP profiles or credentials. * @public */ export interface McpAuthorization { diff --git a/user-docs/building/typescript-sdk/permissions-and-plans.md b/user-docs/building/typescript-sdk/permissions-and-plans.md index 68987cd58e..57f7702cff 100644 --- a/user-docs/building/typescript-sdk/permissions-and-plans.md +++ b/user-docs/building/typescript-sdk/permissions-and-plans.md @@ -67,6 +67,11 @@ Create a session-bound authorization handle from the handoff returned by `Run.outcome()`. The handle stores correlation only and performs no request or state check during construction: +Use this workflow only for `authorization.required` handoffs from +session-scoped ToolHive broker tools. It does not configure direct or global +MCP profiles or manage their credentials. For those host-local profiles, use +[`mecated mcp add` or `mecated mcp login`](/features/mcp-oauth-and-credentials.md). + ```ts const outcome = await run.outcome(); if (outcome.outcome !== 'authorization_required') { diff --git a/user-docs/building/typescript-sdk/sessions-and-runs.md b/user-docs/building/typescript-sdk/sessions-and-runs.md index e4dda3fb3f..3183e88ebf 100644 --- a/user-docs/building/typescript-sdk/sessions-and-runs.md +++ b/user-docs/building/typescript-sdk/sessions-and-runs.md @@ -168,6 +168,10 @@ failures throw typed SDK errors. Use `outcome()` when an MCP server can require external authorization. It returns either the completed result or a detached authorization handoff: +This lifecycle applies to session-scoped ToolHive broker handoffs. Direct and +global MCP profiles use the host-local +[`mecated mcp` commands](/features/mcp-oauth-and-credentials.md) instead. + ```ts const run = await session.run('Use the configured MCP server'); const outcome = await run.outcome(); diff --git a/user-docs/features/mcp-oauth-and-credentials.md b/user-docs/features/mcp-oauth-and-credentials.md index 6107ff6111..80d12ebea1 100644 --- a/user-docs/features/mcp-oauth-and-credentials.md +++ b/user-docs/features/mcp-oauth-and-credentials.md @@ -20,6 +20,11 @@ Global MCP authentication profiles are supported by `mecated`, `mecatequi`, project configuration. A project `.mecatl/settings.yaml` cannot install or weaken an MCP credential profile. +These direct and global profiles are separate from session-scoped ToolHive +broker authorization. When a broker tool returns `authorization.required`, use +the [TypeScript SDK continuation workflow](/building/typescript-sdk/permissions-and-plans.md#continue-after-mcp-authorization) +instead of the host-local commands on this page. + A server configured with `--mcp-server name=URL` can also use the legacy `MCP__TOKEN` bearer-token convention. Use operator `mcp.servers` profiles for OAuth, rotation, or deployment-managed credentials. diff --git a/user-docs/reference/typescript-sdk-api/core.md b/user-docs/reference/typescript-sdk-api/core.md index a5a9916915..730bcf0132 100644 --- a/user-docs/reference/typescript-sdk-api/core.md +++ b/user-docs/reference/typescript-sdk-api/core.md @@ -2545,7 +2545,7 @@ readonly userModel?: DreamTargetCapability; McpAuthorization -A reusable session-bound correlation handle for one server-owned authorization. Construction stores exact correlation only. It performs no I/O and makes no state or authority claim. The handle does not persist credentials or lifecycle truth. Every presentation lookup and control request receives automatic session affinity. +A reusable session-bound correlation handle for one server-owned authorization. Construction stores exact correlation only. It performs no I/O and makes no state or authority claim. The handle does not persist credentials or lifecycle truth. Every presentation lookup and control request receives automatic session affinity. It consumes session-scoped ToolHive broker authorization handoffs; it does not administer direct or global MCP profiles or credentials. ```ts export interface McpAuthorization @@ -4506,7 +4506,7 @@ Returns: `Promise`: A detached SDK-owned connector invent Session.mcpAuthorization -Binds one external authorization ID to this session without performing I/O. +Binds one external authorization ID to this session without performing I/O. This handle consumes session-scoped ToolHive broker authorization handoffs. It does not administer direct or global MCP profiles or their credentials. ```ts mcpAuthorization(authorizationId: string): McpAuthorization;