Skip to content

fix(mcpapps): prompt browser performs the platform session handshake#1035

Merged
cjimti merged 1 commit into
mainfrom
fix/1032-app-session-handle
Jul 23, 2026
Merged

fix(mcpapps): prompt browser performs the platform session handshake#1035
cjimti merged 1 commit into
mainfrom
fix/1032-app-session-handle

Conversation

@cjimti

@cjimti cjimti commented Jul 23, 2026

Copy link
Copy Markdown
Member

Closes #1032

The prompt browser MCP App added in #1011 could not make a single tool call in a deployment with the session handle gate on. Its first action after ui/initialize is a manage_prompt list, which the platform refuses with SESSION_REQUIRED because the call carries no platform_info-minted session_id, so the app rendered its own error banner instead of the library. List, search-as-you-type, and Run were all dead.

An app's tool calls reach the server over the same MCP transport as an agent's and meet the same gates. pkg/middleware/mcp_session_handle.go:173 exempts only the init tool, operator-configured exempt tools, and the stateless in-memory shims (Source=rest / Source=admin); a host-proxied app call arrives as Source=mcp and is gated exactly like an agent call. So the app now performs the platform handshake itself, the same contract every other caller follows. No server-side gate changes.

The handshake

platform_info is app-callable. MCP Apps tool visibility defaults to ["model", "app"], and the spec scopes "app" per server connection rather than per app-tool binding (ext-apps/specification/2026-01-26/apps.mdx:399-401), so an app bound to manage_prompt may still call it. Its result's first text block is the JSON payload carrying session_id (pkg/platform/info_tool.go:283-320).

callTool owns the whole session concern and returns {promise, cancel}:

  • It waits on an in-flight handshake instead of issuing with a handle it knows was just discarded, so a call started during recovery is not sent pre-refused.
  • It merges session_id into the arguments in one place, covering list, search, and use.
  • It recovers once from SESSION_REQUIRED, SESSION_EXPIRED, or SETUP_REQUIRED by discarding the handle, re-handshaking, and replaying. SETUP_REQUIRED is the transport-keyed session gate (pkg/middleware/mcp_session_gate.go:212) a deployment may run instead of explicit handles; its remedy is the same call.
  • cancel() drops the request and blocks a replay that has not gone out yet, so a superseded search cannot leave a second waiter behind.

A deployment with handles disabled returns no session_id, in which case nothing is threaded and behavior is unchanged.

A failed handshake is not fatal. The list call runs regardless and reports whatever the platform actually says. Handles may be off, and a persona may allow manage_prompt while denying platform_info (pkg/persona/filter.go is default-deny with no exemption for it), so blanking the app on a platform_info failure would break cases that worked before the handshake existed. With the gate on and the handshake refused, the app self-heals through the list call's own recovery and never shows a banner.

Response correlation

Hosts speaking protocol 2025-01-09 deliver results as ui/notifications/tool-result notifications, which carry no request id, so the app matches responses by payload shape. Adding a second kind of call introduced two hazards, both handled here.

The platform_info payload carries a prompts array listing the server's registered MCP prompts (Info.Prompts, pkg/platform/info_tool.go:31), confirmed live at 5 entries against a running server. Identifying a list result by a prompts array alone would therefore let the handshake response satisfy a waiting list call and render the server's MCP prompt list in place of the user's library. A list result is now identified by count plus the absence of config_version and features, both of which are unconditional on the info payload and appear on no manage_prompt payload.

A session refusal invalidates the handle for every in-flight gated call, so it settles all of them and each recovers through the shared handshake. Settling only the oldest waiter would make an unrelated call perform the recovery while the refused one hung to its 20 second timeout. The handshake's own waiter is excluded, since platform_info is never gated and so is never the call being refused.

Test harness

apps/test-harness.html enforces the gate rather than describing it, so an app that skips the handshake fails in development instead of only in production:

  • platform_info mints a fresh handle per call and supersedes the last, as the platform does.
  • Any other call whose handle is missing or stale is refused with the platform's verbatim SESSION_REQUIRED / SESSION_EXPIRED text as an isError result.
  • The handshake payload carries a prompts array, so the correlation collision above reproduces in the harness rather than being hidden by a simplified fixture.
  • A new Expire Session control revokes the live handle, making the expired-handle recovery path drivable on demand.

The platform-info app is excluded from the minting branch, since platform_info is the tool it renders and its call must still receive the editable test data.

Verification

Driven against a real server (HTTP transport, Postgres, handle gate on by default) with the app in an MCP Apps host bridged to the live MCP endpoint, on both the 2026-01-26 and 2025-01-09 protocol paths:

  • Reproduced the reported failure: manage_prompt with no handle returns SESSION_REQUIRED; platform_info mints a dps_ handle; the same call carrying it succeeds; an unknown handle returns SESSION_EXPIRED.
  • Fixed app: boot renders the library, search-as-you-type returns ranked results, a detail opens, and Run resolves and inserts the rendered prompt, with no banner.
  • audit_logs rows for the app's manage_prompt calls carry the caller identity and the resolved dps_ handle as session_id.
  • Confirmed session_id is injected into manage_prompt's input schema on tools/list and absent from platform_info's.

Against a host that enforces the gate under controlled latency and expiry:

  • Overlapping searches across an expiry: both calls refused, one shared handshake, only the current query replayed, correct results under the correct query.
  • A list call and a use call refused together: both settle promptly, both replay on one handshake, Run completes.
  • Handles disabled: no session_id argument sent, library renders.
  • Handshake failure: transient failure self-heals with no banner; persistent failure shows the platform's message with a working Retry, and the panel is never blank at any point during boot, failure, or retry.

make verify passes.

Documentation

docs/mcpapps/development.md gains a "Calling Tools from an App" section covering the handshake, the expired-handle recovery path, the rule that a failed handshake must not be fatal, the correlation hazard, and what the harness enforces, plus a troubleshooting row. docs/mcpapps/overview.md gains a short "Apps Calling Tools" section pointing at it. Mirrored into docs/llms.txt and docs/llms-full.txt.

…1032)

The prompt browser calls platform_info before its first data call and threads the returned session_id on every subsequent call, so it renders in a deployment with the session handle gate on instead of showing a SESSION_REQUIRED banner. An app's tool calls reach the server over the same MCP transport as an agent's and meet the same gate; platform_info is app-callable because MCP Apps tool visibility defaults to ["model", "app"] and scopes "app" per server connection rather than per app-tool binding.

callTool returns {promise, cancel} and owns the whole session concern: it waits on an in-flight handshake rather than issuing with a handle it just discarded, merges the handle into the arguments in one place, and recovers once from SESSION_REQUIRED, SESSION_EXPIRED, or SETUP_REQUIRED by re-handshaking and replaying. cancel() drops the request and blocks a replay that has not gone out, so a superseded search cannot leave a second waiter behind. A deployment with handles disabled returns no session_id and nothing is threaded.

A failed handshake is not fatal. The list call runs regardless and reports whatever the platform says, because handles may be off and a persona may allow manage_prompt while denying platform_info.

Response correlation on hosts that deliver results as notifications carries two hazards, both now handled. The platform_info payload includes a prompts array listing the server's registered MCP prompts, so a list result is identified by count plus the absence of config_version and features rather than by a prompts array alone. A session refusal invalidates the handle for every in-flight gated call, so it settles all of them and each recovers through the shared handshake; the handshake's own waiter is excluded, since platform_info is never gated and so is never the call being refused.

The test harness enforces the gate rather than describing it: it mints a fresh handle on platform_info, refuses any other call whose handle is missing or stale with the platform's own refusal text, and carries the prompts array so the correlation collision reproduces in development. An Expire Session control revokes the live handle to exercise the recovery path.
@cjimti
cjimti merged commit b22789c into main Jul 23, 2026
@cjimti
cjimti deleted the fix/1032-app-session-handle branch July 23, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

prompts: prompt-browser MCP App refused with SESSION_REQUIRED - app tool calls carry no session handle

1 participant