feat(mcp): migrate to python-sdk v2 (protocol 2026-07-28) - #1433
Conversation
|
Claude finished @cbcoutinho's task in 3m 9s —— View job Review: migrate to python-sdk v2 (protocol 2026-07-28)
SummaryThis is a large, high-risk migration (mcp 1.29 → 2.1.1, protocol 2026-07-28) handled with unusual care. I read the four behavioral changes in detail plus spot-checked the mechanical rename, and I don't have any blocking findings. The four non-blocking review notes referenced in the PR thread (integration/OAuth/LDAP/Keycloak lanes, SonarCloud complexity, the What I verified1. 2. 3. 4. Elicitation 5. Mechanical sweep completeness — I grepped the full source and test tree for 6. Non-blocking observations
CI status at time of reviewThe latest push triggered fresh CI (Pact contract tests already green; the full Tests workflow and this review were still queued/in-progress when I looked). Given the author's prior comment that the full 27-lane matrix passed three times on the previous commit ( ConclusionNo blocking issues found. The behavioral risk surface called out in the PR description is each backed by a targeted test that exercises the real SDK dispatch path rather than mocking around it, which is the right way to de-risk an SDK major-version migration like this. |
4c72ae6 to
ca2c141
Compare
Status update — the "never executed" caveat above is now staleWhen this PR was opened, three new test files had never run anywhere. They have
The card's last open item is now closed for real
It also pins that setting an I also rewrote one assertion in that file that was vacuous: it checked that a SonarCloudGate OK on all five conditions (new maintainability rating A). It reports 5 CI so farPass: |
Review catch on #1433. My audit of "exceptions whose text stops reaching the model" grepped for `raise ValueError` and so missed this one: `_build_link_response` raises `RuntimeError` when an OCS public-link payload carries no url, and `nc_share_create_public_link` calls it with no enclosing handler. `friendly_tool_error` only rewrites httpx errors, so under 2.x the model would be told the call failed and nothing else. Same class as the 21 ValueError sites, same fix. Swept the tool modules for every other non-httpx raise: only `semantic.py`'s TypeError, which is caught by the `except Exception` that re-raises as MCPError, so its message survives. Also corrects two docstrings this PR had already invalidated (`Raises: ValueError` on the expiry helper and its caller), and a comment in semantic.py that still described v1 delivery — an MCPError from a tool is now mapped back to ToolError at our boundary rather than shipping as a protocol error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review catch on #1433. My sed matched `.isError` as attribute access, so the same names spelled as *strings* inside getattr/hasattr were left behind — and both degrade quietly rather than erroring: - test_mail_greenmail.py: `getattr(result, "isError", False)` now always returns the default, so _tool_payload stops raising on a failed tool and instead json.loads() the error text. All 32 call sites in that file lose the loud AssertionError its docstring promises; a CSRF rejection — the thing the suite exists to surface (#965) — would have shown up as an opaque JSONDecodeError. - test_error_propagation.py (x3): `hasattr(response, "structuredContent")` is now always False, so the structured-content branch is dead and the three tests silently fall through to the is_error assertion, dropping the "structured content carries success=False" coverage they were written for. Neither would have failed CI: the mail lane needs the mail profile, and the error_propagation tests still pass down the else path. The `"mimeType"` keys in server/notes.py are deliberately left camelCase — they are dict keys in hand-built resource payloads, i.e. wire format, which v2 did not rename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review suggestion on #1433. The order depends on the SDK's exception hierarchy, which a reader would otherwise have to go check: UnexpectedToolError subclasses ToolError, so the specific clause must come first or every crash takes the "message is already the author's" path and ships the SDK's contentless text. MCPError is a separate tree (Exception directly), so its position is free. Verified against the installed SDK rather than assumed: UnexpectedToolError -> ToolError -> MCPServerError -> Exception, and issubclass(MCPError, ToolError) is False. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review suggestion on #1433: _max_request_body_size() is pure arithmetic gating user-visible behaviour, and nothing covered it. Get the base64 factor wrong and a nc_webdav_write_file call under the advertised 50 MB starts failing at the transport with an opaque 413 instead of that tool's explanatory ToolError — silently, since no test would notice. Covers the three cases that matter: the 50 MB default leaving room for the base64 wire form, a small-or-unset setting never tightening below the SDK's 4 MiB floor (0/None mean "no app-level cap", not "no bytes"), and the limit actually scaling with the setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
|
Answering the four non-blocking notes — no code changes needed for any of them, so this PR stays at 1. Integration/OAuth/LDAP/Keycloak lanes — now green, all of them. This was the right thing to hold on; it was the surface I flagged as least verified. Final state on Worth recording that holding on those lanes found a real regression: 2. SonarCloud complexity — addressed, not deferred. #1437 is stacked on this PR and clears all five: SonarCloud now reports "No issues found", with 3. 4. Agreed — no One correction to the review, since it affects how much weight note 1 carries: the summary says several lanes "hadn't run at PR-open time" and cites a prior partial run. That was accurate when written. As of
|
Bumps `mcp[cli]` from 1.29 to >=2.1,<3. The rename sweep is the bulk of the
diff; the behaviour changes are the risk, and are called out individually
below.
Mechanical:
- `FastMCP` -> `MCPServer`, `mcp.server.fastmcp*` -> `mcp.server.mcpserver*`
- `McpError(ErrorData(...))` -> `MCPError(code, message)` across 141 sites
- camelCase -> snake_case attribute access (`.isError`, `.structuredContent`,
`.uriTemplate`, `.inputSchema`) and `ToolAnnotations` kwargs. The kwargs still
construct either way; renaming them clears ~200 `ty` warnings.
- `streamablehttp_client` -> `streamable_http_client` + an `httpx2.AsyncClient`
- `mcp.shared.context.RequestContext` -> `ServerRequestContext` /
`ClientRequestContext`
- `NextcloudFastMCP` -> `NextcloudMCPServer`: it was named after a class that
no longer exists
Behavioural:
- `request_ctx` and `MCPServer.get_context()` are both gone, and v2 injects
`Context` only into tools and *template* resources. That left capability
gating in `list_tools()` and every static `@mcp.resource()` with no context —
failing open silently rather than crashing. New `request_context.py`
republishes it from a middleware.
- `Server.request_handlers` is gone; the tool-outcome metrics and client-fleet
instrumentation were patching that dict. Both are now a middleware.
`instrument_call_tool_outcomes(mcp)` keeps its signature.
- `MCPError` raised in a tool is a top-level JSON-RPC error in v2, not
`is_error=True`. All ~140 of our raise sites are failures the model should
read and react to, which is what `ToolError` means in v2 — mapped back at the
`call_tool` boundary rather than changing the wire contract.
- `UnexpectedToolError` withholds the original message entirely. 21
`raise ValueError` sites in tool bodies ("calendar_name is required...",
comment length limits) were reaching the model as text and would now reach it
as nothing; converted to `ToolError`.
- Streamable HTTP now caps POST bodies at 4 MiB, answering 413 before parsing.
`WEBDAV_WRITE_MAX_MB` defaults to 50 MB, so `max_request_body_size` is derived
from it.
- `transport_security` moved off the constructor onto `streamable_http_app()`.
- `ctx.elicit()` raises `NoBackChannelError` at 2026-07-28; the fallback now has
a dedicated branch and `mcp_elicitation_total{reason="no_back_channel"}`.
- Deck resource deprecation notices moved from `ctx.warning()` (dropped on
2026-era connections per SEP-2577) into descriptions clients actually see.
Tests: `Client(server)` negotiates 2026-07-28, so the new bridge and
tool-error tests pin the modern era; the existing `ClientSession` fixtures stay
on the legacy path, which is what real clients still speak.
Refs card 946 (Deck board 46).
BREAKING CHANGE: requires mcp>=2.1,<3 (protocol 2026-07-28). Server-initiated
elicitation no longer reaches 2026-era clients — progressive consent degrades to
message_only, with the login URL carried in the returned message. Deployments
pinning mcp<2 must stay on the previous release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
…nnection Deck card 946 asks for this explicitly: "verify the degradation empirically, don't assume it." Until now the NoBackChannelError branch was reasoned about, not exercised. Runs the real progressive-consent path behind a Client(server) connection, which negotiates 2026-07-28 — so it proves the SDK actually refuses the elicitation on this era, rather than proving our except clause works. Asserts the login URL survives into the tool result, and that the no_back_channel metric label fires, since that counter is the only production signal that interactive consent has stopped happening. Also pins that registering an elicitation_callback does not rescue it: no request ever reaches the client, so the obvious wrong fix is dead code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
The two places that get no Context — static @mcp.resource() functions and list_tools() — both fail open rather than raising, so the wrong instinct here is silent. Names current_context() as the narrow escape hatch, and says plainly that get_context() and request_ctx are gone so nobody reaches for them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review catch on #1433. My audit of "exceptions whose text stops reaching the model" grepped for `raise ValueError` and so missed this one: `_build_link_response` raises `RuntimeError` when an OCS public-link payload carries no url, and `nc_share_create_public_link` calls it with no enclosing handler. `friendly_tool_error` only rewrites httpx errors, so under 2.x the model would be told the call failed and nothing else. Same class as the 21 ValueError sites, same fix. Swept the tool modules for every other non-httpx raise: only `semantic.py`'s TypeError, which is caught by the `except Exception` that re-raises as MCPError, so its message survives. Also corrects two docstrings this PR had already invalidated (`Raises: ValueError` on the expiry helper and its caller), and a comment in semantic.py that still described v1 delivery — an MCPError from a tool is now mapped back to ToolError at our boundary rather than shipping as a protocol error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
CI caught this: four login-flow integration tests failed with "Error executing tool nc_notes_create_note" and nothing else. Two of them were scope denials — the model was told the call was refused but not that it needed notes.write. Root cause is broader than the ValueError sweep in the parent commit. ScopeAuthorizationError and its subclasses derive from Exception, not ToolError, because they are also raised on HTTP routes where ToolError would be the wrong type. mcp 2.x turns any such exception into UnexpectedToolError and replaces its message with a bare "Error executing tool <name>", so the reason never reaches the model. friendly_tool_error only rewrites httpx errors and returned None for everything else, which re-raised the message-less form. Fixed once at the boundary that already exists for this rather than by converting exception types one at a time — that list is open-ended, and the next `raise` in a tool body would reintroduce it. UnexpectedToolError (the crash case) now always carries a message; a plain ToolError is left alone because its text is already the tool author's. Pinned at the unit tier so this fails in seconds rather than in a Playwright lane, including the empty-message case where str(exc) is "" and only the exception type identifies the failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
…Error Follows the exception-type change in the previous commit. I missed this test when making it — I grepped tests/unit/test_sharing* and tests/client/, but it lives in tests/unit/server/. Records *why* ToolError specifically, so the next person does not swap it back for a bare exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review catch on #1433. My sed matched `.isError` as attribute access, so the same names spelled as *strings* inside getattr/hasattr were left behind — and both degrade quietly rather than erroring: - test_mail_greenmail.py: `getattr(result, "isError", False)` now always returns the default, so _tool_payload stops raising on a failed tool and instead json.loads() the error text. All 32 call sites in that file lose the loud AssertionError its docstring promises; a CSRF rejection — the thing the suite exists to surface (#965) — would have shown up as an opaque JSONDecodeError. - test_error_propagation.py (x3): `hasattr(response, "structuredContent")` is now always False, so the structured-content branch is dead and the three tests silently fall through to the is_error assertion, dropping the "structured content carries success=False" coverage they were written for. Neither would have failed CI: the mail lane needs the mail profile, and the error_propagation tests still pass down the else path. The `"mimeType"` keys in server/notes.py are deliberately left camelCase — they are dict keys in hand-built resource payloads, i.e. wire format, which v2 did not rename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review suggestion on #1433. The order depends on the SDK's exception hierarchy, which a reader would otherwise have to go check: UnexpectedToolError subclasses ToolError, so the specific clause must come first or every crash takes the "message is already the author's" path and ships the SDK's contentless text. MCPError is a separate tree (Exception directly), so its position is free. Verified against the installed SDK rather than assumed: UnexpectedToolError -> ToolError -> MCPServerError -> Exception, and issubclass(MCPError, ToolError) is False. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
Review suggestion on #1433: _max_request_body_size() is pure arithmetic gating user-visible behaviour, and nothing covered it. Get the base64 factor wrong and a nc_webdav_write_file call under the advertised 50 MB starts failing at the transport with an opaque 413 instead of that tool's explanatory ToolError — silently, since no test would notice. Covers the three cases that matter: the 50 MB default leaving room for the base64 wire form, a small-or-unset setting never tightening below the SDK's 4 MiB floor (0/None mean "no app-level cap", not "no bytes"), and the limit actually scaling with the setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
SonarCloud flagged configure_{cookbook,semantic,notes,deck,mail}_tools at
cognitive complexity 153/80/76/68/45 against the 15 allowed. Pre-existing debt,
surfaced by #1433 only because its rename sweep touched lines inside them.
Two different causes, two different fixes.
**Four are the registration idiom, not the code.** Every tool is a nested
closure, and S3776 charges a nesting penalty per level, so the outer function
accumulates the complexity of every tool it contains even though no individual
tool in notes/deck/mail exceeds McCabe 8. Lifting the 71 tools to module level
and registering them with `mcp.tool(...)(fn)` leaves behaviour identical --
@mcp.tool() is simply the outermost decorator, applied last either way. Resource
functions stay nested: they call current_context(mcp) and genuinely close over
it.
**semantic.py is real complexity**, not an artifact: nc_semantic_search was an
850-line function, so extraction alone would have relocated the finding rather
than fixed it. Pulled out five cohesive pieces it was carrying -- candidate
retrieval, result mapping, context expansion, the date-bound guards, and the
rerank pass -- each named for what it does and documented with why it moved.
Cookbook's two outliers (create/update_recipe, 16 and 20 on their own) needed
their own pass: the ten sequential `if field:` assignments became one
field-mapping table, and the HTTP-status if/elif chains a status->message dict.
The create/update guard difference is preserved and now stated explicitly --
create drops falsy values, update drops only None so an empty string still
clears a field.
Result: max complexity across the five files is now 13, from 153.
The one behavioural risk is that module-level functions are shared across
MCPServer instances while stamp_required_capability mutates them. Safe because
the stamp is idempotent and deterministic per app, and now pinned by a test
rather than left to that argument.
Refs card 1203.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd
a1b6b22 to
01fe0bd
Compare
|
The claude-review action's allowlist had no `Task`, so on a large diff the reviewer fails silently. On cbcoutinho#1437 (~3800 lines across 6 files) it announced "dispatched parallel sub-reviews of the three logical chunks", had all 12 of those calls denied, then exited with is_error: false and 17 turns — leaving a sticky comment that still read "Review in progress" with an unticked checklist and no findings. The check went SUCCESS regardless, because it reports that the action ran, not that a review happened. So the PR presented as reviewed-and-clean when nothing had been reviewed. Two runs failed identically, ~2 minutes apart; it is not a flake. Smaller diffs are unaffected — cbcoutinho#1433 reviewed fine because it never chose to fan out. Two changes: - Allow `Task`, so the fan-out the reviewer already attempts can actually run. - Tell it, in the prompt, to say so in the tracking comment when it cannot finish. Its system prompt already asks it to report missing permissions and it did not, and a silent stall is the expensive failure here: a green check over a half-written comment is worse than a red one. Cost note recorded inline: fan-out means several sub-agents per review, and a single non-fanned-out run on that PR cost ~$1. Drop `Task` again if that proves too expensive, accepting that large PRs then need splitting to be reviewable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd



Migrates to
mcppython-sdk 2.1.1 (protocol 2026-07-28), from 1.29.Closes the work planned on Deck card 946 (board 46, "MCP 2026-07-28 Spec
Adoption"), which blocks every other card on that board.
Deviation from the card's plan — read this first
The card plans 4 stacked PRs (spike / prep-on-v1 / mechanical bump /
semantics). This is one PR. Phase 1 ("prep on v1") is no longer reachable:
the
get_context()replacement needsServerRequestContext, which does notexist in 1.x, so there is nothing forward-compatible left to land first. And
splitting the bump from the semantics would make the bottom PR knowingly
broken — without the
ToolErrorconversions, tools silently stop explainingthemselves to the model.
Happy to restructure into a stack if you'd rather review it that way; say so and
I'll split it. The semantic changes are the four bullets under Behavioural
below, each with a comment at the site.
Mechanical (the bulk of the diff)
FastMCP→MCPServer,mcp.server.fastmcp*→mcp.server.mcpserver*McpError(ErrorData(...))→MCPError(code, message).isError→.is_error,.structuredContent,.uriTemplate,.inputSchemaToolAnnotations(readOnlyHint=…)→read_only_hint=streamablehttp_client→streamable_http_client+httpx2.AsyncClientRequestContext→ServerRequestContext/ClientRequestContextThe
ToolAnnotationskwargs were not required — camelCase still constructs,verified at runtime — but renaming them clears ~200
tywarnings, sotyisusable again.
NextcloudFastMCP→NextcloudMCPServer, since it was namedafter a class that no longer exists.
Behavioural — the actual risk
1.
request_ctxandget_context()are both gone. v2 injectsContextinto tools and template resources only. That left capability gating in
list_tools()and every static@mcp.resource()(nc://capabilities,notes://settings,cookbook://version) with no route to a context — andbecause both callers fail open by design, this would have silently disabled
capability gating rather than crashing. New
nextcloud_mcp_server/request_context.pyrepublishes the context from a middleware, which is where v2 puts per-message
interception.
2.
Server.request_handlersis gone — the tool-outcome metrics andclient-fleet instrumentation were patching that dict. Both are now a middleware;
instrument_call_tool_outcomes(mcp)keeps its signature.3.
MCPErrorfrom a tool is now a JSON-RPC error, notis_error=True.The client raises it, so the model never sees the message. All ~140 of our
raise sites are "Note 5 not found" / "Nextcloud access not provisioned" —
failures the model should read and react to, which is precisely what
ToolErrormeans in v2. Mapped back at the
call_toolboundary rather than changing thewire contract for 140 messages.
4.
UnexpectedToolErrorwithholds the original message entirely (Error executing tool <name>, nothing more). 21raise ValueErrorsites in tool bodies—
"calendar_name is required when search_all_calendars is False", commentlength limits — were reaching the model as text in 1.x and would now reach it as
nothing. Converted to
ToolError. A live integration test caught this one.Also: Streamable HTTP now caps POST bodies at 4 MiB, answering 413 before
parsing, while
WEBDAV_WRITE_MAX_MBdefaults to 50 MB —max_request_body_sizeis now derived from that same setting so the size refusal stays where it can
explain itself.
transport_securitymoved off the constructor ontostreamable_http_app()(see the note appended todocs/MCP-1.23-DNS-REBINDING-FIX.md).ctx.elicit()raisesNoBackChannelErrorat 2026-07-28, so the fallback gained a dedicated branchand a
mcp_elicitation_total{reason="no_back_channel"}label. Deck resourcedeprecation notices moved out of
ctx.warning()— dropped on 2026-eraconnections per SEP-2577 — into descriptions clients actually see.
Test coverage
New:
tests/unit/test_request_context_bridge.py— the middleware bridge, driventhrough a real
Client(server)(which negotiates 2026-07-28). Calling themethods directly would pass while the served path silently failed open.
tests/unit/test_tool_errors_stay_tool_results.py— the card's acceptancecriterion: a Nextcloud 404 and a raised
MCPErrorboth land inCallToolResult.is_errorwith a usable message, asserted from the client side.tests/smoke/test_protocol_2026.py— closes a coverage gap this portsurfaced: every existing fixture drives a hand-rolled
ClientSession+initialize(), which negotiates a 2025-era version, so nothing exercised2026-07-28 over HTTP. Uses
Client(url)(mode="auto").The existing
ClientSessionfixtures deliberately stay on the legacy path —that is what real clients still speak, and both eras need to keep working.
No new API surface, so no new contract tests; the existing Pact lanes should be
unaffected and are the check on that.
Verification status — please read
Run locally before I was asked to move validation to CI:
ruff,ruff format,ty(201 → 0 diagnostics)pytest -m unit— 3727 passedpytest -m contract— 19 passedpytest -m smoke— passed against a live stack over real Streamable HTTPnc://capabilitiesstatic resource confirmed working live in single-userBasicAuth — the piece with no route to a
Contextunder v2Not yet run anywhere:
-m "integration and not oauth"got to ~78% with allfailures traced to profiles I hadn't started (ports 8003/8004, keycloak, ldap)
rather than regressions — but I did not finish confirming that, so treat it as
unverified.
-m oauth,-m ldap,-m login_flow_ldap,-m keycloakhave notrun at all. The three new test files above have never been executed — CI is
their first run.
Auth is the surface I'd least want to take on faith here: elicitation losing its
back-channel, and
AuthenticatedUser/auth_context_varstill beingSDK-internal (unchanged in v2, but only the OAuth lanes prove it).
The card's open item — now closed
tests/unit/test_elicitation_no_back_channel.pydoes this, and passes in CI. Itdrives the real
present_login_urlpath behind aClient(server)connectionthat negotiates 2026-07-28, and asserts the metric reason is specifically
no_back_channelrather than merely that some fallback fired —reason="error"would mean we were guessing at the cause. It also pins that registering an
elicitation_callbackdoes not rescue it, since that is the obvious wrong fix.Confirmed, not assumed: the SDK does raise
NoBackChannelError, our handlercatches that specific exception, and the tool degrades instead of failing.
Whether losing the clickable prompt is acceptable is a product call — restoring
an interactive one needs the resolver /
InputRequiredResultroute, which isDeck card #526.
This PR was generated with the help of AI, and reviewed by a Human
🤖 Generated with Claude Code
https://claude.ai/code/session_019iCzZAuCMsZS7UJYfy3htd