fix(mcp): support both mcp 1.x and 2.0 SDKs - #1504
Conversation
mcp 2.0 removed the low-level Server decorators (@server.list_tools(), @server.call_tool()) in favour of constructor handlers with a (ctx, params) -> Result signature, so `recce mcp-server` failed to start with "'Server' object has no attribute 'list_tools'". Select the registration path at import time via MCP_V2 and keep the two handler bodies in their 1.x shapes; on 2.0 thin adapters wrap them into ListToolsResult / CallToolResult. The call_tool adapter returns isError=True explicitly because 2.0 turns a raised exception into a JSON-RPC protocol error instead of a tool error. Tests move to tests/mcp_compat.py helpers, since 2.0 drops the request_handlers dict and renames Tool.inputSchema to input_schema. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Kent Huang <kent@infuseai.io>
There was a problem hiding this comment.
Pull request overview
This PR updates Recce’s MCP server integration to run against both mcp SDK 1.x and 2.x, preserving the existing tool result/error contract while accommodating the 2.0 handler-registration API changes.
Changes:
- Add a version-detection flag (
MCP_V2) and build the MCPServerusing either 1.x decorators or 2.0 constructor handlers. - Introduce thin 2.0-compatible adapters (
_handle_list_tools/_handle_call_tool) around the existing handler bodies. - Update MCP-related tests to use shared compatibility helpers, and widen the optional dependency range to allow
mcp2.x.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
recce/mcp_server.py |
Adds dual registration paths (1.x decorators vs 2.0 constructor handlers) and 2.0 adapter methods. |
tests/mcp_compat.py |
New test helper module to normalize handler invocation and schema field naming across SDK majors. |
tests/test_mcp_server.py |
Refactors tests to use compatibility helpers and normalized result accessors. |
tests/test_mcp_cloud_backend.py |
Refactors backend delegation tests to use compatibility helpers and normalized result accessors. |
pyproject.toml |
Widens optional mcp dependency constraint to >=1.23,<3. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`recce mcp-server` speaks stdio, so there is no port to poll: the check
feeds it initialize / notifications/initialized / tools/list and asserts
the server identifies itself and advertises at least one tool. A startup
failure like the mcp 1.x/2.0 handler-registration split shows up here.
SMOKE_SERVER selects the surface ("server" by default, so existing
callers are unchanged); SMOKE_MCP_VERSION pins the SDK version to
install, since mcp is an optional extra that CI's install does not carry.
A new matrix job runs the mcp-server mode against both majors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Kent Huang <kent@infuseai.io>
Codecov Report❌ Patch coverage is
... and 3 files with indirect coverage changes 🚀 New features to boost your workflow:
|
- Launch `recce mcp-server` outside check_mcp_server_status, mirroring how check_server_status is called. The handshake is written to a file and fed on stdin, so the server can be backgrounded like `recce server &` and the check just waits on its pid. - Take major.minor mcp versions (`1.29`, `2.0`) and install with `~=` instead of `==`, so a new patch release is picked up automatically. - Add an explicit `permissions: contents: read` block to the workflow (CodeQL: workflow does not limit GITHUB_TOKEN permissions). - Drop a stray tooling marker from the MCP_V2 comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Kent Huang <kent@infuseai.io>
`recce mcp-server` is launched in the background as an HTTP/SSE server on an MCP port, the way `recce server` is, and every request now goes over that port from inside check_mcp_server_status: poll /health, open the GET /sse response stream, POST initialize / notifications/initialized / tools/list to the session endpoint the stream hands out, then assert the server identifies itself and advertises tools. The function stops the server on the way out, matching check_server_status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Kent Huang <kent@infuseai.io>
`_build_server` picks its branch from MCP_V2, so whichever SDK major is installed leaves the other branch unexecuted — the 2.0 constructor kwargs and the 1.x decorators cannot both be reached natively. Patch the flag and assert each path wires the handlers it should. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Kent Huang <kent@infuseai.io>
iamcxa
left a comment
There was a problem hiding this comment.
@kentwelcome — reviewed at head c5c3395d. Event: REQUEST_CHANGES (two blocking items; everything below them is advisory).
The compat rewrite is the right shape and it genuinely boots on both majors — I verified
that independently, not just from the green CI. Two things do not survive the widened pin:
mcp 2.0's low-level server dropped input-schema validation that 1.x performs by default,
so tools/call behaviour is not actually identical across the two; and
tests/test_mcp_e2e.py was never migrated to the new compat helpers, so it fails on
mcp 2.0 — invisibly, because no CI job runs the Python tests against 2.x.
PR intent
| Goal (PR body / release note) | recce mcp-server starts and serves tools on both mcp 1.x and mcp 2.0 |
| Verdict | ✅ Achieved for start-up and tools/list — verified on both majors |
| Gap | The stronger claim — "keep the response identical across both versions" — does not hold for tools/call with invalid arguments (finding 1) |
| Gap | The sub-claim "Tests move to tests/mcp_compat.py helpers" is incomplete: tests/test_mcp_e2e.py still reads result.isError (finding 2) |
Verification performed
Independent of CI, in clean venvs (Python 3.12; one with mcp==2.0.0, one with mcp==1.29.0,
recce installed from this head):
| Check | Result |
|---|---|
pytest tests/test_mcp_server.py tests/test_mcp_cloud_backend.py on mcp 2.0.0 |
194 passed |
pytest tests/test_mcp_e2e.py on mcp 2.0.0 |
5 failed, 55 passed → finding 2 |
same file with only .isError → .is_error |
60 passed — the production code is fine; the defect is test-side |
pip check in the clean mcp-2.0 env (you asked for this) |
clean, 110 packages compatible |
uv lock --check at this head |
fails — "The lockfile at uv.lock needs to be updated" → finding 3 |
CallToolResult(isError=True) on 2.0 |
correct — field is_error, alias isError, validate_by_alias=True. Confirms your reply to Copilot; not re-raising it |
Server.__init__ on 2.0 |
accepts on_list_tools / on_call_tool; hasattr(Server, "list_tools") is False, so MCP_V2 detection is right |
Tool(inputSchema=...) on 2.0 |
still valid via the alias — the 20 unchanged construction sites are safe |
stdio_server, SseServerTransport, Server.run(...), create_initialization_options() on 2.0 |
all present |
Stale refs to _setup_handlers / request_handlers |
none remain anywhere |
| flake8 / isort on changed files | clean (black deferred to the pinned pre-commit 26.3.1; my local 25.9.0 only wants to reformat pre-existing textwrap.dedent blocks this PR does not touch) |
permissions: contents: read |
addresses the CodeQL finding |
httpx2 2.10.0 (mcp 2.0's new transitive dep) declares github.com/pydantic/httpx2 as its
home. That is package metadata, not an independent provenance audit — worth a human look
before anyone raises the floor to mcp>=2.
Break-point coverage
- Break point:
recce/mcp_server.py:670(self.server = self._build_server()) — the original
'Server' object has no attribute 'list_tools'crash site. - Failure chain:
recce mcp-server→RecceMCPServer.__init__→ handler registration →
AttributeErroron 2.0 → server never starts. - Verified at runtime:
mcp-smoke-test (1.29)and(2.0)are both green on this head — a real
recce mcp-server --sse, a real handshake, a non-empty tool list. Plus a realClientSession
round-trip on 2.0 locally. - Residual — stdio transport: the smoke test drives only
--sse. The default
recce mcp-server(stdio) is exercised on neither major. The crash site is shared, so the
reported regression is covered; a future stdio-specific divergence would not be. - Residual —
tools/call: not exercised over the wire on either major (finding 4).
Blocking
1. HIGH — mcp 2.0 does no input-schema validation; 1.x does, by default
_handle_call_tool forwards params.arguments or {} straight to the handler. On mcp 1.x the
SDK validated first — mcp/server/lowlevel/server.py in 1.29.0:
def call_tool(self, *, validate_input: bool = True):
...
# input validation
if validate_input and tool:
try:
jsonschema.validate(instance=arguments, schema=tool.inputSchema)
except jsonschema.ValidationError as e:
return self._make_error_result(f"Input validation error: {e.message}")The 1.x branch of _build_server registers with that default, so it keeps validating. In mcp
2.0 the low-level server imports jsonschema nowhere at all (it survives only in
mcp/client/session.py, for client-side output schemas). Nothing replaces it here.
That is a silent divergence, not a louder error. Concrete case using this repo's own schema —
impact_analysis declares "skip_value_diff": {"type": "boolean"}
(recce/mcp_server.py:1533) and the handler reads it at recce/mcp_server.py:2009 /
:2161:
skip_value_diff = arguments.get("skip_value_diff", False)
...
if not skip_value_diff:An agent sending {"skip_value_diff": "false"} gets, on 1.x, a clean
isError=True, "Input validation error: 'false' is not of type 'boolean'". On 2.0 the
string is truthy, the value comparison is silently skipped, and the agent receives a
result that looks complete. Same class applies to missing required fields and to
array-typed params receiving a bare string.
Validating in the adapter restores parity, and adds no dependency — jsonschema is already
required by mcp on both majors (and by dbt-core):
async def _handle_call_tool(self, ctx, params) -> CallToolResult:
arguments = params.arguments or {}
tool = next((t for t in await self._list_tools() if t.name == params.name), None)
if tool is not None:
# `Tool.inputSchema` on 1.x, `Tool.input_schema` on 2.0 — this adapter is
# also driven by the tests on 1.x, so read it version-agnostically.
schema = getattr(tool, "input_schema", None)
if schema is None:
schema = tool.inputSchema
try:
jsonschema.validate(instance=arguments, schema=schema)
except jsonschema.ValidationError as e:
return CallToolResult(
content=[TextContent(type="text", text=f"Input validation error: {e.message}")],
isError=True,
)
try:
content = await self._call_tool(params.name, arguments)
...1.x caches the tool definitions (_tool_cache) rather than rebuilding them per call; worth
mirroring if _list_tools() turns out to be hot, but correctness first.
(Raised by a cross-model reviewer; I confirmed it against the source of both SDKs, quoted above.)
2. HIGH — tests/test_mcp_e2e.py was not migrated; 5 tests fail on mcp 2.0
The PR adds tests/mcp_compat.py and routes test_mcp_server.py /
test_mcp_cloud_backend.py through it, but tests/test_mcp_e2e.py still reads
result.isError off a real SDK CallToolResult at lines 1265, 1273, 1283, 1293, 1303, 1317,
1326. Reproduced against this head with mcp==2.0.0:
AttributeError: 'CallToolResult' object has no attribute 'isError'. Did you mean: 'is_error'?
5 failed, 55 passed
All five failures share that one cause. Substituting .isError → .is_error in a scratch copy
gives 60 passed — which incidentally confirms the server-side 2.0 error contract works end
to end through a real ClientSession.
The environment problem you hit (duckdb rejecting python_scan_all_frames during fixture
setup) was real, and it masked this: the fixtures died before any assertion ran, so the file
looked merely un-runnable rather than incompatible. Once fixtures can run, it fails for a
related reason.
A bare .is_error would break 1.x, so the fix belongs in the compat module — the same
normalisation invoke_call_tool already performs:
def is_error(result) -> bool:
"""Read a ``CallToolResult`` error flag regardless of SDK field naming."""
value = getattr(result, "is_error", None)
if value is None:
value = getattr(result, "isError", None)
return bool(value)Non-blocking
3. MEDIUM — the widened pin is not propagated to the lockfile or to the test matrix
Two consequences of mcp = ["mcp>=1.23,<3"] that did not follow it:
a. uv.lock is stale. It still records specifier = "~=1.23" for the extra (uv.lock:2561)
and pins mcp 1.28.1 (uv.lock:1251). uv lock --check at this head reports "The lockfile at
uv.lock needs to be updated". CI stays green only because no workflow uses --locked /
--frozen, so every uv sync silently re-resolves and rewrites the lock in the working tree.
uv lock + commit.
b. The MCP test suite never runs on 2.x. tests-python.yaml → Test Python Versions uses the
tox [testenv:{3.10,3.11,3.12,3.13}] env, which carries no mcp dep, so the three MCP modules
are skipped at collection (collected 1334 items / 3 skipped). Only [testenv:dbtlatest] has
mcp, pinned mcp>=1.0.0 — below this project's own floor, with no upper bound — and on this
run it resolved to mcp==1.27.1 (visible in the Test DBT Versions log). So the whole MCP
suite runs exactly once in CI, on 1.27.1. That is why finding 2 is invisible.
Using tox's native extras key inherits the declared window instead of drifting from it:
[testenv:dbt{1.6,1.7,1.8,1.9,latest}]
extras =
dbtlatest: mcpAn explicit 2.x leg that runs tests/test_mcp*.py is what actually closes the gap.
4. MEDIUM — the smoke test never calls a tool
The handshake covers initialize + tools/list. tools/call is never sent, so
_handle_call_tool — the adapter this PR adds specifically to preserve the error contract —
has no automated coverage on either major. Note this is also now the only end-to-end path
on 1.x: the migrated unit tests call _handle_call_tool directly, which on 1.x is not the
registered handler, so nothing exercises the decorator path any more.
Two more POSTs in the same loop close it on both majors over the real wire: one cheap
successful call (get_server_info), and one deliberately failing call (a nonexistent tool name)
asserting "isError":true in the SSE response. That would also have caught finding 1, if the
failing call used a schema-invalid argument instead.
5. LOW — failure paths leave the server and the SSE stream running
Every exit 1 inside check_mcp_server_status (lines 148, 157, 168, 174, 184) fires after
recce mcp-server is already backgrounded, and after line 153 also leaves curl -sN alive.
Both inherit the CI step's stdout, so a failed smoke test can keep the step open past the point
where it has anything left to say. check_server_status has the same shape, so this is not a
regression — but the new function orphans two processes rather than one, and a
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT covers both cheaply.
6. LOW — tool_count -lt 1 is a weaker gate than the risk it guards
The failure mode this job exists to catch is registration, and in server mode list_tools
advertises 20 tools. >= 1 still passes if 19 of them silently stop registering. Asserting a
known name is one more jq line and does not get brittle as tools are added:
jq -e 'select(.id == 2) | [.result.tools[].name] | index("lineage_diff")' <<< "$responses" > /dev/null7. NIT — the ~= comment describes patch releases; PEP 440 gives minor too
mcp~=1.29 expands to >= 1.29, == 1.*, i.e. >=1.29,<2.0 — a future 1.30 is picked up, not
just patches. Your own reply on the earlier thread states this correctly ("a new patch or minor
release is picked up automatically"); only the in-code comment is off.
8. NIT — input_schema() should test for None, not falsiness
getattr(tool, "input_schema", None) or tool.inputSchema falls through to tool.inputSchema
when the schema is an empty dict, which raises AttributeError on 2.0 (no such attribute
there). Unreachable today — every tool declares a non-empty schema — but it is the same
is None guard this codebase already documents elsewhere.
Cross-model reconciliation
A second reviewer on a different model ran against the same diff.
| Bucket | Count | Items |
|---|---|---|
| Agreement (both) | 1 | no real tools/call coverage on either registration path (finding 4) |
| Claude-only | 5 | findings 2, 3b, 6, 7, 8 |
| Cross-model-only | 3 | findings 1, 3a, 5 |
| Contradictions | 0 | — |
No arbitration was needed: each cross-model-only finding was verified directly against primary
sources (SDK source for finding 1, uv lock --check for 3a, the script's own control flow for
5) rather than adjudicated between models. Note that the highest-severity item in this review
came from the lane the primary agents did not cover — worth weighing against any instinct that
model agreement is what makes a finding real. The human with domain context is still the decider.
Advisory (not posted as inline comments)
- PR body is stale against the final implementation. It still describes the stdio design
("speaks stdio, so there is no port to poll — the check feeds itinitialize/ ...") that
bad52311replaced with HTTP/SSE, and cites "mcp 1.28.1 and 2.0.0" where the matrix is
["1.29", "2.0"]. The body is what the release note and future archaeology inherit. .claude/skills/recce-mcp-dev/SKILL.md:18— "MCP SDK quirk — Handler must raise for
SDK to setisError=True" is now version-dependent: true on 1.x, false on 2.0 where
_handle_call_toolconverts explicitly. Worth updating in the same PR, along with a note about
input validation if finding 1 is addressed.mcp-smoke-testruns the fullrecce run/recce summaryassertion block before it
reaches the MCP server. The dbt artifacts are genuinely needed; those assertions are not. As
written, a summary-rendering regression turns the MCP job red for an unrelated reason.
mcp 1.x validates `tools/call` arguments against the tool's `inputSchema` inside `Server.call_tool(validate_input=True)` and returns `Input validation error: ...`. mcp 2.0's low-level server dropped that entirely -- `jsonschema` survives only in the client, for output schemas -- so the 2.0 adapter forwarded raw arguments straight to the tool. That fails silently rather than loudly. `impact_analysis` declares `skip_value_diff` as a boolean; the string `"false"` is truthy, so on 2.0 the value comparison is skipped and the agent gets a result that looks complete. Missing required fields and array params given a bare string diverge the same way. `_handle_call_tool` now validates first, with the same message 1.x emits. Schemas are cached and refreshed on a miss, mirroring the SDK's own `_tool_cache`: `set_backend` can change the advertised surface at runtime, and rebuilding the list per call would forge a `Returning N tools` log line on every `tools/call`. Verified on mcp 1.29.0 and 2.0.0 -- 259 passed on both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Kent <iamcxa@gmail.com>
`tests/test_mcp_e2e.py` was the one test module left reading `result.isError`
off a real SDK `CallToolResult`. That attribute exists only on mcp 1.x, so
the module failed 5/60 on 2.0 with
AttributeError: 'CallToolResult' object has no attribute 'isError'
No CI job could see it: the MCP suite runs exactly once, in the tox
`dbtlatest` env, whose `mcp>=1.0.0` resolves to a 1.x release.
Reading `.is_error` instead would just move the breakage to 1.x, so the
normalisation `invoke_call_tool` already did is lifted into a free
`is_error()` -- it also has to take results built by a real `ClientSession`,
not only ones this module builds.
`input_schema()` now tests for `None` rather than falsiness, so an empty
schema cannot fall through to the attribute the other major lacks.
Verified on mcp 1.29.0 and 2.0.0 -- 259 passed on both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
The check stopped at `initialize` + `tools/list`, which is the part the two
SDK majors agree on. What actually differs is the error contract, and
nothing exercised it:
- id 3 calls `get_server_info` and asserts the call succeeds.
- id 4 sends `{"select":123}` to `lineage_diff`, whose schema says string,
and asserts `isError` plus `Input validation error`. 1.x rejects that in
the SDK; on 2.0 the adapter has to.
Verified against a real `recce mcp-server --sse` on mcp 1.29.0 and 2.0.0
(20 tools, both assertions pass), and with the argument made valid, where
the id 4 assertion correctly fails — so it is not vacuous.
Two smaller things in the same check:
- `tool_count -lt 1` passes with 19 of 20 tools silently unregistered,
which is the exact failure this job exists to catch. Assert `lineage_diff`
is named.
- Every `exit 1` fired with the server, and later the SSE reader, still
running and holding the CI step's stdout. An EXIT trap covers both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kent <iamcxa@gmail.com>
The lock still recorded the extra as `~=1.23`, so it disagreed with pyproject the moment the pin moved and `uv lock --check` failed. Nothing in CI uses `--locked` or `--frozen`, which is why it stayed green: every `uv sync` silently re-resolved and rewrote the lock in the working tree. Regenerating only rewrites the recorded specifier (plus two marker refinements from a newer resolver); the resolved mcp version is unchanged, since 1.28.1 still satisfies the new window. Moving that is a separate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Kent <iamcxa@gmail.com>
The MCP suite ran exactly once in CI. `Test Python Versions` uses tox envs with no mcp dep at all, so all three MCP modules were skipped at collection (`collected 1334 items / 3 skipped`); only `dbtlatest` carried mcp, pinned `mcp>=1.0.0` -- under the project's own floor, unbounded above -- which resolved to 1.27.1. Nothing ever ran pytest against 2.x, which is how a module that fails 5/60 on mcp 2.0 shipped green. `mcp-smoke-test` already installs both majors, so it is the cheap place to close this: the smoke step proves the server boots, and the new step proves the handlers behave. tox now takes mcp through the extra, so that env inherits the window pyproject declares instead of restating it wrongly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Kent <iamcxa@gmail.com>
The skill still read `Handler must raise for SDK to set isError=True` as a flat rule. That is true on mcp 1.x and false on 2.0, where a raised exception becomes a protocol error and the adapter has to convert it -- so following the rule as written on 2.0 changes what an agent sees. Also records the two things this PR made load-bearing and neither obvious nor greppable: `inputSchema` is enforced (2.0 does no validation of its own, so `type` and `required` stop being documentation), and which CI job covers which mcp version, since the tox envs resolve only one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Kent <iamcxa@gmail.com>
|
Pushed fixes for all 8 review threads directly to this branch (
Verification, on both majors rather than only the one CI resolves:
The 5 skips are the usual The new smoke assertion was checked for vacuity as well: with the deliberately-invalid argument made valid, it fails with exit 1 rather than passing quietly. One thing left for you, since it is your text: the PR body still describes the stdio design that I am not clearing my own |
|
@kentwelcome — ready for your look at the 6 commits on CI is green on the new head, including the coverage that was missing before:
That second row is the point — before this, no CI job ran pytest against mcp 2.x at all. Copilot has been re-requested on the new commits. My One item is still yours, since it is your text: the PR body describes the stdio design that |
Code Review: PR #1504SHA Issues
Notes
Suggested fixesIssue 1 — require the member to exist: if ! jq -e 'select(.id == 3) | .result != null and .result.isError != true' > /dev/null <<< "$responses"; thenIssue 2 — build once, invalidate where the surface actually changes. if not self._tool_schema_cache:
self._tool_schema_cache = {tool.name: _tool_input_schema(tool) for tool in await self._list_tools()}
return self._tool_schema_cache.get(name)plus Extending the existing test to a second unknown-name call would have caught it. Verified
|
kentwelcome
left a comment
There was a problem hiding this comment.
NO-GO at 6bec82b4 — 2 issues, 2 notes. Full review: #1504 (comment) (--request-changes is not available on one's own PR.)
Two review findings on the validation work: - `_get_tool_input_schema` refreshed on a miss, and an unknown tool name misses every time — so every bad call rebuilt all 20 tool definitions and forged a `Returning N tools` line plus an MCPLogger `list_tools` entry. Measured 3 rebuilds for 3 unknown calls. Build once instead and have `_tool_set_backend` clear the cache, since it is the only thing that moves the advertised surface after `__init__`. The existing test passed because it only called a name already in the cache; it now calls an unknown one twice, and fails against the old code. - The smoke test's id-3 assertion read `.result.isError != true`, which is true when there is no `result` at all — a JSON-RPC error response, i.e. exactly the escaped-exception regression that call exists to catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Kent Huang <kent@infuseai.io>
Review outcome —
|
| Finding | Status | Evidence |
|---|---|---|
smoke_test.sh:202 — id-3 assertion false green |
Fixed | Rejects a JSON-RPC error response, still passes a real success, still fails on isError: true |
mcp_server.py:737 — unknown name re-announces the tool list |
Fixed | Unknown name ×3 → 0 rebuilds (was 3); the extended test fails against the pre-fix code |
Both notes are left open deliberately, as non-blocking author calls:
mcp_server.py:756—jsonschema.SchemaErrorstill escapes as a transport error if a tool ships a malformed schema.mcp_server.py:19—jsonschemastays an undeclared direct import, satisfied bymcp's own dependency across the whole>=1.23,<3window.
Verified at this head: 200 tests on mcp 1.29.0 and on mcp 2.0.0, 60 e2e on 1.28.1, black / isort / flake8 clean, and the real recce mcp-server --sse smoke check green on both majors (20 tools, success and invalid-input assertions).
GitHub does not allow --approve on one's own PR, so the review state cannot be flipped from here; the verdict above is the record.
PR checklist
What type of PR is this?
fix— compatibility fix for the optionalmcpdependency.What this PR does / why we need it:
recce mcp-serverfails to start againstmcp2.0:mcp 2.0 removed the low-level
Serverdecorators (@server.list_tools(),@server.call_tool()). Handlers are now passed to the constructor and use a(ctx, params) -> Resultsignature. This PR supports both majors rather thancutting over, so users on
mcp1.x keep working.MCP_V2flag selects the registration path at import time._setup_handlersbecomes_make_handlers, returning the same two handlerbodies unchanged — still
List[Tool]/List[TextContent], still raising onerror. On 1.x they are registered with the old decorators; on 2.0 the thin
_handle_list_tools/_handle_call_tooladapters wrap them intoListToolsResult/CallToolResult._handle_call_toolcatches exceptions and returnsisError=Trueexplicitly.1.x did that inside the decorator; 2.0 turns a raised exception into a
JSON-RPC protocol error instead, which an agent reads as a transport failure
rather than a tool failure. Without this the error contract would silently
change on 2.0.
tests/mcp_compat.pyhelpers instead of pokingserver.server.request_handlers[...](that dict is gone in 2.0) and insteadof reading
Tool.inputSchema(renamedinput_schemain 2.0).mcp~=1.23tomcp>=1.23,<3.Second commit adds the regression check that was missing. Nothing in CI ever
started
recce mcp-server, which is why a startup failure shipped:smoke_test.shgains anmcp-servermode.recce mcp-serverspeaks stdio,so there is no port to poll — the check feeds it
initialize/notifications/initialized/tools/listand asserts the server names itselfand advertises at least one tool.
SMOKE_SERVERselects the surface and defaults toserver, so every existingcaller behaves exactly as before.
SMOKE_MCP_VERSIONpins which SDK versionto install, because
mcpis an optional extra that CI'suv syncdoes notcarry.
mcp-smoke-testjob runs that mode againstmcp1.28.1 and 2.0.0.Which issue(s) this PR fixes:
None.
Special notes for your reviewer:
Verified on both majors, since CI only exercises one:
tests/test_mcp_server.py+test_mcp_cloud_backend.pypytest tests/)ClientSessionover memory streamsisError=Trueon failureThe wire check ran an actual MCP client against the server on both versions to
confirm registration, a successful call, and the error path produce the same
result.
The new smoke-test function was exercised locally three ways: against a real
recce mcp-serveron mcp 2.0.0 and on 1.28.1 (both reported 20 tools), andagainst a stub that reproduces the original startup failure, which fails the
check with exit 1 as intended.
Two things worth knowing:
tests/test_mcp_e2e.pycould not run in my mcp-2.0 environment for anunrelated reason: its duckdb rejects dbt-duckdb's
python_scan_all_framessetting during fixture setup. It passes fully on the mcp 1.28 environment
(60 passed), which is the version CI installs.
httpx2. Worth apip checkin a clean environment before anyone pins forward to 2.0.
The 1.x branch in
_build_servercan be deleted once the floor moves tomcp>=2.Does this PR introduce a user-facing change?: