Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 27 additions & 27 deletions docs/acceptance/sdk-mcp-authorization-lifecycle.md

Large diffs are not rendered by default.

47 changes: 39 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -494,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
Expand All @@ -508,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
Expand All @@ -537,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.

Expand Down
73 changes: 65 additions & 8 deletions docs/design/IMPLEMENTATION-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -8890,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
Expand Down Expand Up @@ -8972,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
Expand Down
25 changes: 17 additions & 8 deletions internal/adapter/server/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -1735,6 +1737,10 @@ func (h *HarnessServer) relayMCPAuthorizationControl(ctx context.Context, id ses
select {
case err := <-controlDone:
controlDone = nil
if errors.Is(err, io.EOF) {
controlEOF = true
strand()
}
if err != nil && !errors.Is(err, io.EOF) {
if sendErr == nil {
sendErr = err
Expand All @@ -1750,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
Expand All @@ -1759,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()
Expand Down
12 changes: 10 additions & 2 deletions internal/adapter/server/mcp_authorization_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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.
Expand Down
Loading
Loading