Skip to content

fix(mcp): close the review findings on the mcp 2.x port - #552

Merged
mocha06 merged 15 commits into
refactor/543-mcp-2x-portfrom
rc-547/fix/mcp-2x-review-fixes
Jul 31, 2026
Merged

fix(mcp): close the review findings on the mcp 2.x port#552
mocha06 merged 15 commits into
refactor/543-mcp-2x-portfrom
rc-547/fix/mcp-2x-review-fixes

Conversation

@mocha06

@mocha06 mocha06 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #547, targeting refactor/543-mcp-2x-port so the port and these fixes stay separately reviewable.

Motivation

#547 was reviewed across four independent lenses: behavior parity, the request-scoped plumbing rewrite, test-suite integrity, and runtime validation against production. The port itself held up well, and this branch does not relitigate it. What remained at f5b28ca were three defects the suite could not see and a set of smaller gaps the reviews left open.

The one that mattered most: a default mcp 2.x Client uses mode="auto", opens with the enveloped server/discover, and lands on protocol revision 2026-07-28. That revision has no server-to-client back channel, so create_card and fill_card_phase_fields returned a JSON-RPC protocol error instead of a tool result. #547's own falsification check drives a client offering protocolVersion in initialize, which is the handshake leg and does correctly negotiate 2025-11-25, so it could not see this. Nothing in the suite asserted a negotiated revision either, which is why the failure surfaced only as a cluster of confusing elicitation failures.

Outcome

Elicitation is gated on the back channel rather than the advertised capability. supports_elicitation now requires ServerSession.can_send_request, which is the SDK's own gate, and _elicit_field_details absorbs NoBackChannelError for the residual cases the guard cannot see. Both tools fall back to the caller's supplied fields, the same path they already took for a client advertising no elicitation. This also covers stateless HTTP, which has no back channel at any revision, so a version-string check would not have been enough.

A short-circuiting middleware no longer leaks a 2026-era field onto a legacy connection. The SDK shapes a handler's result per negotiated revision inside call_next, so a middleware returning without awaiting it was never shaped, and CallToolResult's resultType default reached the wire on connections that negotiated 2025-11-25. short_circuit_error now returns the wire dict shaped by the SDK's own serialize_server_result, so there is no hardcoded assumption about which keys belong at which revision.

Per-session credential binding discriminates per user. mcp 2.0 compares a (client_id, issuer, subject) triple to decide whether a request may use an existing session, and only client_id was supplied. For the hosted server that is the single public pipefy-mcp OAuth client every end user authorizes through, so the check could not tell two users apart and either could present the other's mcp-session-id. The JWT sub and iss are now carried on the fields the comparison reads.

Two previously unasserted facts are pinned: the negotiated protocol revision and the advertised serverInfo. Each expected literal is cross-checked against mcp_types.version, so a failure distinguishes "this server's negotiation changed" from "the SDK's revision set changed".

Smaller, all verified against the tree rather than taken from the reviews: starlette is now declared where four modules import it, instead of relying on it arriving transitively through the SDK. A tools/call that names no tool is logged under <unnamed> rather than sharing a blank dashboard bucket with real tool names, while ctx.tool_name stays the raw view of what the client sent. The last FastMCP references outside the changelog are gone, including a documented FASTMCP_LOG_LEVEL variable that does not exist. runtime.py no longer claims to own the transport allowlist it stopped holding. And the suite is back to a clean warning baseline behind one scoped ignore, matched on the message rather than the class so the SDK's other deprecation subjects still surface.

The changelog entry for the moved transport arguments is corrected: it listed port, which only ever reached the uvicorn bind, and omitted json_response, which genuinely moved. That omission matters for an embedder that calls streamable_http_app() directly and expects plain JSON rather than SSE framing.

Notes

Considered and rejected: pinning the server to 2025-11-25. It is the obvious fix for the negotiation finding and it is wrong. Measured on both transports: over streamable HTTP a mode="auto" client falls back cleanly, but on a duplex connection the connect fails outright with -32601, because send_discover stamps the modern envelope and the opening request permanently locks the connection's protocol era. This package ships stdio as the default for local installs, so pinning would have fixed hosted HTTP callers and broken every default local client. There is also no supported API for it; the only lever is the private _lowlevel_server, which the SDK marks with a TODO to make public.

Three corrections to the review that prompted this branch, recorded because they are wrong in a public comment on #547. The read-side back-channel concern is inert: Context.elicit passes related_request_id, and the SDK routes such messages to that request's own POST stream, so holding another session's id does not expose its elicitation. The real variant is write-side response injection, currently masked by the wrapper's json_response=True, which is itself one of the calls that breaks under 2.0. The review also named pipe_config and service_account as the eliciting tools; neither elicits, only create_card and fill_card_phase_fields do, and that misattribution came from #547's own _mcp_compat docstring, corrected here. Finally, the isError:true line in the review's wire capture is from a raised exception, not a Pipefy-style tool_error envelope, which is a successful call reporting a business error and carries isError:false.

Known gap, deliberately not fixed here. On a 2026-07-28 connection the SDK also stamps _meta.serverInfo inside _serialize, which a short-circuit skips exactly like resultType did. Unlike resultType it cannot be fixed from short_circuit_error, because the stamp needs a server-scoped value unreachable from a ToolCallContext. Not reachable today given what the deployment negotiates, but whoever adopts the modern revision should know a governance stop will silently omit it.

Out of scope, and still the prerequisite for any deployment. The hosted wrapper breaks at three call sites under 2.0, and the shortcut fix (deleting the two app.settings assignments and leaving streamable_http_app() argument-less) produces a pod that passes its readiness probe while rejecting every proxied MCP request with 421. Combined with the upstream session-leak issue, that state also strands memory per rejected request. That work belongs in the wrapper repo.

Gates on this branch: ruff check and format across all five src trees and the changed files, lint-imports 2 kept 0 broken, bump_version.py verify, uv sync --locked, uv build --all-packages --wheel, and pytest -m "not integration" at 4029 passed against a 4008 baseline.

mocha06 added 10 commits July 31, 2026 11:37
…pability [#543]

`supports_elicitation` read `client_params.capabilities.elicitation`, which a
client on protocol revision 2026-07-28 still sets even though that revision has
no server-to-client channel. A default `Client` (`mode="auto"`) negotiates it, so
the guard passed, `ctx.elicit` raised `NoBackChannelError`, and `create_card` /
`fill_card_phase_fields` returned a JSON-RPC protocol error instead of a tool
result. Measured against this branch: `mode="auto"` negotiates 2026-07-28 and
both tools raise; `mode="legacy"` negotiates 2025-11-25 and both work.

The guard now also requires `ServerSession.can_send_request`, which is the SDK's
own gate: `DispatchContext.send_raw_request` raises `NoBackChannelError` exactly
when it is False, and the 2026-07-28 single-exchange dispatch context hardcodes
it False. A session that does not expose the flag is still treated as sendable,
so older SDKs and test doubles keep working.

`_elicit_field_details` absorbs `NoBackChannelError` and returns None for the
residual cases the guard cannot cover: the channel closes between the check and
the request when the inbound request finishes, and an unmeasurable session passes
the check. A None result takes the same path as a client that advertises no
elicitation at all, so the supplied `fields` are used.
… serverInfo [#543]

Nothing in the suite asserted either. `protocolVersion` appeared only as a
request input, `2026-07-28` only in a docstring, and `serverInfo` not at all. The
gap has a concrete cost: drop `_mcp_compat`'s `mode="legacy"` pin and the only
failures are seven elicitation tests in `tools/test_pipe_tools.py`, which read as
an elicitation bug rather than as "the negotiated revision moved to one with no
back channel".

`test_protocol_handshake.py` asserts that a handshake client reaches 2025-11-25,
that a default client reaches 2026-07-28, and that both see `serverInfo` carrying
`pipefy` and the package version. Each literal is cross-checked against
`mcp_types.version`, so a failure separates "this server's negotiation changed"
from "the SDK's revision set changed underneath it".

Also corrects the `_mcp_compat` docstring: it named `pipe_config` and
`service_account` as the elicitation tests blocking the flip. Neither elicits.
`create_card` and `fill_card_phase_fields` hold the only `ctx.elicit` call in the
package, and the destructive-tool guard deliberately avoids elicitation.
… client

`mcp` 2.0 records the authorization context that created a Streamable HTTP
session and answers 404 when a later request presents a different one. That
context is the `(client_id, issuer, subject)` triple `principal_components`
derives from the `AccessToken` the token verifier returns.

`JwtTokenVerifier` supplied only `client_id`, so the triple collapsed onto
`azp`. Every hosted caller authorizes through the one public `pipefy-mcp`
OAuth client (see the install command in README.md), so `azp` is a single
constant for the whole user base and the check could not tell two users
apart: any authenticated caller could present any other caller's
`mcp-session-id`. The same triple also binds sealed `requestState`, via the
`RequestStateBoundary` middleware `MCPServer` installs by default.

Map the JWT `sub` onto the SDK's own `subject` field and carry `iss` on
`claims`, which is where `principal_components` reads the issuer that
namespaces the subject. Only `iss` is copied: the rest of the payload has no
consumer, and `AccessToken` is reachable off `request.scope["user"]` from
anywhere in the ASGI stack.

`subject` is a base `AccessToken` field in 2.0, so `PipefyAccessToken` (which
existed only because 1.x's `AccessToken` had neither field) is redundant and
goes, leaving one stored field instead of two names for the JWT `sub`. Its
sole production reader was the request log middleware.

Not a regression: 1.x had no session ownership check at all, and no `subject`
or `claims` to populate. The port adopts the mechanism, so this wires it.
`short_circuit_error` returned a bare `types.CallToolResult`, and v2's
`CallToolResult` adds a `result_type` field defaulting to `'complete'`. A real
tool call is shaped for the wire by `ServerRunner._serialize`, which sieves
2026-era fields off a connection that negotiated an older revision; a middleware
that returns without awaiting `call_next` skips that path entirely and is dumped
by `_dump_result` only. Measured on the wire in one session at 2025-11-25:

    real tool error : {"content":[...],"isError":false}
    short-circuit   : {"content":[...],"structuredContent":{...},"isError":true,
                       "resultType":"complete"}

`resultType` is not in the 2025-11-25 schema. Nothing objected, because the
client's own surface validation carries `extra="ignore"`.

The SDK's `_serialize` docstring states the rule: a middleware short-circuiting
without `call_next` owns its result, envelope included. So the fix is to shape
the result where it is built, using the SDK's own supported per-revision shaper
(`mcp.types.methods.serialize_server_result`) rather than stripping a key by
hand. It drops `resultType` on a handshake revision and keeps it at 2026-07-28,
where the spec requires it, so the helper stays correct if this deployment ever
serves the modern era; hardcoding either answer would not.

`short_circuit_error` therefore takes the `ctx` it needs the revision from and
returns the wire dict. `ToolCallContext` gains `protocol_version`.

The existing tests could not see this: they compared a short-circuit to an
in-tool error on the decoded envelope only, and never returned a short-circuit
through a client. `test_short_circuit_matches_the_in_tool_error_on_the_wire` now
drives both branches through a real client and reads the raw response `result`
(the parsed `CallToolResult` re-adds `resultType` on any later dump, so it cannot
show what crossed the wire), asserting the field set of each.

Also fixes the sibling fixture problem in the tool-log tests. `_ok_result()`
built a `CallToolResult`, a shape production never produces at that point:
instrumenting the live chain shows every result arriving at a middleware is a
wire dict, success included. Same class of mismatch as the outcome-logging
regression fixed in 0a10a83, on the success path that fix did not touch. It is
latent today (the middleware reads the result only through `result_is_error`),
but it is a fixture that exercises a branch no real call takes. The
short-circuit-model test keeps covering `result_is_error`'s model branch by
building the model directly, which is what an embedder's hand-built result
actually looks like.
A `tools/call` with no `name` in params reaches the middleware chain on purpose:
middleware reads raw pre-validation params, so a governance layer counting calls
sees the ones that go on to fail request-layer validation, and
`test_context_tolerates_params_without_a_name` pins that. The firing is not the
problem. The label is: the line went out as `{"event":"tool_call","tool":"",...}`,
so every such call lands in the blank bucket of any dashboard or metric that
facets by tool name, adjacent to the real tools.

Substitutes `<unnamed>` for the label only. `ctx.tool_name` stays `""`: it is the
raw view of what the client sent, and a governance middleware matching on it must
not see a name the client did not send. Angle brackets cannot collide with a tool
name, so the bucket reads as what it is.
Four modules import starlette directly (the `Starlette` return type of
`wire_hosted_observability`, and the `Request` annotation on `McpRuntime`,
`RequestScopedIdentity`, and `StartupIdentity`), but it was declared in no
pyproject and arrived only transitively through `mcp`. An SDK release that
drops or vendors starlette would break those imports with nothing to catch it.

The floor is what the SDK resolves today (1.3.1); `<2` matches how the repo
bounds `pydantic` and `gql`, and is the bound the SDK's own `starlette>=0.27`
does not provide. Declared on `packages/mcp` only, the one package whose
source and tests import it.
`docs/config.md` told operators to prefer `PIPEFY_MCP_LOG_LEVEL` over a
`FASTMCP_LOG_LEVEL` env var; no such variable exists in the SDK at any version
in this repo's history (grepping the installed 2.0.0 for "fastmcp" returns
nothing), so the sentence sent readers looking for a knob that was never there.
The row now names what the value actually reaches: `MCPServer(log_level=...)`
and uvicorn.

`docs/architecture.md` listed `core/fastmcp_tool_lifecycle.py` as a driving
adapter; the file is deleted, and the tool-table edits it did now go through
the public `MCPServer.remove_tool` from `tools/registry.py`, already inside the
`tools/` bullet.

`AGENTS.md` attributed tool-argument coercion to FastMCP. `MCPServer` builds a
pydantic model per tool from the signature (`func_metadata` in
`mcp.server.mcpserver.tools.base`), so the boundary claim holds under the new
name.

CHANGELOG hits are left alone: they are dated history describing the 1.x
versions they shipped against.
…hange [#543]

`transport_security` left `McpRuntime` in this PR, but three docstrings still
described the old arrangement, now in the opposite direction from the drift they
were written to fix. The class docstring claimed the runtime turns settings into
"the HTTP transport's DNS-rebinding allowlist" and feeds one parsed resource to
"both the inbound-auth and the allowlist builders, so they cannot disagree";
`for_profile` claimed it "builds the transport allowlist from that one parsed
resource"; the closing line listed the allowlist among what the runtime owns.
None is true: the runtime parses `resource_server_url` for the inbound-auth pair
only, and the allowlist is resolved on the serving path. The class docstring now
says where it went instead of implying it is here.

`transport_security_for` claimed "two callers need the result at different
times"; only `run_server` calls it. The reason to keep the parse and the build
paired survives the change, so it is restated as what it is now: keeping the
host derivation in one place, since `for_profile` parses the same URL.

Also drops `runtime.transport_security = None` from the `mocked_runtime`
fixture. It set an attribute `McpRuntime` no longer has and nothing reads; the
fixture's `inbound_auth = None` still does real work (`build_pipefy_mcp_server`
unpacks it), and the docstring already described only that.
The suite emits 997 `MCPDeprecationWarning` lines at this head where `dev`
emitted zero. The SDK deprecated the logging capability in its 2026-07-28
revision (SEP-2577) and the 68 untouched `ctx.debug` / `ctx.info` calls each
trip it. Deferring that migration is right (replacing the calls stops the client
receiving those lines, a behavior change owed its own review), but with no filter
configured the deprecation buries any genuinely new warning, which is the thing
a reviewer reads the tail of a suite run for.

Scoped to the one message rather than the class, so the SDK's other
`MCPDeprecationWarning` subjects (sampling, roots) still surface if we ever reach
them. The failure mode is bounded either way: when the SDK removes the capability
the calls break outright, so the ignore can hide the interim warning and nothing
more.

Two copies because pytest applies a single config: CI runs `uv run pytest` from
the root, and a package-local run reads `packages/mcp/pyproject.toml`.
@mocha06 mocha06 added enhancement New feature or request hosted-mcp Hosted, multi-user, on-behalf-of MCP server profile labels Jul 31, 2026
@mocha06
mocha06 requested a review from adriannoes July 31, 2026 15:09

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ef82857b and re-verified with a second pass: fresh worktree at head, every finding re-checked adversarially, plus the CI gates this PR gets no Actions run for (base is not main/dev).

Verdict: merge with notes into refactor/543-mcp-2x-port, then #547 into dev. No blocker. One authorization residual worth an explicit decision before #548 is treated as done.

What passes

Gate Result
ruff check packages/mcp pass
pytest packages/mcp/tests -m "not integration" 1776 passed, 5 skipped, 4 deselected
Full repo suite (what CI's test step runs) 4029 passed, 5 skipped, 34 deselected, zero warning records
scripts/bump_version.py verify pass
ruff check + ruff format --check, full tree pass, 211 files formatted
Banned upward imports (sdk, auth, infra) pass
lint-imports in packages/mcp 68 files, 216 dependencies, 2 contracts kept, 0 broken
uv build --all-packages --wheel 5 wheels
Clean-venv wheel install, deps resolved from PyPI installs and both entry points run

The clean-venv install is the one gate that resolves past uv.lock. It pulls uvicorn 0.52.0 (lock: 0.34.0), httpx2 2.9.1 (lock: 2.6.0) and typing_extensions 4.16.0, and the server works on all of them.

I also ran the live smoke that the earlier pass had to skip, launching the server from the worktree at head so provenance is not in question. The initialize handshake negotiates 2025-11-25 with serverInfo {name: pipefy, version: 0.4.0-beta.1} and tools/list returns 187 tools; --toolsets power returns 9; get_tool_categories returns isError=false. Same result from the wheel install. So the protocol and serverInfo pins hold live, not only in tests.

The one thing I would decide before closing #548

_to_access_token maps subject from sub only when it is a str, so sub absent, sub: null, and any non-string sub all become subject=None, while sub: "" and whitespace-only are kept verbatim. Under the hosted single public client (azp=pipefy-mcp) plus one issuer, two bearers that share a shape produce an identical (client_id, iss, subject) triple, and the SDK then serves one user's request on the other's session. Reproduced at head against the real StreamableHTTP manager and the real verifier: ping and tools/list both return 200 with real results on the other principal's session. The sharpest case is two distinct numeric subs (101 vs 102): the IdP does distinguish them and the mapping discards the distinction. Mixed shapes do not collapse (sub absent vs sub: "" correctly gets a 404), and distinct string subs are correctly isolated.

Two things to note in the PR's favour, which is why this is a note and not a change request. First, the base mapping put the claim on PipefyAccessToken.sub and never set the SDK's subject or claims, so principal_components compared (client_id, None, None) and every hosted user of pipefy-mcp shared sessions, distinct well-formed subs included (reproduced against the base mapping). This PR strictly narrows the hole. Second, the token is signature-protected, so nobody forges a sub-less bearer; it takes an IdP that mints one, for both parties, plus knowledge of a uuid4().hex session id.

What makes it still worth a decision: JwtValidator requires only exp, verify_audience defaults to False for the same-audience interim and typ is never checked, so any realm-signed RS256 JWT with iss and exp reaches the mapping. Keycloak lightweight access tokens and the 25+ basic-scope removal are documented ways to get a sub-less access token out of the default issuer, so "the IdP always sends sub" is a configuration fact rather than an invariant. And the same collapsed triple is the SDK's default requestState principal binding (authenticated_principal, installed unconditionally via RequestStateSecurity.ephemeral()), so cross-user replay of sealed elicitation state rides on the same seam, and this PR touches the elicitation paths.

One caveat on the obvious fix: rejecting a missing sub outright would contradict the azp or client_id or sub precedence you documented right above, whose point is to accept a bearer whose only identity claim is client_id. So my suggestion is to split it:

  • Reject empty and whitespace-only sub in the mapping. Never a valid RFC 9068 subject, so this is safe on its own.
  • For absent or non-string sub, either fail closed for ownership (derive a per-credential discriminator such as jti or a hash of the bearer for subject, so a second bearer of the same client gets a 404 instead of session reuse; note subject is also read by request_log_middleware, so keep it recognizable in logs), or write the waiver down with a test that pins today's behavior for two azp-only bearers and two sub: "" bearers.

Either way, the comment that ends "the comparison then falls back to the remaining components, as the SDK documents" reads as cover for a fail-open. The SDK's fallback is safe when the remaining components identify one principal; six lines above, the same comment explains why they do not here.

Changelog: the polymorphic bullet contradicts itself

CHANGELOG.md:19 still says short_circuit_error returns a CallToolResult model. Line 20, packages/mcp/AGENTS.md:388-389, and the signature (-> dict[str, Any]) all say it returns the wire dict shaped by serialize_server_result. Both bullets are new in this PR, so this is internal rather than leftover 1.x prose. result_is_error's own docstring already has the wording to lift.

Same edit is needed at CHANGELOG.md:14, which still ends "a middleware returns a CallToolResult rather than a types.ServerResult". The seam is typed Awaitable[HandlerResult] now, and a short-circuiting middleware returns a dict. Line 14 is the worse of the two to leave wrong, since it is the bullet enumerating the migration steps a middleware author reads first, and while you are there, that same "two shape changes for a middleware author" list omits the new required ToolCallContext.protocol_version. Since the changelog is the lockstep channel for embedders, these two lines matter more than the lockstep itself.

Embedder lockstep, for the record

Any outside-repo extra_tool_middlewares calling short_circuit_error("...") 1.x-style has to pass ctx. In-repo callers are all updated. Worth noting only because the break, while loud (TypeError: missing 1 required positional argument), fires lazily: it surfaces the first time the short-circuit branch actually executes, so an embedder without deny-path coverage ships it undetected. result_is_error handles both result shapes for a wrapper that inspects the flag.

Small stuff

  • The back-channel gate covers one more case than the docs say. supports_elicitation's docstring (mcp_capabilities.py:20-22) and CHANGELOG.md:50 both name stateless HTTP as the non-revision case, but the SDK stamps can_send_request = not is_json_response_enabled (streamable_http.py:239), so a stateful deployment serving json_response=True also loses elicitation, at 2025-11-25 included. Measured through wire_hosted_observability over the real ASGI stack: json_response=False gives True at 2025-11-25, json_response=True gives False. No impact on the served path, since the default is False and run_server never passes it. Worth a clause anyway, because json_response is precisely the flag CHANGELOG.md:16 tells an embedder to pass to streamable_http_app().
  • test_protocol_handshake.py:56 carries runtime.transport_security = None in its copied mocked_runtime fixture, the same dead line 658997b removed from test_server.py in this PR for setting an attribute McpRuntime no longer has. The fixture is a MagicMock, so nothing fails and nothing catches it.
  • core/runtime.py:88-90, in the for_profile docstring this PR rewrote: "selects the per-profile identity from it (and, for remote, the inbound-auth pair derived from that same resource)" reads as if the identity comes from the parsed resource on both profiles. _local_identity(settings) does not take it, and on the local profile the parsed resource is discarded rather than stored.
  • uvicorn is imported and used on the --transport http path (server.py:191) but not declared. It is guaranteed only transitively by mcp, whose requirement carries a sys_platform != 'emscripten' marker. Your own starlette comment in this PR writes the policy that covers it. Nothing breaks today (verified against 0.52.0), so this is consistency. anyio and typing_extensions are the same class, weaker because their transitive guarantees are unconditional.
  • CHANGELOG.md:26 and pyproject.toml:69 say 68 ctx.debug/ctx.info calls across 11 tool modules producing ~997 warnings. At head it is 64 calls across 10 modules and 1010 warning records. The 68/11 figure was already wrong at the base (63/10 there). Dropping the exact numbers is probably more durable than chasing them.
  • CHANGELOG.md:15 hardcodes serverInfo.version as 0.4.0-beta.1. Correct for this head and for dev, but main already shipped beta.2, and test_protocol_handshake.py asserts against pipefy_mcp.__version__ dynamically, so nothing catches the literal after the next back-merge.
  • On revision 2026-07-28, a short-circuited result omits the _meta.serverInfo stamp that ServerRunner._serialize adds on the call_next path, so a modern client sees the stamp on real tool results and not on middleware denials. ToolCallContext has no route to server_info_stamp, so I would just say so in the short_circuit_error docstring, which already explains the shaping obligation, rather than restating the server info.
  • create_card and fill_card_phase_fields differ on the no-back-channel path in a way worth one line each in their docstrings: the promise "an interactive form is presented" is false whenever the session cannot carry server-to-client requests, which the client cannot observe. With fields omitted, fill_card_phase_fields returns {"success": true, "message": "No fields to update."} while required phase fields go uncollected, and create_card sends {} on to the API. The gate-False path logs nothing, while the NoBackChannelError absorb at least emits ctx.debug. No test covers elicitation-unavailable plus required-fields-present plus fields-omitted; the four tests in TestElicitationWithoutABackChannel all supply fields.

Cleared

The new tests hold up under mutation. Replacing _mcp_compat's mode="legacy" pin makes exactly seven elicitation tests in test_pipe_tools.py fail, matching the docstring's claim, and makes the handshake test fail with '2026-07-28' == '2025-11-25', so the protocol pin is genuinely can-fail. test_session_ownership.py drives the real StreamableHTTPSessionManager rather than a stub and asserts the same-user 200 control alongside the hijack 404. The filterwarnings entry is load-bearing: test_pipe_tools.py alone emits 198 warnings with it off and 0 with it on.

Security sweep over the diff found nothing: no bearer, claim value, or tool argument value reaches any log (verified with an in-process ASGI harness capturing the structured logger and the root logger at DEBUG), transport_security's diff is docstring-only with enforcement live, session ids stay uuid4().hex with unknown client-supplied ids answered 404, and dropping email/name/groups from the claim copy narrows what request.scope["user"] exposes. Every other Unreleased claim checks out against the code, including the four-modules-import-starlette rationale, the whole lockfile transition list, and the "port is unaffected" note. The starlette constraint is compatible with the SDK's own floor, and the filterwarnings entry is message-scoped: a different MCPDeprecationWarning subject still raises under simplefilter("error").

mocha06 added 5 commits July 31, 2026 13:25
…ipal

`subject` is one third of the `(client_id, iss, subject)` triple
`principal_components` derives, which is what session ownership and
`requestState` binding compare. The mapping supplied it only when `sub` was a
`str`, so every other shape -- a numeric `sub`, a JSON `null`, an absent claim --
normalized to the same `subject=None`.

That guard was inherited verbatim from the `PipefyAccessToken.sub` field it
replaced, where `sub` fed request logging only and degrading to `None` cost
nothing. Once the field became an ownership component, the same line became a
fail-open: two bearers of the one public `pipefy-mcp` OAuth client whose `sub`
collapsed the same way produced an identical triple, and the manager served one
caller's request on the other's session (reproduced against the real
`StreamableHTTP` manager: `ping` and `tools/list` both 200 for `sub` 101 vs 102).

Normalize in `_subject` instead:

- coerce a non-string `sub` with `str` rather than dropping it, so an IdP that
  emits a numeric `sub` keeps distinguishing its users. The SDK's own
  `principal_components` coerces a non-string `iss` the same way;
- reject an empty or whitespace-only `sub`, which is never a valid RFC 9068
  subject. Rejecting rather than mapping to `None` is deliberate: `None` is the
  no-subject class, so folding a blank `sub` into it would make two blank-`sub`
  bearers share a context where today they do not;
- carry a non-blank value verbatim, never stripped, so a padded `" u1"` is not
  fused with `"u1"` on a guess.

A bearer carrying no `sub` at all still maps to `subject=None`, and two such
bearers of one client still share a context. That is waived, not overlooked: the
identity precedence exists to accept a bearer whose only claim is
`azp`/`client_id`, which names no end user, and it is the degradation the SDK
documents for an unsupplied component. Deriving a `subject` from `jti` or a hash
of the bearer would put a credential id in the field every reader takes for an
end user (`request_log_middleware` logs it as `sub`) and would break a caller's
own session on every token refresh. The waiver is pinned against the real session
manager so changing it takes a decision.

Also drops the comment that described the old behavior as a documented fallback.
It read as cover for a fail-open and contradicted the paragraph six lines above
it, which says `subject` namespaced by `iss` is what makes the check per-user.
…ing a form appeared [#543]

`supports_elicitation`'s docstring named two cases with no back channel,
revision 2026-07-28 and stateless HTTP. There is a third:
`StreamableHTTPServerTransport._message_metadata` stamps
`can_send_request=not is_json_response_enabled`, so a stateful deployment
serving `json_response=True` loses elicitation at every revision, 2025-11-25
included. Measured over the real ASGI stack through `wire_hosted_observability`:
`json_response=False` gives `True` at 2025-11-25, `json_response=True` gives
`False`, with the same session id and the same advertised capability either way.

That is the case an embedder controls, and it is the flag the hosted wrapper
passes (`pipefy_remote_mcp.asgi` sets it so a POST answers as plain JSON). Once
that wrapper is on 2.x carrying the flag, elicitation is unavailable on the
hosted server at every revision and both eliciting tools always take their
fallback path there. Pinned by two tests over the real stack, one per mode, so
the pair discriminates: if the flag stopped mattering, the first would fail.

`create_card` and `fill_card_phase_fields` promised in their docstrings that a
form is presented, which is false whenever the session cannot carry a
server-to-client request, and the client cannot see the difference. Both now say
when no form is possible and what the tool does instead.

`fill_card_phase_fields` also returned `{"success": true, "message": "No fields
to update."}` when the phase had required editable fields and nothing had been
collected, which reads to an agent caller as "the card is complete". The message
now distinguishes the two ways that branch is reached: a phase with no editable
fields keeps the old text (truthful, and what the CLI's `card fill` returns for
the same case), while a phase that does have editable fields says nothing was
updated and how to supply values. The payload keys and `success` are unchanged;
whether that case should report `success: false` is a separate decision.

The gate-False path logged nothing while the `NoBackChannelError` absorb emitted
a `ctx.debug`, so an operator could see the residual case and not the common one.
Both tools now log symmetrically.

Tests: the uncovered corner was elicitation unavailable AND required fields
present AND `fields` omitted; the four existing cases all supply `fields`, so
none of them reaches the empty fallback. Added for both tools, pinning that
`create_card` sends `{}` and lets the API rule on its own required fields.

Also: drop `runtime.transport_security = None` from `test_protocol_handshake`'s
`mocked_runtime`, the same dead line this branch removed from `test_server`
(`McpRuntime` no longer has the attribute; the fixture is a `MagicMock`, so it
set a field nothing reads). `inbound_auth = None` still does real work.

And `short_circuit_error` now records that on 2026-07-28 its result omits the
`_meta` `io.modelcontextprotocol/serverInfo` stamp that `ServerRunner._serialize`
adds on the `call_next` path, so a modern client sees the stamp on a tool's
result and not on a middleware denial. Confirmed by measurement. Not fixed:
the stamp comes off the `MCPServer` and `ToolCallContext` has no route to it.
Four claims this cycle introduced contradicted the code they describe.

The changelog's migration-steps bullet said a middleware returns a
`CallToolResult` rather than a `types.ServerResult`. The seam is typed
`HandlerResult` (`BaseModel | dict[str, Any] | None`) and a short-circuiting
middleware returns the wire dict `short_circuit_error` builds. That bullet is
the first thing a middleware author reads, and it also omitted the now-required
`ToolCallContext.protocol_version`, so it is now three steps, not two.

The polymorphic-result bullet put `short_circuit_error` on the `CallToolResult`
side, contradicting both the next bullet and its own `-> dict[str, Any]`
signature. Wording lifted from `result_is_error`'s docstring, which was right.

`serverInfo.version` cited `0.4.0-beta.1` as a literal. `main` already shipped
beta.2 and the handshake test asserts against `pipefy_mcp.__version__`, so the
literal would rot on the next back-merge with nothing to catch it.

The ctx.debug warning counts (68 calls / 11 modules / 997 warnings) were wrong
at both the base and here: 64 calls across 10 modules, 1010 warning records,
and no `ctx.info` call exists at all. The counts drift with every touched
module, so they are dropped rather than chased; the bound on the ignore is what
matters and it stays.

`for_profile`'s docstring read as if the per-profile identity is derived from
the parsed resource on both profiles. `_local_identity` does not take the
resource, and `local` discards it.
`server.py` imports uvicorn and builds a `Config`/`Server` pair on the
`--transport http` path, but the package declared it nowhere. It is guaranteed
only through `mcp`, whose own requirement carries a `sys_platform !=
'emscripten'` marker, so the guarantee is conditional while our import is not.

This is the policy already written for `starlette` in this cycle, applied to the
same class of dependency: floor at what resolves today (0.34.0), next-major cap
so an upgrade is deliberate, matching `starlette>=1.3.1,<2` and
`pydantic>=2.13.4,<3`.

`uv.lock` is edited minimally (the two `pipefy-mcp-server` entries) rather than
regenerated: a `uv lock` here rewrites all 984 package blocks and bumps the
lockfile revision, which is a resolver-version artifact and not this change.
`uv sync --locked --all-extras --dev` passes on the edited lock.

`anyio` and `typing_extensions` are deliberately left undeclared: they are the
same class of direct import but their transitive guarantee through `mcp` is
unconditional, so there is no marker that can drop them out.
@mocha06

mocha06 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, this was a useful review, and the two changelog contradictions were both mine. Pushed as 9c8bce4. Every gate re-run on the integrated tree: ruff check and format across all five src trees, lint-imports 2 kept 0 broken, bump_version.py verify, uv sync --locked, uv build --all-packages --wheel 5 wheels, and pytest -m "not integration" at 4049 passed, 5 skipped, 34 deselected, zero warning records.

The sub residual: partly fixed, partly waived, and two of the premises do not hold

Reproduced all eleven shapes against the real manager and verifier before touching anything. Your finding is real. Three corrections to how it is framed, all measured:

verify_audience is true in both deployments, not left at the default. PIPEFY_JWT_VERIFY_AUDIENCE: "true" with PIPEFY_JWT_AUDIENCE pinned to the resource URL (pipefy-mcp-server k8s/production/production-oci01.yaml:44-54, staging :45-64). A bearer also needs aud=https://mcp.pipefy.com/mcp, which only the hand-made audience mapper emits, so "any realm-signed RS256 JWT with iss and exp reaches the mapping" is not the deployed posture.

Neither Keycloak mechanism you cite exists on our issuer. Prod and staging run 21.1.2 (keycloak/Dockerfile.production:1; prod tag v0.3.10, staging v0.3.32). The basic client scope arrived in 24 and lightweight access tokens in 25. The realm export has no basic entry and lightweight.access.token has zero grep hits across the workspace. 21.1.2 sets a string-UUID sub on every access token, client-credentials included, and no mapper in the export touches sub.

So this is not reachable in production. It is a real fail-open in a general-purpose JWT adapter, reachable by a self-hosted PIPEFY_JWT_ISSUER_URL pointed at another IdP. Worth fixing on those terms, not an incident.

But your "configuration fact, not invariant" instinct is right, for a better reason than the one given. The production realm config is in no repository at all: keycloakConfigCli.enabled: false in both env values files, no Keycloak terraform, and the only realm JSON is a local-dev export last touched 2024-09-09 that does not contain pipefy-mcp or pipefy-cli. The sub guarantee rests on the version pin plus Keycloak behavior, not on a versioned realm definition. That is the part worth carrying into #548.

One correction to the reproduction. "Mixed shapes do not collapse" holds only for the pair you tested. 101 vs absent does collapse, as do bool and list. The real equivalence class is {absent, null, every non-string} -> None, a single principal, so the numeric case is one member of a wider class. Separately, sub is the only fail-open shape hole: a non-string azp/client_id already fails closed, because pydantic rejects it with a ValidationError that verify_token catches into a 401.

And the blast radius is different. Tool calls run under the caller's own bearer, read per message via RequestScopedIdentity.resolve, so a collapsed triple does not let a hijacker act as the victim against Pipefy. What it gives is control of the victim's protocol session and back channel, including the elicitation prompts our tools send. That is sharper than the requestState angle, which is currently unexercised since none of our tools emit input_required.

Applied

  • Non-string sub is coerced with str rather than dropped, so an IdP emitting a numeric sub keeps distinguishing its users. This matches how the SDK's own principal_components coerces a non-string iss.
  • Empty and whitespace-only sub is rejected (401), per your item 1. Worth flagging for anyone implementing this: it has to be a rejection, not a map to None. None is the no-subject class, so folding a blank sub into it would make two blank-sub bearers share a context where at head they do not, which is a regression. Easy to write as or None by accident.
  • Non-blank values carried verbatim, never stripped, so " u1" cannot fuse with "u1".
  • The misleading comment is gone. You were right that "falls back to the remaining components, as the SDK documents" read as cover for a fail-open six lines after explaining why they do not identify one principal here.

Not applied: the jti or bearer-hash fallback

Took your option 2 instead, with the waiver written down and pinned against the real session manager. Four reasons:

  1. It is not a subject. request_log_middleware.py:81 logs AccessToken.subject as sub. A per-credential id there destroys per-user log grouping and adds unbounded cardinality. You flagged the recognizable-in-logs constraint yourself; this is the same problem one step further.
  2. It breaks liveness. jti is per-token, so a sub-less caller's own session would 404 after every refresh, for exactly the azp-only integration class the azp or client_id or sub precedence exists to serve.
  3. It buys nothing semantically. An azp-only bearer names no end user, it is the client, so two of them are the same principal. Collapsing them is correct, and it is the degradation the SDK documents for an unsupplied component.
  4. jti is not repo-verifiable. Nothing suppresses it and nothing reads it, but its presence on pipefy-mcp tokens is console-only config. Building an ownership component on that is worse than a documented waiver.

Also unchanged: the iss guard, since PyJWT already compares iss for equality against the configured issuer so a non-string one cannot arrive (the docstring now explains why the two guards differ), and the identity precedence chain, which is already fail-closed.

On #548: the residual is now decided rather than open, with the waiver and its reachability evidence in the module docstring and pinned by test. Whether that closes it is your call, but nothing about it is undecided from our side.

Both changelog contradictions fixed, and they were self-inflicted

CHANGELOG.md:19 said short_circuit_error returns a CallToolResult model against a -> dict[str, Any] signature. Reworded from result_is_error's docstring as you suggested. CHANGELOG.md:14 was the worse one and is fixed too: read the real types first (HandlerResult = BaseModel | dict[str, Any] | None at mcp/server/context.py:132, and the seam is Awaitable[HandlerResult]), so "two shape changes" is now three, with the required ToolCallContext.protocol_version you spotted missing.

For the record, these came from integrating two independent branches: one changed short_circuit_error's return type while the other wrote the polymorphism bullet, and I merged both without reconciling them. Exactly the failure mode a changelog-as-lockstep-channel is supposed to catch.

Small stuff

json_response as a third no-back-channel case: applied, and it is broader than a doc clause. Confirmed can_send_request = not is_json_response_enabled and measured through wire_hosted_observability over the real ASGI stack at both 2025-06-18 and 2025-11-25. So there are three cases, and the docstring now names json_response explicitly, as does docs/mcp/tools/pipes-and-cards.md, which listed only agents, CLIs, and SDK consumers as the elicitation-unavailable clients and now names the hosted server too.

One thing worth knowing that changes the conclusion, though. The wrapper pins mcp==1.28.1 today, and 1.28.1 has no can_send_request concept at all: in its json_response branch a server-initiated request is logged and discarded. So a hosted elicitation would have hung awaiting a reply that never came. Hosted elicitation has been non-functional the whole time, silently, and 2.0 makes the same condition fail fast and take the documented fallback. That argues for carrying json_response=True across rather than against it. Filed on the wrapper repo with the port work.

create_card and fill_card_phase_fields: docstrings fixed, missing test added, one return shape declined. Both behaviors reproduced verbatim: create_card sends create_card('9982', {}), and fill_card_phase_fields returns {"success": true, "message": "No fields to update."} with update_card never called. Both docstrings now state when no form is possible and what happens instead, the gate-False path logs symmetrically with the absorb path, and the missing test exists for both tools (elicitation unavailable, required fields present, fields omitted), plus two tests pinning the json_response fact over the real ASGI stack.

The message is split: a phase with no editable fields keeps "No fields to update.", while a phase that has them now reports that nothing was updated and how to supply values. Payload keys and success are unchanged, deliberately. Returning tool_error would diverge from pipefy card fill, which returns the identical payload for the same input (packages/cli/src/pipefy_cli/commands/card.py:409,420, 3 tests), contradict the asymmetry docs/parity.md documents, and turn a today-quiet no-op into an error for any caller whose fields are all dropped by the editable filter, which is the same branch. If success: false is wanted there it should be one change across both surfaces plus the parity doc, not an MCP-only flip. Happy to file that.

We also declined local pre-validation of required fields in create_card: the API owns that check and returns an error the tool surfaces, so a local guard duplicates it.

_meta.serverInfo: documented, not fixed, as you asked. Confirmed at 2026-07-28 that a real tool result carries _meta={'io.modelcontextprotocol/serverInfo': ...} and a short circuit carries no _meta, and that _stamp_server_info runs only on the call_next path. The short_circuit_error docstring now records it and why ToolCallContext cannot reach the stamp.

Counts dropped, and both halves were wrong. Real numbers at ef82857: 64 ctx.debug call sites across 10 modules (raw grep says 69/11, but 2 are docstring mentions and 3 are ctx_debug=ctx.debug method passes), and ctx.info does not exist anywhere in packages/mcp/src, zero call sites, so the ctx.debug/ctx.info phrasing was wrong on both sides. 1010 warning records across 66 unique locations, 100% the logging-capability message. Counts are gone from both the changelog and the pyproject.toml comment; the boundedness rationale stays.

CHANGELOG.md:15 now says pipefy_mcp.__version__, matching what test_protocol_handshake.py actually asserts.

core/runtime.py:88-90 fixed, and you slightly understated it. _local_identity(settings) takes no resource and local discards the parsed one, but _remote_identity does not derive its identity from the resource either: it returns a bare RequestScopedIdentity(). The resource is a hard requirement it fails fast on and the input to the inbound-auth pair. The docstring now says that.

test_protocol_handshake.py:56 dead line removed. inbound_auth = None on the line above still does real work, and all five handshake tests pass without it.

uvicorn declared (>=0.34.0,<1), floor at the locked version, cap matching the repo's only upper-bound idiom, with a docs/dependencies.md row and a changelog sentence beside starlette's. anyio and typing_extensions deliberately not declared, and the distinction is exactly the one you drew: mcp's requirements for both are unconditional, so no marker can drop them from a resolution, which is what made uvicorn different.

Lockfile, worth raising as its own problem. uv lock with a current uv rewrites all 984 package blocks and bumps revision = 1 to 2, adding upload-time everywhere. Two separate agents hit it on this branch and both reverted and made the minimal equivalent edit instead, verifying uv sync --locked passes. Whoever normally locks this repo uses an older uv, and CI's setup-uv@v7 pins no version, so this will keep biting. Probably worth pinning the uv version in CI.

Also fixed, from your earlier round

Extended CHANGELOG.md:13's supersession clause to name both entries the port invalidates. It named only the >=1.29.0,<2 floor bump, but the <2 cap entry in Fixed closes with "The <2 upper bound stays", which reads as current policy against a shipped ==2.0.* pin.

Not verified, and I would rather say so

Live end-to-end coverage of these fixes through a built installation is still missing. The negotiation asymmetry is confirmed on the installed binaries (legacy to 2025-11-25, auto and default to 2026-07-28), and the earlier live smoke against production org 300514213 covered read paths, --toolsets power, and execute_tool dispatching to a real read. But the elicitation fix itself cannot be proven without a write, because the tool must successfully fetch field definitions before it decides whether to elicit, and both eliciting tools write. That run is queued next with a create, read, update, delete round trip on a throwaway card.

Your clean-venv install gate is a good one and it caught something worth keeping: it is the only gate that resolves past uv.lock. Nothing in CI does that today, which is the same gap the ==2.0.* pin reasoning leans on.

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked at 9c8bce4 after the follow-up commits that answered the earlier COMMENT review.

Verdict: approve for merge into refactor/543-mcp-2x-port.

What I verified on this tip:

  • ruff check packages/mcp pass
  • Targeted ownership / resource / capabilities / elicitation / middleware / handshake suite: 78 passed
  • Full packages/mcp suite (not integration): 1796 passed
  • F2 pins: blank/whitespace sub refused; numeric sub coerced and not collapsed; subjectless same-client waiver pinned; distinct string subjects still isolated
  • Live smoke from a worktree at head: initialize negotiates 2025-11-25 with serverInfo {name: pipefy, version: 0.4.0-beta.1}; tools/list returns 187 tools
  • Changelog middleware bullets now agree that short_circuit_error returns the wire dict; uvicorn is declared; json_response is named as a no-back-channel case

Accepted residual: the author's queued mutating elicitation E2E against a real org. Unit coverage and the live handshake are enough for this stack layer.

Stack reminder unchanged: land this onto the port branch, then #547 into dev, with the hosted wrapper lockstep still owned on #547.

@mocha06
mocha06 merged commit fb2ffcb into refactor/543-mcp-2x-port Jul 31, 2026
@mocha06
mocha06 deleted the rc-547/fix/mcp-2x-review-fixes branch July 31, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request hosted-mcp Hosted, multi-user, on-behalf-of MCP server profile

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants