Skip to content

Commit 5a8d9d9

Browse files
authored
feat(mcp): Spec 058 US3 stateless list surfaces (#1691)
## 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](https://claude.com/claude-code)
1 parent 1c639c0 commit 5a8d9d9

23 files changed

Lines changed: 2053 additions & 73 deletions

‎ROADMAP.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ graph LR
10121012
| Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | |
10131013
| Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | |
10141014
| Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | |
1015-
| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 21/81 (26%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | |
1015+
| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 40/81 (49%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | |
10161016
| Spec 108 profiles v3: scoped profiles, client binding, scope-aware observability | In progress | P1 | 194/195 (99%) | [108-profiles-v3](./specs/108-profiles-v3/) | |
10171017
| Spec 109 navigation, scope filters and cross-surface UX consistency | In progress | P1 | 257/258 (100%) | [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | |
10181018
| Planning/docs truth automation | In progress | P2 | — | | |
@@ -1122,7 +1122,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
11221122
| [055-frontend-major-upgrades](./specs/055-frontend-major-upgrades/) | `shipped` | 23/24 (96%) |
11231123
| [056-output-schema-validation](./specs/056-output-schema-validation/) | `in-flight` | 22/24 (92%) |
11241124
| [057-in-proxy-profiles](./specs/057-in-proxy-profiles/) | `in-flight` | 20/25 (80%) |
1125-
| [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | `in-flight` | 21/81 (26%) |
1125+
| [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | `in-flight` | 40/81 (49%) |
11261126
| [059-output-sanitisation](./specs/059-output-sanitisation/) | `shipped` | 24/25 (96%) |
11271127
| [060-settings-page](./specs/060-settings-page/) | `shipped` | 16/16 (100%) |
11281128
| [064-glass-cockpit](./specs/064-glass-cockpit/) | — | — |

‎docs/features/profiles.md‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ When more than one source could select a profile, the highest wins:
110110
|---|---|---|
111111
| 1 | **pin** | A **locked** client credential, or an [agent token](./agent-tokens.md#profile-pinning) with a profile. Server-enforced and immutable for the connection. A pin whose profile was deleted denies everything instead of widening |
112112
| 2 | **url** | `/mcp/p/<slug>`. Authoritative for that request. A locked or confined caller may name only its own profile (or, if switchable, one in `switchable_to`) |
113-
| 3 | **session** | The `set_profile` selection. Re-checked on every request |
113+
| 3 | **session** | The `set_profile` selection. Re-checked on every request. Legacy protocol era only, and never consulted for a `*/list` response — see [Protocol eras](#protocol-eras) |
114114
| 4 | **binding** | The profile of a **switchable** client credential |
115115
| 5 | **anonymous** | `anonymous_profile`, for a caller with no credential |
116116
| 6 | **none** | No profile: all servers |
@@ -189,8 +189,8 @@ The `set_profile` MCP tool switches the active profile **inside a live session**
189189
{ "active_profile": "research", "servers": ["research-srv"] }
190190
```
191191

192-
- The selection is keyed by the MCP session id (stable per streamable-HTTP / SSE connection) and persists for the lifetime of that session.
193-
- It applies to subsequent `retrieve_tools`, `call_tool_*`, `code_execution` and direct-mode (`server__tool`) calls on the base `/mcp` endpoint — `retrieve_tools` searches the profile's per-profile index directly.
192+
- The selection is keyed by the MCP session id of a **legacy-era** connection (MCP `2025-11-25` and earlier, where streamable-HTTP and SSE bind a stable id at `initialize`) and persists for the lifetime of that session. The `2026-07-28` revision has no sessions, so `set_profile` cannot persist anything there — see [Protocol eras](#protocol-eras).
193+
- It applies to subsequent `retrieve_tools`, `call_tool_*`, `code_execution`, direct-mode (`server__tool`) calls and `prompts/get` on the base `/mcp` endpoint — `retrieve_tools` searches the profile's per-profile index directly. It does **not** change what `tools/list` or `prompts/list` return: list responses are the same for every connection, whatever it selected (MCP `2026-07-28` forbids per-connection list variation, and MCPProxy applies that rule in both eras). The one exception during the deprecation window is the visibility of the built-in administration tools (`profiles`, `credentials`, `code_execution`, `upstream_servers`, `quarantine_security`), which a legacy session's selected profile still governs at list time.
194194
- Passing an empty string (`""`) clears the selection and returns to all servers. `active_profile` always reports the **stored session selection** — `""` after a clear, even for a token with a [`profile_pin`](./agent-tokens.md#profile-pinning) — while `servers` reports the **effective scope** the session can actually reach after the update: the pin's servers for a pinned token (nothing once the pinned profile has been deleted), the URL profile on a `/mcp/p/<slug>` endpoint, otherwise the selection or every configured server.
195195
- The `servers` list is always bounded by the caller's credential, using the same rule that scopes `retrieve_tools`: for an [agent token](./agent-tokens.md) scoped to specific servers it is the intersection of the effective profile (resolved pin > URL > session, see [Which profile applies](#which-profile-applies)) with the token's `allowed_servers`, so a token restricted to one server is never told about the others. On a `/mcp/p/<slug>` endpoint the URL still governs the request, so `set_profile("other")` there stores `other` as `active_profile` but reports `<slug> ∩ allowed_servers` in `servers`. API-key and socket callers see the full lists.
196196
- An unknown slug is rejected. An administrator (API key, socket, anonymous back-compat) gets the discovery affordance: `unknown profile '<slug>' (available: research, deploy)`. An agent token gets `unknown profile '<slug>'` with no list at all: it may select only the profiles overlapping its `allowed_servers` (or its pin while the pin still has reach), and a profile entirely outside its reach (an empty profile, a profile whose servers are all outside `allowed_servers`, or the token's own pin once it no longer exists or no longer overlaps the token's servers) is rejected with that same error rather than confirmed as existing. A pinned token asking for any profile OTHER than its pin (see [profile pinning](./agent-tokens.md#profile-pinning)) is rejected with that same `unknown profile '<slug>'` error too — never a distinct "pinned to..." message, which would let the token confirm from the wording alone that it is pinned, and to what, from a refusal aimed at a different slug. The check looks only at the requested slug (and the token's pin) and tests the token's own `allowed_servers` against that profile's precomputed server set — its cost does not depend on how many other profiles are configured, on how many servers the requested profile declares or on how many servers are configured at all, only on the size of the token's own grant — so a token cannot learn which profiles or servers exist, or whether it is pinned, from `set_profile`, by body or by timing.
@@ -199,6 +199,22 @@ The `set_profile` MCP tool switches the active profile **inside a live session**
199199

200200
`set_profile` is available on the default `/mcp` server and the `call_tool` / `code_execution` routing-mode servers.
201201

202+
### Protocol eras
203+
204+
MCPProxy serves two MCP protocol eras. They differ in exactly one thing that matters to profiles: whether a connection has a session.
205+
206+
| Era | Session | `set_profile` | How to select a profile |
207+
|---|---|---|---|
208+
| **Legacy** — `2025-11-25` and earlier (the only era served on the client-facing surfaces today) | A session id is bound at `initialize` | Works as described above; the selection lives for the session | Pin or binding on the credential, `/mcp/p/<slug>`, or `set_profile` |
209+
| **Modern** — `2026-07-28` | None. Every request carries its own identity in `_meta` and headers | Refused. The reply names the per-request form, for example `connecting to /mcp/p/research`, so an agent can act on it. An unknown slug is still answered `unknown profile '<slug>'`, exactly as in the legacy era | Pin or binding on the credential, or `/mcp/p/<slug>` — both are carried by every request |
210+
211+
Rules that hold in both eras:
212+
213+
- `tools/list` and `prompts/list` never vary per connection. The identity-scoped parts of a response come from the request (credential, URL path, `_meta`), never from what a connection did earlier.
214+
- The URL form `/mcp/p/<slug>` is request-carried identity: the slug is read from every request's path, nothing is bound at `initialize`, so it keeps working without a session.
215+
- A stdio client is held to the same era pin as the HTTP endpoints: while the client-facing surfaces serve only the legacy era, a stdio request that declares `2026-07-28` in `_meta` is refused with an `Invalid Request` error naming the served versions, instead of being served statelessly on a transport that reports a constant session id.
216+
- Activity records of a modern client still carry its self-reported name and version, read from the request's `_meta` rather than from a session.
217+
202218
### REST API
203219

204220
| Method & path | Description |

‎internal/runtime/activity_attribution.go‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ type ActivityAttribution struct {
2323
// ClientName is the self-reported clientInfo.name: advisory, never
2424
// authoritative (a client can claim any name).
2525
ClientName string
26-
TokenName string
26+
// ClientVersion is the self-reported clientInfo.version that travelled
27+
// with ClientName when the identity was read off the REQUEST rather than
28+
// a session (Spec 058 US3: a 2026-07-28 request never ran initialize, so
29+
// the session store cannot supply it). Empty when the session resolver
30+
// already stamped metadata.client_version.
31+
ClientVersion string
32+
TokenName string
2733
// TokenPrefix is the 12-char display prefix of the calling token. It is
2834
// INTERNAL: persisted on the storage record as the ownership proof (so a
2935
// scoped caller recognises its own policy_decision and prompt_get rows),
@@ -51,6 +57,7 @@ func (a ActivityAttribution) payload() map[string]any {
5157
set("profile_source", a.ProfileSource)
5258
set("client_id", a.ClientID)
5359
set("client_name", a.ClientName)
60+
set("client_version", a.ClientVersion)
5461
set("token_name", a.TokenName)
5562
set("_token_prefix", a.TokenPrefix)
5663
return out
@@ -68,14 +75,18 @@ func attributionFromPayload(payload map[string]any) ActivityAttribution {
6875
ProfileSource: getStringPayload(m, "profile_source"),
6976
ClientID: getStringPayload(m, "client_id"),
7077
ClientName: getStringPayload(m, "client_name"),
78+
ClientVersion: getStringPayload(m, "client_version"),
7179
TokenName: getStringPayload(m, "token_name"),
7280
TokenPrefix: getStringPayload(m, "_token_prefix"),
7381
}
7482
}
7583

7684
// applyAttribution copies an attribution onto the first-class record fields.
7785
// ClientName falls back to the session-resolved metadata.client_name that
78-
// withClientInfo already wrote, so the two never disagree on a record.
86+
// withClientInfo already wrote, so the two never disagree on a record. A
87+
// request-carried ClientVersion lands in metadata.client_version — the key
88+
// withClientInfo uses — when the session resolver wrote none, so a modern
89+
// client's record carries the same shape a legacy client's does.
7990
func applyAttribution(rec *storage.ActivityRecord, a ActivityAttribution) {
8091
rec.Profile = a.Profile
8192
rec.ProfileSource = a.ProfileSource
@@ -88,6 +99,24 @@ func applyAttribution(rec *storage.ActivityRecord, a ActivityAttribution) {
8899
rec.ClientName = name
89100
}
90101
}
102+
// A request-carried identity (Spec 058 US3) also lands under the metadata
103+
// keys withClientInfo writes for a session-resolved one, because readers
104+
// such as UsageAggregate.applyClientCalls key per-client usage on
105+
// metadata.client_name, not on the first-class field.
106+
setMetadataIfAbsent := func(key, value string) {
107+
if value == "" {
108+
return
109+
}
110+
if _, ok := rec.Metadata[key]; ok {
111+
return
112+
}
113+
if rec.Metadata == nil {
114+
rec.Metadata = make(map[string]interface{})
115+
}
116+
rec.Metadata[key] = value
117+
}
118+
setMetadataIfAbsent("client_name", a.ClientName)
119+
setMetadataIfAbsent("client_version", a.ClientVersion)
91120
}
92121

93122
// NewUsageAggregate returns an empty aggregate at the current admission

0 commit comments

Comments
 (0)