fix(mcp): close the review findings on the mcp 2.x port - #552
Conversation
…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`.
adriannoes
left a comment
There was a problem hiding this comment.
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
subin 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 asjtior a hash of the bearer forsubject, so a second bearer of the same client gets a 404 instead of session reuse; notesubjectis also read byrequest_log_middleware, so keep it recognizable in logs), or write the waiver down with a test that pins today's behavior for twoazp-only bearers and twosub: ""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) andCHANGELOG.md:50both name stateless HTTP as the non-revision case, but the SDK stampscan_send_request = not is_json_response_enabled(streamable_http.py:239), so a stateful deployment servingjson_response=Truealso loses elicitation, at 2025-11-25 included. Measured throughwire_hosted_observabilityover the real ASGI stack:json_response=Falsegives True at 2025-11-25,json_response=Truegives False. No impact on the served path, since the default is False andrun_servernever passes it. Worth a clause anyway, becausejson_responseis precisely the flagCHANGELOG.md:16tells an embedder to pass tostreamable_http_app(). test_protocol_handshake.py:56carriesruntime.transport_security = Nonein its copiedmocked_runtimefixture, the same dead line658997bremoved fromtest_server.pyin this PR for setting an attributeMcpRuntimeno longer has. The fixture is aMagicMock, so nothing fails and nothing catches it.core/runtime.py:88-90, in thefor_profiledocstring this PR rewrote: "selects the per-profile identity from it (and, forremote, 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.uvicornis imported and used on the--transport httppath (server.py:191) but not declared. It is guaranteed only transitively bymcp, whose requirement carries asys_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.anyioandtyping_extensionsare the same class, weaker because their transitive guarantees are unconditional.CHANGELOG.md:26andpyproject.toml:69say 68ctx.debug/ctx.infocalls 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:15hardcodesserverInfo.versionas0.4.0-beta.1. Correct for this head and fordev, butmainalready shipped beta.2, andtest_protocol_handshake.pyasserts againstpipefy_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.serverInfostamp thatServerRunner._serializeadds on thecall_nextpath, so a modern client sees the stamp on real tool results and not on middleware denials.ToolCallContexthas no route toserver_info_stamp, so I would just say so in theshort_circuit_errordocstring, which already explains the shaping obligation, rather than restating the server info. create_cardandfill_card_phase_fieldsdiffer 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. Withfieldsomitted,fill_card_phase_fieldsreturns{"success": true, "message": "No fields to update."}while required phase fields go uncollected, andcreate_cardsends{}on to the API. The gate-False path logs nothing, while theNoBackChannelErrorabsorb at least emitsctx.debug. No test covers elicitation-unavailable plus required-fields-present plus fields-omitted; the four tests inTestElicitationWithoutABackChannelall supplyfields.
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").
…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.
|
Thanks, this was a useful review, and the two changelog contradictions were both mine. Pushed as The
|
adriannoes
left a comment
There was a problem hiding this comment.
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/mcppass- Targeted ownership / resource / capabilities / elicitation / middleware / handshake suite: 78 passed
- Full
packages/mcpsuite (not integration): 1796 passed - F2 pins: blank/whitespace
subrefused; numericsubcoerced and not collapsed; subjectless same-client waiver pinned; distinct string subjects still isolated - Live smoke from a worktree at head:
initializenegotiates2025-11-25withserverInfo {name: pipefy, version: 0.4.0-beta.1};tools/listreturns 187 tools - Changelog middleware bullets now agree that
short_circuit_errorreturns the wire dict;uvicornis declared;json_responseis 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.
Stacked on #547, targeting
refactor/543-mcp-2x-portso 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
f5b28cawere three defects the suite could not see and a set of smaller gaps the reviews left open.The one that mattered most: a default
mcp2.xClientusesmode="auto", opens with the envelopedserver/discover, and lands on protocol revision2026-07-28. That revision has no server-to-client back channel, socreate_cardandfill_card_phase_fieldsreturned a JSON-RPC protocol error instead of a tool result. #547's own falsification check drives a client offeringprotocolVersionininitialize, which is the handshake leg and does correctly negotiate2025-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_elicitationnow requiresServerSession.can_send_request, which is the SDK's own gate, and_elicit_field_detailsabsorbsNoBackChannelErrorfor the residual cases the guard cannot see. Both tools fall back to the caller's suppliedfields, 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, andCallToolResult'sresultTypedefault reached the wire on connections that negotiated2025-11-25.short_circuit_errornow returns the wire dict shaped by the SDK's ownserialize_server_result, so there is no hardcoded assumption about which keys belong at which revision.Per-session credential binding discriminates per user.
mcp2.0 compares a(client_id, issuer, subject)triple to decide whether a request may use an existing session, and onlyclient_idwas supplied. For the hosted server that is the single publicpipefy-mcpOAuth client every end user authorizes through, so the check could not tell two users apart and either could present the other'smcp-session-id. The JWTsubandissare 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 againstmcp_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:
starletteis now declared where four modules import it, instead of relying on it arriving transitively through the SDK. Atools/callthat names no tool is logged under<unnamed>rather than sharing a blank dashboard bucket with real tool names, whilectx.tool_namestays the raw view of what the client sent. The lastFastMCPreferences outside the changelog are gone, including a documentedFASTMCP_LOG_LEVELvariable that does not exist.runtime.pyno 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 omittedjson_response, which genuinely moved. That omission matters for an embedder that callsstreamable_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 amode="auto"client falls back cleanly, but on a duplex connection the connect fails outright with-32601, becausesend_discoverstamps 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.elicitpassesrelated_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'sjson_response=True, which is itself one of the calls that breaks under 2.0. The review also namedpipe_configandservice_accountas the eliciting tools; neither elicits, onlycreate_cardandfill_card_phase_fieldsdo, and that misattribution came from #547's own_mcp_compatdocstring, corrected here. Finally, theisError:trueline in the review's wire capture is from a raised exception, not a Pipefy-styletool_errorenvelope, which is a successful call reporting a business error and carriesisError:false.Known gap, deliberately not fixed here. On a
2026-07-28connection the SDK also stamps_meta.serverInfoinside_serialize, which a short-circuit skips exactly likeresultTypedid. UnlikeresultTypeit cannot be fixed fromshort_circuit_error, because the stamp needs a server-scoped value unreachable from aToolCallContext. 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.settingsassignments and leavingstreamable_http_app()argument-less) produces a pod that passes its readiness probe while rejecting every proxied MCP request with421. 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
srctrees and the changed files,lint-imports2 kept 0 broken,bump_version.py verify,uv sync --locked,uv build --all-packages --wheel, andpytest -m "not integration"at 4029 passed against a 4008 baseline.