Skip to content

feat(mcp): Spec 058 US3 stateless list surfaces - #1691

Open
Dumbris wants to merge 20 commits into
mainfrom
058-us3-stateless-list-surfaces
Open

Dumbris wants to merge 20 commits into
mainfrom
058-us3-stateless-list-surfaces

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Summary

Spec 058 (MCP 2026-07-28 upgrade), user story US3 — stateless readiness, landed under the FR-028 legacy pin. Tracking issue #532. Tasks T020–T036b in specs/058-mcp-2026-upgrade/tasks.md; decisions recorded in research.md D11.

Under the 2026-07-28 revision there are no sessions, so */list results must not vary per connection (FR-012) and nothing a request does may persist per-connection state (FR-011). The one piece of connection state mcpproxy holds is the legacy set_profile selection. This PR makes the modern era clean, fixes the pre-existing FR-012 violation on legacy, and closes two defects the era exposes.

Behaviour

  • Era-gated session tier. profileSessionIDFromContext returns "" for a 2026-07-28 request whatever the transport reports — stdio reports the constant "stdio" in both eras, and before this a modern stdio set_profile persisted a selection (proven by TestProfileEra_ModernStdioRequestCannotPersistSelection). Both resolvers (v2 precedence and Spec 108 v3) read the one gate.
  • List-only resolution. resolveActiveProfileForList / listOnlyProfileResolution skip the session tier and record nothing on the session; the direct-tools filter uses it at list time only (call-time re-evaluation keeps the full precedence) and the aggregated-prompts filter uses it for prompts/list (prompts/get keeps its handler gate). Fixes the pre-existing prompts/list FR-012 violation research D1 named.
  • set_profile validates the slug before the session guard (an unknown slug is answered unknown profile '<slug>' in both eras) and refuses a modern request with guidance naming /mcp/p/<slug>.
  • Cross-client cancellation guard (R1, security). mcp-go keys in-flight cancels as "<sessionID>:<id>"; every modern request has session id "", so two clients using JSON-RPC id 1 collide and one can cancel the other's call — reproduced in TestCancelIsolation_LibraryCollisionStillPresent. The notification is handled before any hook, so the guard is HTTP middleware (modernCancellationGuard): a cancellation notification on a modern POST is acknowledged (202) and dropped. Legacy flows are untouched (TestCancelIsolation_LegacySelfCancelStillWorks).
  • Stdio era pin (T036a). mcp-go has no stdio equivalent of the Streamable HTTP version pin; rejectUnservedProtocolOnStdio refuses a declared version outside clientFacingProtocolVersions(), the constant set the HTTP option now reads too, so T041 lifts both in one place.
  • Attribution without initialize (T032/T033). activityAttribution falls back to RequestProtocolInfoFromContext(ctx).ClientInfo (name + new ClientVersion → metadata.client_version), and the BeforeAny hook feeds the Spec 044 activation funnel and client-seen presence from the same data, debounced per client identity per minute.
  • FR-016b on /mcp/all is now asserted over the live direct routing fixture (T036b).
  • Server edition: TestEntitlementStateless_TwoUsersIsolatedWithoutSession (-tags server) proves two OAuth users with differing entitlements are isolated over modern requests with no session (FR-013, research D4's untested half).

Era harness

Modern-era tests reach the proxy through unpinnedProxyEndpoint: the proxy's own MCP server over an unpinned Streamable HTTP transport behind the production auth middleware. rawEraClient speaks JSON-RPC by hand so byte-identity can be asserted on the wire. Every modern-era test proves it took the modern path (requireEra).

Deliberate residual (documented)

filterProfileV3Tools (Spec 108 FR-017 / Spec 115 FR-011) still hides the built-in administration tools according to a legacy session's selection at list time — a Spec 108 contract pinned by its visibility matrix tests, out of US3's scope. It cannot happen in the modern era and disappears at T077 (deprecation-window closeout). research.md D11 and the profiles docs name it.

Upstream issue

The mcp-go report for the inflight-key collision could not be filed: mark3labs/mcp-go restricts interactions to collaborators (2026-10-20). The draft is in specs/058-mcp-2026-upgrade/upstream-issues/mcp-go-inflight-cancel-collision.md; inflightKey is unchanged on mcp-go main (v1.3.0).

No new dependencies.

Verification

  • ./scripts/test-api-e2e.sh
  • go test -race for ./internal/server/... ./internal/runtime/... (bare) and ./internal/serveredition/... ./internal/server/... (-tags server) with the unit-tests.yml -skip regex
  • golangci-lint v2 with .github/.golangci.yml, bare and --build-tags server: no findings in touched files (remaining findings are pre-existing in untouched files)
  • Cross-model review, one round: zcode (GLM) could not run (expired project-token login; needs a human zcode login), so the round ran on opencode github-copilot/gpt-6.1-sol in three chunks. 7 findings, 6 fixed (bounded client recorder; era gate on the session write paths; metadata.client_name on modern records so per-client usage counts them; two test-vacuity fixes; an accurately scoped server-edition test plus a production-chain agent-token test), 1 declined with rationale (body buffering: mcp-go already buffers the whole POST before era detection). Round 2 (incremental, on the fixes): 1 finding (a vacuous work-session assertion), fixed with a storage-backed test proven to bite. Table in research.md D11.

Closes nothing on its own; part of #532.

Verified on a live instance

Scratch core from this branch (./mcpproxy serve --config <scratch>/mcp_config.json --data-dir <scratch>/data --listen 127.0.0.1:18731, two disabled upstreams, profiles research/deploy, API key auth), driven with raw JSON-RPC over curl; stdio via ./mcpproxy serve --listen ":0" on a second scratch data dir.

Check Result
Two fresh legacy connections, tools/list both 200, 14 tools, result bytes identical (sha256 8c0242b4cb8e74e8… on both)
Legacy set_profile("research") on A, then tools/list A vs B set_profile OK (profile_source: session); lists differ by credentials/profiles only — the documented built-in-visibility residual (Spec 108, legacy era only)
Modern (2026-07-28) tools/list and set_profile on the pinned endpoint 400, -32022 unsupported protocol version — never dispatched, nothing persisted
Modern notifications/cancelled POST 202 from modernCancellationGuard (the same endpoint answers a modern request with 400), A unaffected and still serving
Legacy notifications/cancelled with A's session id 202, passes through to mcp-go unchanged
stdio: modern tools/list with _meta -32600 protocol version 2026-07-28 is not served on the stdio surface: the client-facing era is pinned to 2025-11-25, …
stdio: legacy initialize + tools/list initialize result protocolVersion: 2025-11-25, tools listed

The modern-era handler behaviour itself (refusal naming /mcp/p/<slug>, identity-scoped lists, attribution) is reachable only through the in-process era harness while FR-028 pins the listening endpoint; those are the forEachEra/unpinnedProxyEndpoint tests in this PR.

🤖 Generated with Claude Code

… (T020-T022)

FR-012 byte-identical tools/list across two connections in both eras over an
unpinned era harness; direct-tools and aggregated-prompts list filters must
ignore a session's set_profile selection (fails on main); a modern stdio
request must not persist a profile selection despite stdio's constant session
id (fails on main: it does).
… (T023-T025)

Spec 058 US3. profileSessionIDFromContext gates the resolver's session tier on
!server.IsModernRequest(ctx) (stdio reports the constant "stdio" in both eras)
and on a list-only marker; resolveActiveProfileForList skips the tier so the
direct-tools and aggregated-prompts list filters no longer vary per connection
(FR-012). Both the v2 precedence and the Spec 108 v3 resolution read the tier
through the one gate, and a list-only resolution records nothing on the
session.
… and guides modern requests (T026)

Spec 058 US3. An unknown slug is refused as unknown in both eras; a 2026-07-28
request (gated on the era, not the transport's session id, which stdio reports
as the constant "stdio") is refused with machine-actionable guidance naming
/mcp/p/<slug>, and nothing is stored.
…ern era variants (T027-T028)

TestProfile_SetProfileSessionScoped keeps its name and pins the legacy era
explicitly; TestProfile_SetProfileModernRequestIsStateless covers the
2026-07-28 era over the unpinned era harness; TestProfile_SetProfileUnknown
asserts the unknown-slug message in both eras.
…a (T029)

Documents mcp-go's inflightKey collision for 2026-07-28 requests (every modern
request runs under session id "", so two clients using JSON-RPC id 1 share
key ":1") and asserts mcpproxy's guard isolates them while a legacy session
can still cancel its own request.
…ec 058 R1, T030)

A 2026-07-28 request has no session, so a notifications/cancelled posted on
its own cannot be correlated with any in-flight request; mcp-go keys it as
":<id>" and would cancel whichever client's call shares that id. The guard
acknowledges (202) and drops such notifications in the /mcp* middleware chain;
legacy requests pass through unchanged.
mark3labs/mcp-go restricts interactions to collaborators, so the report is
kept in the spec folder until it can be filed.
…isolated with no session (T031)

Spec 058 FR-013, server edition: bearer JWT → user record →
ResolveAgentTokenOwner → AllowedServers, re-derived per modern request; tool
lists and calls differ per identity while every request runs under an empty
session id.
…(T032)

Spec 058 US3: initialize never runs in the 2026-07-28 era, so the session store
never learns a modern client's name. Adds ActivityAttribution.ClientVersion
(metadata.client_version when the session resolver wrote none) and the failing
unit + end-to-end tests.
…l info (T033)

Spec 058 US3. activityAttribution falls back to RequestProtocolInfoFromContext
(ctx).ClientInfo when no session names the client, and the BeforeAny hook
feeds the Spec 044 activation funnel and client-seen presence from the same
data, debounced per client identity, so both survive the loss of
AfterInitialize in the 2026-07-28 era.
… false (T034)

The 'stable session id, no synthetic fallback' comment on sessionIDFromContext
was rewritten in T023 (the id is attribution-grade only; modern requests have
"" and stdio a constant). The 'background inactivity cleanup calls
RemoveSession' comment in session_store.go had already been removed by #1497;
the remaining claim there (the closed-entry TTL matches the storage inactivity
timeout) is true. The AfterInitialize hook comment now says it never fires in
the modern era.
… (T036a)

Spec 058 FR-028 closes the stdio era gap: mcp-go has no stdio equivalent of
WithStreamableHTTPProtocolVersions and chooses the era per request, so a stdio
client could opt into 2026-07-28 while every HTTP door refused it. An
OnRequestInitialization hook rejects a declared version outside
clientFacingProtocolVersions — the one constant set the HTTP pin now reads
too — with INVALID_REQUEST; legacy stdio and the HTTP surfaces are untouched.
…sultEnvelope (T036b)

FR-016b on /mcp/all: over the live direct routing fixture, an upstream _meta
payload key survives the hop while the hop-scoped serverInfo and
protocolVersion keys are dropped, proving the registered handler calls the
envelope helper.
…T035-T036)

Profiles docs gain a Protocol eras section (set_profile is legacy-era only;
lists never vary per connection; stdio holds the same pin). research.md D11
records the US3 decisions, including per-session token stats staying
legacy-only. tasks.md ticks T020-T036b; ROADMAP.md regenerated.
…ient recorder (review round 1)

Cross-review findings, both latent until T041 lifts the pin: a modern stdio
request (session id "stdio") could still write the session's latest profile
resolution and mark it worked; and the per-identity debounce map grew without
limit on client-supplied names. recordSessionResolution and markWorkIfToolCall
now skip modern requests; the recorder is capped at 1024 identities with a
stale sweep.
…de markSessionWorked (review round 1)

Sol 6.1 findings: UsageAggregate keys per-client usage on metadata.client_name,
which a request-carried identity never wrote; the stdio modern-selection test
accepted any error; the collision test's skip branch was unreachable; the
server-edition isolation test stated a broader scope than it exercised. Adds
TestStatelessList_AgentTokensScopedPerRequest through the production auth
middleware (SC-004) and moves the work-session era gate into markSessionWorked
so every funnel agrees.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1c92711
Status: ✅  Deploy successful!
Preview URL: https://412b9404.mcpproxy-docs.pages.dev
Branch Preview URL: https://058-us3-stateless-list-surfa.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 89.92248% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/mcp.go 87.50% 6 Missing and 3 partials ⚠️
internal/runtime/activity_attribution.go 83.33% 1 Missing and 1 partial ⚠️
internal/server/profile_tool.go 83.33% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

…session (review round 2)

The earlier assertion ran over a session store with no storage manager, where
EnsurePersisted returns "" regardless of the era gate. The new test uses the
storage-backed work-session fixture with a counting resolver and a legacy
control; removing the gate in markSessionWorked fails it.
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 058-us3-stateless-list-surfaces

Available Artifacts

  • archive-darwin-amd64 (32 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (28 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (25 MB)
  • smart-mcp-proxymcpproxy-go4ZF1ZL.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 39437845341 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

This branch has not been deployed

No deployments
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.

2 participants