Skip to content

Classify logging/setLevel as a Legacy-only method - #6184

Open
amirejaz wants to merge 1 commit into
mainfrom
classify-legacy-only-methods
Open

Classify logging/setLevel as a Legacy-only method#6184
amirejaz wants to merge 1 commit into
mainfrom
classify-legacy-only-methods

Conversation

@amirejaz

@amirejaz amirejaz commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

vMCP tool calls against a dual-era stdio backend fail, while the gateway reports healthy. Reproduced against github-mcp-server v1.6.0 over stdio:

backend unavailable: tool call failed on backend gh-c: connection closed:
  calling "tools/call": client is closing: sending "logging/setLevel":
  MCP-Protocol-Version header "2026-07-28" does not match _meta protocol version "": Bad Request

The chain:

  1. mcpcompat/client does not forward the caller's requested ProtocolVersion, so go-sdk negotiates its own latest -- 2026-07-28 -- even over a Legacy initialize. A dual-era backend accepts it and echoes it back.
  2. Per the lifecycle spec the client must then send MCP-Protocol-Version: 2026-07-28 on every subsequent request.
  3. logging/setLevel is Legacy-only: Modern carries the level per-request in the reserved io.modelcontextprotocol/logLevel _meta key instead (see MetaKeyLogLevel), and SEP-2577 deprecates even that. go-sdk therefore sends it with no Modern _meta to pair with the header.
  4. ClassifyRevision saw a Modern header with no Modern _meta, called it an inconsistent Modern request, and rejected it with -32020.
  5. go-sdk treats that rejection as fatal — the session closes and the in-flight call dies.

Generalise the existing initialize short-circuit into a legacyOnlyMethods set and add logging/setLevel. Both are methods with no Modern counterpart, so a Modern protocol header can never make them Modern, and neither should reach the header/_meta consistency check.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (described below)

Two TestClassifyRevision cases: logging/setLevel with a Modern header and no _meta, and with spoofed Modern _meta — both must classify Legacy, mirroring the existing initialize pair.

End-to-end A/B against github-mcp-server v1.6.0 over stdio, driving a real tools/call through a vMCP gateway:

tools/call
origin/main failsclient is closing: sending "logging/setLevel"
this branch succeeds — returns the live GitHub payload

pkg/mcp/... and pkg/transport/... pass with -race. task lint-fix is clean on the changed files; the remaining cmd/thv/app/upgrade.go gosec G115 finding is pre-existing and untouched.

Does this introduce a user-facing change?

Yes. vMCP can call tools on dual-era stdio backends again — github-mcp-server v1.6.0 among them. Before this, such a gateway reported Ready with all health checks passing and still failed every tool call.

Special notes for reviewers

Why this hid behind green health checks. enableBackendLogging is called only from legacyCallTool — not from ListCapabilities, resources or prompts. Health checks and tools/list never send logging/setLevel, so they stayed green while every tool call died. Worth knowing when reading the vMCP health status: it does not exercise the tool-call path.

Regression window. The strict classifier entered the streamable proxy in #5839/#5884 and shipped in v0.41.0; v0.40.1's proxy answers the same request with {"result":{}}. Verified by curling both builds directly.

The deeper cause is upstream. mcpcompat/client.Initialize silently drops the caller's ProtocolVersion (zero references to the field), so no caller can control what it negotiates — that is also what produced #6154. Fixing it in toolhive-core would remove the surprise, but this change is worth making regardless: a dual-era backend can legitimately negotiate a Modern version over a Legacy handshake, and a Legacy-only method must stay classifiable after it does.

Generated with Claude Code

A dual-era backend can negotiate 2026-07-28 over a Legacy initialize
handshake. The client must then stamp MCP-Protocol-Version: 2026-07-28
on every subsequent request, per the lifecycle spec. But logging/setLevel
is Legacy-only -- Modern carries the level per-request in the reserved
io.modelcontextprotocol/logLevel _meta key instead -- so go-sdk sends it
with no Modern _meta to pair with the header.

The classifier treated that as an inconsistent Modern request and
rejected it with -32020. go-sdk clients treat the rejection as fatal:
the session closes and the in-flight call fails. Observed with vMCP
driving github-mcp-server v1.6.0 over stdio, where every tool call died
on the logging/setLevel that precedes it -- health checks and tools/list
stayed green because only the tool-call path enables backend logging.

Generalise the existing initialize short-circuit into a legacyOnlyMethods
set and add logging/setLevel to it. Both are methods with no Modern
counterpart, so a Modern protocol header can never make them Modern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The failing Tests / Test Go Code check isn't a flake, it's a real, deterministic conflict with an existing test.

TestIntegration_Modern_RealBackend_LoggingContract in pkg/vmcp/server/modern_realbackend_integration_test.go (around lines 398-434) pins the exact contract this PR replaces. Its doc comment and both subtests assert that a well-formed Modern logging/setLevel should be rejected as method-not-found (404 + -32601 from dispatchModern), and a malformed one should get 400 + -32020 (HeaderMismatch).

Now that ClassifyRevision treats logging/setLevel as unconditionally Legacy, neither request reaches those code paths anymore. The well-formed case gets routed as Legacy and 400s with a plain-text "protocol version 2026-07-28 is only supported on stateless HTTP servers" body instead of the expected JSON-RPC error, and the malformed case fails at json.Unmarshal for the same reason (the response isn't JSON).

This test (and its doc comment, which explicitly documents the now-superseded contract) needs to be updated to reflect the new Legacy classification before this can merge. Re-running CI won't help since the failure is deterministic, not timing-related.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants