fix(mcpcompat): close functional gaps vs mcp-go for loss-free migration (#156) - #157
Conversation
…sue #156 wave 1) U1 — Map capability flags to go-sdk ServerOptions.Capabilities so vMCP advertises tools/resources/prompts at initialize even with zero pre-registered features (merge blocker). U3 — Rekey the pending-request-context bridge from session ID to a per-POST crypto/rand nonce (X-MCP-Req-Nonce), eliminating the cross-request context bleed / identity-attribution race for concurrent POSTs on the same session (merge blocker, security). go-sdk does not propagate the per-POST HTTP context into session middleware, so the bridge is retained but keyed per-request. U6 — Narrow normalizeObjectSchema to replace only nil/empty/empty-type schemas with {"type":"object"}; pass $ref/oneOf/boolean/object- with-type-omitted through verbatim (matching mcp-go). Recover go-sdk AddTool panics on the per-session path so a bad overlay schema skips the tool instead of crashing the session. Tests: capability advertisement e2e, per-request context isolation under -race (verified to fail without the bridge), schema normalization table, non-object schema panic recovery, nil-schema tools/list e2e. Refs #156
…ssue #156 wave 2) 1a + U8 — Register go-sdk ProgressNotificationHandler and LoggingMessageHandler in the mcpcompat client; route to the compat OnNotification callback. dispatch() now carries notification params via toNotificationParams (JSON round-trip, _meta split), restoring progress and logging notifications end-to-end with their params. 1b — Confirmed go-sdk auto-emits tools/list_changed on SetSessionTools (no production code needed beyond U1); corrected the stale doc comments in notifications.go that claimed list_changed is dropped. Added SetLoggingLevel + mcp.LoggingLevel aliases (required for the logging-notification path; renamed/simplified vs mcp-go's SetLevel, documented). Tests: progress+logging e2e with param assertions, list_changed e2e, toNotificationParams unit table (meta split, error paths, nil guards), handler nil-param guards, method-string coverage for all three list_changed variants, broadcast delivery assertion, early-exit on list_changed. Refs #156
…l-session validate (issue #156 wave 3) 2 — Wire WithHeartbeatInterval to go-sdk ServerOptions.KeepAlive (previously a silent no-op); sessions now close on unanswered pings. 5 — Expose WithDisableLocalhostProtection(bool) so callers can preserve pre-migration behavior for local proxies with custom Host headers (default: protection ON, matching go-sdk). 6 — Add WithPageSize(n int) ServerOption so aggregating servers (vMCP) can raise the tools/list page size above go-sdk's default 1000. 3/U5 — Call SessionIdManager.Validate on local sessions too, not just rehydrated ones; restores per-request liveness and sliding TTL so a session terminated cross-pod or via auth-failure takes effect on the origin pod immediately. Tests: heartbeat eviction e2e, localhost-protection toggle e2e (raw non-localhost Host, both enabled and disabled), page-size pagination e2e with nextCursor, local-session validate-evicts-on-shared-terminate and served-without-manager. Refs #156
…issue #156 wave 4) 4/U7 — Constrain 401/unauthorized/404 string-matching to transport-level errors only by checking the captured HTTP status (from errorbody.go) first and using errors.As(*jsonrpc.Error) to distinguish RPC-level from transport-level errors. A JSON-RPC tool error whose message contains "unauthorized" no longer triggers a false-positive OAuth refresh. Restore mcp-go's any-4xx-on-initialize → ErrLegacySSEServer classification (400/403/404/405 on connect → legacy SSE; 401 stays ErrUnauthorized). String-matching remains as a best-effort fallback for transport failures with no captured body. Tests: false-positive JSON-RPC error isolation, transport 401/404/403 classification by captured status, 4xx-on-initialize legacy SSE restoration, string-matching fallback. Refs #156
…on docs, stale comments (issue #156 wave 5) U4 — Document elicitation delivery under JSONResponse mode (requires client continuous listening); add client-side ElicitationHandler interface, WithElicitationHandler option, and NewStreamableHttpClientWithOpts; rewrite server elicitation.go to mirror mcp-go (SessionWithElicitation, RequestElicitation, ErrNoActiveSession/ErrElicitationNotSupported). U9 — Populate before-hook request objects (mcp.CallToolRequest with tool name + args, mcp.ListToolsRequest with cursor) from go-sdk params; fix latent translateUnknownToolError type assertion (CallToolParamsRaw). U2 — Document the preset backend session ID limitation (upstream-only: go-sdk has no SessionID field on StreamableClientTransport; honored via resume path only, not Initialize). 7 — Fix remaining stale doc comments (WithSessionIdManager, package doc, SessionWithTools). Tests: elicitation e2e (works with continuous listening, fails without), handler error return, ErrNoActiveSession/ErrElicitationNotSupported guards, before-hook tool name+args+cursor, unknown-tool error contains tool name. Refs #156
JAORMX
left a comment
There was a problem hiding this comment.
(Submitted as COMMENT — GitHub rejects REQUEST_CHANGES on one's own PR; treat this as request-changes.) Verified each fix against go-sdk v1.6.1 and mcp-go v0.55.1 sources; U1/U3/U9/5/6 and the 4/U7 core are correct. Four issues must change first — see inline comments (heartbeat session eviction, global schema panic → poisoned sync.Once, Validate-error forgetSession, empty-body 4xx capture). Separately, the "zero functional loss" claim needs reconciling: notifications/tools/list_changed and notifications/message are still lost end-to-end through ToolHive's stdio bridge — the bridge never mutates its feature set (so go-sdk never auto-emits) and doesn't call WithLogging (so clients never setLevel and ServerSession.Log drops everything; the tests only pass because they call SetLoggingLevel first). Those need toolhive-side companions to #5729: WithLogging() + feature re-sync on upstream list_changed in the bridge, and WithPageSize in vMCP.
CRITICAL — Revert WithHeartbeatInterval → ServerOptions.KeepAlive wiring: go-sdk's KeepAlive sends an active ping request that closes sessions without a connected standalone SSE stream, incompatible with JSONResponse mode + JSON-only clients. WithHeartbeatInterval is now a documented no-op (passive SSE-comment design is TODO). HIGH — Recover global AddTool panics in buildServer so a non-object schema tool doesn't poison sync.Once (was nil-panic on every subsequent request). Improve normalizeObjectSchema: type-omitted object schemas with properties/required get type:object added (callable, matching mcp-go); truly non-object schemas pass through and are caught by recover. MEDIUM — Only forgetSession on genuine termination (isTerminated), not on transient Validate errors (Redis blip), preventing split-brain where a retry builds a second go-sdk session with the same ID. MEDIUM — captureErrorBody now records h.status unconditionally (regardless of body length) so empty-body 4xx (common 401/404) are classified by status, not lost to the string fallback. LOW — Document unbounded session growth gap and bridge notification POST limitation. Fix pre-existing data race: clientSession.owner converted to atomic.Pointer[MCPServer] (matching boundServer/goSession pattern) — SetSessionTools and registerAndSync were racing on the plain pointer. Tests: healthy session survives heartbeat interval (no eviction), non-object global tool doesn't poison server, type-omitted schema callable, transient Validate error doesn't forget session, empty-body 4xx classification, 10x -race clean. Refs #156
jhrozek
left a comment
There was a problem hiding this comment.
Reviewed the full diff (17 files, ~3.1k LOC) plus ran go build, go vet, go test -race ./mcpcompat/..., golangci-lint, and task license-check — all green. The PR is high quality: the per-request nonce context bridge (U3), the 401-string-scoping to transport-level errors (wave 4), the capability-flag wiring (U1), and the schema-normalization narrowing (U6) are all correctly reasoned and well-documented. Findings below are all non-blocking nits/minor gaps; none block merge.
One additional note that I couldn't anchor inline (the relevant code is pre-existing, not in this PR's diff): newClientSession (mcpcompat/server/session.go:98-110) spawns a drain goroutine for range cs.notifCh that never terminates and is never stopped on forgetSession. So every clientSession ever created leaks a goroutine for the process lifetime. This is distinct from the documented sessions/localSessions map-growth gap (server.go:148-174): even if the planned reaper drops map entries, the goroutine stays alive because nothing closes notifCh. If/when the reaper lands, closing notifCh in forgetSession (session.go:316) would let the drain goroutine exit — but it needs a sync.Once or LoadAndDelete guard against double-close, since sessionFor can re-create an entry for the same ID. Not a blocker for this PR since it's pre-existing, but worth tracking alongside the reaper work.
jhrozek
left a comment
There was a problem hiding this comment.
Review of the mcpcompat functional-gaps changes. Build, go test -race ./mcpcompat/..., and golangci-lint are all green, and the PR is unusually well-documented. The inline comments below are gaps/edge-cases worth a look before merge; my one blocking concern is the mapConnectError asymmetry (first comment).
Overall: request a look at the first inline comment before merge; the rest are non-blocking (a question, a leak worth folding in, and follow-up/doc notes).
Additional non-inline note — pre-existing goroutine leak (not introduced by this PR, but the fix would fit naturally here):
newClientSession (server/session.go:98-110) spawns go func(){ for range cs.notifCh }(), and notifCh is never closed anywhere in the package. forgetSession (session.go:316-319) drops the map entries but doesn't close the channel, so the drain goroutine — plus the clientSession it references — leaks for the process lifetime, one per session ever created. This compounds the documented finding-5 (unbounded sessions/localSessions growth). Both the drain goroutine and forgetSession predate this PR (introduced in bcdfd8b, already on main), so it's strictly out of scope — flagging because forgetSession is the natural teardown point to close notifCh (via sessions.LoadAndDelete so a DELETE-then-Validate double call can't double-close), and this PR is in the neighborhood. Fine to defer to a follow-up.
Skipped as INFO-only: (a) logging capability advertised only with WithLogging — matches mcp-go and delivery isn't capability-gated, just worth confirming log-emitting servers call WithLogging; (b) errorbody.go:72-78 comment says it "restores" resp.Body on a second error response but installs an empty one — traced unreachable in the current Connect flow.
Addresses all unresolved review findings from jhrozek (2026-07-13): 1. 5xx false-positive OAuth refresh (MEDIUM): a captured 5xx status whose body contained "401"/"unauthorized" still fell through to the string fallback → false OAuth refresh. mapTransportError now surfaces 5xx unchanged (only string-fallbacks when no status was captured at all). Refactored into mapStatusError/mapStringError for clarity. 2. Rehydrated transient Validate error inconsistency (MEDIUM): serveRehydrated returned 404 "Invalid session ID" on a transient Validate error, while the local path returned 503. Now: a cached rehydrated session with a transient error gets 503 (preserving the cache); an unknown (never cached) session still gets 404. 3. extractResourceMetadataURL stub (MINOR): the shim's headerRoundTripper has direct access to resp.Header and now captures WWW-Authenticate values into errBody. extractResourceMetadataURL parses the RFC 9728 §5.1 resource_metadata parameter (ported from mcp-go's well-tested parser) and populates AuthorizationRequiredError.ResourceMetadataURL end-to-end. No longer blocked by the go-sdk. 4. normalizeObjectSchema branch unification (NIT): unified the empty-type and no-type-key branches into a single typeIsStr/typeStr check, preserving the semantic distinction (empty-string type → normalize; absent type without properties/required → pass through). 5. notifCh drain goroutine leak (pre-existing, MINOR): forgetSession now closes notifCh via sync.Once, letting the drain goroutine exit. sessionFor re-creates a fresh channel for the same ID after forgetSession. 6. rehydrate lock scope (LOW): documented the cross-sid serialization cost of holding rehydratedMu across buildServer+Connect (not worth restructuring now per reviewer). Tests: - TestMapTransportError_5xxWithUnauthorizedBodyNotMisclassified (3 cases) - TestMapCallError_5xxSurfacesUnchanged - TestExtractResourceMetadataURL_FromCapturedHeader (5 cases) - TestMapCallError_Transport401_PopulatesResourceMetadataURL (e2e) - TestMapConnectError_Transport401_PopulatesResourceMetadataURL (e2e) - TestCaptureErrorBody_CapturesWWWAuthenticate (e2e through RoundTripper) - TestRehydratedSession_TransientValidateError_Returns503 - TestForgetSession_ClosesNotifChannel Verification: task lint (0 issues), task test (-race, all green), task license-check (clean).
Wave 6 — addresses jhrozek's review findingsPushed 1. 5xx false-positive OAuth refresh (MEDIUM —
|
Addresses all 5 inline comments from jhrozek's second review
(2026-07-13T09:27:55Z), which were missed in wave 6:
1. [BLOCKING] mapConnectError missing JSON-RPC guard (errors.go):
mapConnectError had no *jsonrpc.Error guard like mapCallError has.
An app-level JSON-RPC error at initialize (200 OK + error body,
h.status==0) with "unauthorized" text was misclassified as
ErrUnauthorized → false OAuth refresh. Added the symmetric guard:
when h.status==0 and err is a *jsonrpc.Error, surface unchanged.
Test: TestMapConnectError_DoesNotMisclassifyJSONRPCErrors (3 cases).
2. [Question] SetLevel compat alias (client.go + alias.go):
Shim had SetLoggingLevel but not SetLevel/SetLevelRequest/
SetLevelParams, breaking the drop-in contract for callers using
c.SetLevel(ctx, mcp.SetLevelRequest{...}). Added SetLevel method
(forwards to SetLoggingLevel) and re-exported SetLevelRequest/
SetLevelParams from the mcp package.
Test: TestSetLevel_CompatAlias (e2e against a live server).
3. [Compat gap] OnNotification drops resources/updated + elicitation/complete
(client.go): ResourceUpdatedHandler and ElicitationCompleteHandler
were left nil. Wired both via dispatch, mirroring the progress/logging
handlers. Updated OnNotification doc to list all forwarded types.
Test: TestResourceUpdatedAndElicitationCompleteHandlers.
4. [Follow-up] No keep-alive (transports.go): Strengthened the
WithHeartbeatInterval doc to explain the passive keep-alive is
load-bearing (elicitation + all server→client notifications depend
on the idle SSE stream staying open) and should be prioritized
before internet-facing vMCP use.
5. [Doc note] 503 diverges from mcp-go's 404 on Validate (transports.go):
Added a compat-note comment explaining 503 is not mapped by the client
classifier (errors.Is(err, ErrSessionTerminated) is false), so callers
keying re-init off mcp-go's 404→ErrSessionTerminated path won't re-init
on a transient blip.
Verification: task lint (0 issues), task test (-race, all green),
task license-check (clean).
Wave 7 — addresses jhrozek's second review (the one I missed)My apologies — jhrozek posted a second set of 5 inline comments at 1. [BLOCKING]
|
jhrozek
left a comment
There was a problem hiding this comment.
Approving — all review findings are resolved. Verified against c469b88 (build + client/mcp/server tests green, including the new TestMapConnectError_DoesNotMisclassifyJSONRPCErrors).
mapConnectErrorJSON-RPC guard — fixed with the symmetricerrors.As(&wireErr)guard + regression test.SetLeveldrop-in parity — fixed viaClient.SetLevel(ctx, mcp.SetLevelRequest)wrapper +SetLevelRequest/SetLevelParamsaliases.notifChgoroutine leak — fixed (wave 6) with async.Onceclose inforgetSession.OnNotificationdropped notifications — fixed by wiringResourceUpdatedHandlerandElicitationCompleteHandler.- 503 vs 404 divergence — compat note added at the 503 site.
- Keep-alive gap — acknowledged and documented with a prioritized TODO (passive SSE keep-alive). Reasonable to land as a tracked follow-up; worth prioritizing before internet-facing vMCP use, alongside the session-reaping / max-session-cap work also tracked under issue #156.
Nice, thorough iteration across the waves. 🚀
Summary
Closes #156 — closes the mcpcompat functional gaps vs mcp-go so the ToolHive migration (stacklok/toolhive#5729) lands with zero functional loss.
Five commits, one per wave, each reviewed by an independent panel (spec + standards + domain specialists + QA) with findings iterated before commit.
b22b8e1ServerOptions.Capabilities; U3 (merge blocker, security) per-request context bridge rekeyed to crypto/rand nonce; U6 normalize only nil/empty input schemas + recover per-session AddTool panicsa3020893c7ab69WithHeartbeatInterval→ServerOptions.KeepAlive; 5WithDisableLocalhostProtection(bool); 6WithPageSize(n int); 3/U5 per-requestValidatefor local sessions19c482bErrLegacySSEServer884cfd3ElicitationHandler+ serverRequestElicitation); U9 populate before-hook request objects (tool name + args + cursor); U2 document preset session ID limitation (upstream-only); 7 fix remaining stale doc commentsWhat landed (15 items)
Shim fixes (all 15 work items from the issue + 2 audit comments)
WithToolCapabilities/WithResourceCapabilities/WithPromptCapabilitiesmapped to go-sdkServerOptions.Capabilities; vMCP advertises tools/resources/prompts at initialize even with zero pre-registered features.pendingReqCtxrekeyed from session-ID to a per-POSTcrypto/randnonce (X-MCP-Req-Nonce); eliminates the cross-request context-bleed / identity-attribution race. go-sdk does not propagate per-POST context into session middleware (verified), so the bridge is retained but keyed per-request.normalizeObjectSchemanarrows to nil/empty-only;$ref/oneOf/boolean pass through verbatim; per-sessionAddToolpanics recovered and skipped.ProgressNotificationHandler+LoggingMessageHandlerwired in the client;dispatchcarries params viatoNotificationParams(JSON round-trip,_metasplit).tools/list_changedonSetSessionTools(no production code needed beyond U1); corrected stale doc comments.WithHeartbeatIntervalwired toServerOptions.KeepAlive(was a silent no-op).WithDisableLocalhostProtection(bool)exposed (default: protection ON).WithPageSize(n int)exposed (default 0 → go-sdk's 1000).SessionIdManager.Validatecalled for local sessions too; restores per-request liveness + sliding TTL.errors.As(*jsonrpc.Error); false-positive OAuth refresh on tool-error text eliminated; any-4xx-on-initialize →ErrLegacySSEServerrestored.ElicitationHandler/WithElicitationHandler/NewStreamableHttpClientWithOpts; serverSessionWithElicitation/RequestElicitation/ErrNoActiveSession/ErrElicitationNotSupported); JSONResponse mode documented (requires client continuous listening).mcp.CallToolRequestwith name + args,mcp.ListToolsRequestwith cursor); latenttranslateUnknownToolErrortype-assertion bug fixed (*CallToolParams→*CallToolParamsRaw).SessionIDfield onStreamableClientTransport; honored via resume path only).WithSessionIdManager, package doc,SessionWithTools, capability-flag comment,OnNotification,notifications.go,normalizeObjectSchema).Upstream asks (documented, not implemented — tracked in the issue)
ServerSession.StreamableClientTransport(blocks U2).Validate/Terminatehooks.Test coverage
Each item has e2e and/or unit tests that would fail without the fix:
TestAdvertisesToolsCapability_WithNoGlobalTools)-race(verified to fail without the bridge)list_changede2e onSetSessionToolstoNotificationParamsunit table (meta split, error paths, nil guards)nextCursortranslateUnknownToolError)Verification
task lint— 0 issues (golangci-lint + go vet)task test— all packages pass with-racetask license-check— cleanReview process
Each wave was independently reviewed by a panel (spec adherence, standards conformance, secure-code-reviewer, software-architect, devex-reviewer, QA test-expert) in fresh contexts. Cross-confirmed findings were iterated before commit:
toNotificationParamsunit tests + nil-param guards + broadcast delivery assertiontranslateUnknownToolErrorregression test + elicitation guard branches + doc fixesRefs #156