Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
471b57d
test(server): Spec 058 US3 failing stateless-list and stdio era tests…
Dumbris Oct 20, 2026
f3024f2
feat(server): list-only profile resolution and era-gated session tier…
Dumbris Oct 20, 2026
19f8255
feat(server): set_profile validates the slug before the session guard…
Dumbris Oct 20, 2026
80fb94e
test(server): split set_profile integration tests into legacy and mod…
Dumbris Oct 20, 2026
e9faf0c
test(server): cross-client cancellation isolation under the modern er…
Dumbris Oct 20, 2026
7ababe7
fix(server): drop modern clients' separately posted cancellations (Sp…
Dumbris Oct 20, 2026
934c68b
docs(spec): draft the mcp-go inflight-cancellation issue (T030)
Dumbris Oct 20, 2026
133d974
test(serveredition): two OAuth users with differing entitlements are …
Dumbris Oct 20, 2026
f7ed699
test(server): modern-era client identity must reach activity records …
Dumbris Oct 20, 2026
743ca66
feat(server): capture modern-era client identity from request protoco…
Dumbris Oct 20, 2026
bae91ae
docs(server): correct the two session comments the stateless era made…
Dumbris Oct 20, 2026
f004516
fix(server): pin the stdio surface to the client-facing protocol eras…
Dumbris Oct 20, 2026
aa572dc
test(server): direct-mode handler relays result _meta through proxyRe…
Dumbris Oct 20, 2026
eedc5b9
docs(spec): profiles era section, US3 decision record, tasks ticked (…
Dumbris Oct 20, 2026
cf5d238
fix(server): era-gate the session write paths and bound the modern cl…
Dumbris Oct 20, 2026
aa9bd1f
fix(server): modern records carry metadata.client_name; era gate insi…
Dumbris Oct 20, 2026
1fb4ea6
docs(spec): record cross-review round 1 outcome in research D11
Dumbris Oct 20, 2026
ae226d5
Merge remote-tracking branch 'origin/main' into 058-us3-stateless-lis…
Dumbris Oct 20, 2026
1643dfb
test(server): storage-backed proof that modern requests mark no work …
Dumbris Oct 20, 2026
1c92711
docs(spec): record cross-review round 3 (clean) in research D11
Dumbris Oct 20, 2026
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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,7 @@ graph LR
| Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | |
| Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | |
| Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | |
| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 21/81 (26%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | |
| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 40/81 (49%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | |
| Spec 108 profiles v3: scoped profiles, client binding, scope-aware observability | In progress | P1 | 194/195 (99%) | [108-profiles-v3](./specs/108-profiles-v3/) | |
| 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/) | |
| Planning/docs truth automation | In progress | P2 | — | | |
Expand Down Expand Up @@ -1120,7 +1120,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [055-frontend-major-upgrades](./specs/055-frontend-major-upgrades/) | `shipped` | 23/24 (96%) |
| [056-output-schema-validation](./specs/056-output-schema-validation/) | `in-flight` | 22/24 (92%) |
| [057-in-proxy-profiles](./specs/057-in-proxy-profiles/) | `in-flight` | 20/25 (80%) |
| [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | `in-flight` | 21/81 (26%) |
| [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | `in-flight` | 40/81 (49%) |
| [059-output-sanitisation](./specs/059-output-sanitisation/) | `shipped` | 24/25 (96%) |
| [060-settings-page](./specs/060-settings-page/) | `shipped` | 16/16 (100%) |
| [064-glass-cockpit](./specs/064-glass-cockpit/) | — | — |
Expand Down
22 changes: 19 additions & 3 deletions docs/features/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ When more than one source could select a profile, the highest wins:
|---|---|---|
| 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 |
| 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`) |
| 3 | **session** | The `set_profile` selection. Re-checked on every request |
| 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) |
| 4 | **binding** | The profile of a **switchable** client credential |
| 5 | **anonymous** | `anonymous_profile`, for a caller with no credential |
| 6 | **none** | No profile: all servers |
Expand Down Expand Up @@ -186,8 +186,8 @@ The `set_profile` MCP tool switches the active profile **inside a live session**
{ "active_profile": "research", "servers": ["research-srv"] }
```

- The selection is keyed by the MCP session id (stable per streamable-HTTP / SSE connection) and persists for the lifetime of that session.
- 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.
- 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).
- 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.
- 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.
- 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.
- 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.
Expand All @@ -196,6 +196,22 @@ The `set_profile` MCP tool switches the active profile **inside a live session**

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

### Protocol eras

MCPProxy serves two MCP protocol eras. They differ in exactly one thing that matters to profiles: whether a connection has a session.

| Era | Session | `set_profile` | How to select a profile |
|---|---|---|---|
| **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` |
| **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 |

Rules that hold in both eras:

- `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.
- 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.
- 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.
- 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.

### REST API

| Method & path | Description |
Expand Down
33 changes: 31 additions & 2 deletions internal/runtime/activity_attribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ type ActivityAttribution struct {
// ClientName is the self-reported clientInfo.name: advisory, never
// authoritative (a client can claim any name).
ClientName string
TokenName string
// ClientVersion is the self-reported clientInfo.version that travelled
// with ClientName when the identity was read off the REQUEST rather than
// a session (Spec 058 US3: a 2026-07-28 request never ran initialize, so
// the session store cannot supply it). Empty when the session resolver
// already stamped metadata.client_version.
ClientVersion string
TokenName string
// TokenPrefix is the 12-char display prefix of the calling token. It is
// INTERNAL: persisted on the storage record as the ownership proof (so a
// scoped caller recognises its own policy_decision and prompt_get rows),
Expand Down Expand Up @@ -51,6 +57,7 @@ func (a ActivityAttribution) payload() map[string]any {
set("profile_source", a.ProfileSource)
set("client_id", a.ClientID)
set("client_name", a.ClientName)
set("client_version", a.ClientVersion)
set("token_name", a.TokenName)
set("_token_prefix", a.TokenPrefix)
return out
Expand All @@ -68,14 +75,18 @@ func attributionFromPayload(payload map[string]any) ActivityAttribution {
ProfileSource: getStringPayload(m, "profile_source"),
ClientID: getStringPayload(m, "client_id"),
ClientName: getStringPayload(m, "client_name"),
ClientVersion: getStringPayload(m, "client_version"),
TokenName: getStringPayload(m, "token_name"),
TokenPrefix: getStringPayload(m, "_token_prefix"),
}
}

// applyAttribution copies an attribution onto the first-class record fields.
// ClientName falls back to the session-resolved metadata.client_name that
// withClientInfo already wrote, so the two never disagree on a record.
// withClientInfo already wrote, so the two never disagree on a record. A
// request-carried ClientVersion lands in metadata.client_version — the key
// withClientInfo uses — when the session resolver wrote none, so a modern
// client's record carries the same shape a legacy client's does.
func applyAttribution(rec *storage.ActivityRecord, a ActivityAttribution) {
rec.Profile = a.Profile
rec.ProfileSource = a.ProfileSource
Expand All @@ -88,6 +99,24 @@ func applyAttribution(rec *storage.ActivityRecord, a ActivityAttribution) {
rec.ClientName = name
}
}
// A request-carried identity (Spec 058 US3) also lands under the metadata
// keys withClientInfo writes for a session-resolved one, because readers
// such as UsageAggregate.applyClientCalls key per-client usage on
// metadata.client_name, not on the first-class field.
setMetadataIfAbsent := func(key, value string) {
if value == "" {
return
}
if _, ok := rec.Metadata[key]; ok {
return
}
if rec.Metadata == nil {
rec.Metadata = make(map[string]interface{})
}
rec.Metadata[key] = value
}
setMetadataIfAbsent("client_name", a.ClientName)
setMetadataIfAbsent("client_version", a.ClientVersion)
}

// NewUsageAggregate returns an empty aggregate at the current admission
Expand Down
Loading
Loading