Skip to content

MCP server: full compliance with MCP spec 2026-07-28 (stateless protocol) #265

Description

@bobakemamian

Summary

MCP revision 2026-07-28 is a breaking, stateless redesign of the protocol (changelog). The initialize handshake and sessions are gone: every request self-describes via reserved _meta fields (io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities), a new server/discover RPC is mandatory, every result carries a required resultType, list results carry required cache hints (ttlMs, cacheScope), and ping, sampling, roots, and the MCP logging feature are removed or deprecated. Server-initiated requests no longer exist — server→client interaction (elicitation) happens via the new MRTR pattern (resultType:"input_required"). Error codes -32020..-32099 are now spec-reserved (e.g. -32022 UnsupportedProtocolVersionError).

Our server (internal/mcpserver/, wired by cmd/mcp.go) is a hand-rolled stdio JSON-RPC server that still advertises 2024-11-05 — and echoes back any version a client requests without validating it. Goal of this issue: 100% compliance with 2026-07-28, keeping legacy clients working (dual-era).

Already compliant (no work needed)

Much of the current design survives the new spec untouched:

  • stdio framing: newline-delimited JSON-RPC, one UTF-8 message per line, no embedded newlines; stdout carries only protocol messages, diagnostics on stderr (which is also the spec's recommended replacement for the now-deprecated Logging feature).
  • Server never initiates JSON-RPC requests and never responds to notifications — both hard requirements in the MRTR world.
  • Accidentally stateless already: tools/call works without initialize; no per-connection context is accumulated; tools/list is deterministic and identical for every client.
  • Two-tier error model (tool failures as isError content blocks vs. protocol failures as JSON-RPC errors) is exactly the required split; no custom codes minted in the newly reserved -32020..-32099 range.
  • Tool names and inputSchemas conform (valid JSON Schema, no $ref, so the new network-dereference prohibitions are trivially met).
  • Security baseline: input validation, per-button rate limit (10/min), 1-concurrent guard, 120s hard cap, mcp_enabled opt-in.
  • Does not implement the deprecated Roots, Sampling, or Logging features, nor the deprecated HTTP+SSE transport.
  • Shutdown (EOF + WaitGroup drain + SIGINT/SIGTERM), concurrent request handling with serialized stdout writes, and env-based credentials for stdio all match spec guidance.

Phase 1 — Modern-era core (MUST, breaking)

  • Implement server/discover: return resultType:"complete", supportedVersions, capabilities ({tools:{}}), ttlMs/cacheScope, and io.modelcontextprotocol/serverInfo ({name:"buttons", version}) in result._meta (SEP-2575). Currently returns -32601.
  • Replace the protocol-version echo in initializeResult (server.go) with an explicit supported-versions list; reject undeclared versions with -32022 UnsupportedProtocolVersionError, data: {"supported":[...], "requested":"..."}. Remove the echo assertion in server_test.go.
  • Parse params._meta on every request. For modern-era requests, require both io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities; reject with -32602 when missing. Capability gating is per-request, never per-connection.
  • Add required resultType:"complete" to every result (server/discover, tools/list, tools/call) (SEP-2322). Legacy-era initialize/ping results may stay bare.
  • Add required ttlMs + cacheScope to tools/list and server/discover results (CacheableResult, SEP-2549). The meta-tool surface is fixed for the process lifetime, so a generous ttlMs with cacheScope:"private" is right — document the choice.
  • Honor notifications/cancelled on stdio: track in-flight requests by ID with per-request context.CancelFunc; cancel the press (existing process-group kill path), suppress the response, handle the cancel-after-complete race gracefully, ignore unknown IDs. Today all notifications are ignored and a 120s press can't be aborted.
  • Dual-era gating: keep initialize / notifications/initialized / ping working for legacy clients only; serve any request carrying modern _meta statelessly under the new rules. Never require initialize before tools/* in either era (already true — pin with a test).
  • Return -32600 Invalid Request for structurally invalid requests (missing method). The constant exists but is never used; these currently fall through to -32601.

Phase 2 — SHOULDs

  • Include io.modelcontextprotocol/serverInfo in every modern-era result's _meta via a shared result-wrapping helper.
  • Unknown tool name in tools/call-32602 "Unknown tool: " (currently -32601, which is for unknown RPC methods).
  • tools/list with a non-empty cursor-32602 (we never issue cursors, so any cursor is invalid; today params are silently ignored).

Phase 3 — Conditional: Streamable HTTP (only if an --http mode ever ships)

Stdio-only is fully spec-compliant; no action while we stay there. But any future HTTP mode (plausible given the webhook listener) is gated on the 2026-07-28 Streamable HTTP bundle:

  • Single POST-only endpoint; 405 for GET/DELETE; 202 for notifications; JSON or request-scoped SSE responses; SSE stream close = cancellation; no SSE event IDs/resumability.
  • MCP-Protocol-Version header must match body _meta (mismatch = 400 + -32020 HeaderMismatch); validate mirrored Mcp-Method/Mcp-Name headers.
  • Origin validation (403 on invalid) against DNS rebinding; bind localhost only.
  • Never mint or echo Mcp-Session-Id (sessions are gone).
  • Auth, if any, per OAuth 2.1 + RFC 9728 Protected Resource Metadata, audience-bound tokens (RFC 8707), no token passthrough.

Phase 4 — Opportunities (MAY, not required for compliance)

  • structuredContent + declared outputSchema on the meta-tools (SEP-2106) — we already build JSON payloads, they're just stuffed into text blocks; keep the text block for backwards compat.
  • Tasks extension (io.modelcontextprotocol/tasks, SEP-2663) for long-running presses: advertise in server/discover capabilities.extensions, return a task handle (resultType:"task") backed by existing pressed/ history + $BUTTONS_PROGRESS_PATH JSONL, add tasks/get / tasks/update / tasks/cancel, high-entropy task IDs, gated on the client declaring the extension per-request. Would lift the 120s wall for MCP clients.
  • MRTR elicitation for missing buttons_press args: resultType:"input_required" with a flat-primitive requestedSchema, HMAC-protected requestState (treat echoes as attacker-controlled), capability-gated, and never elicit $ENV{...}-style secrets via form mode.
  • subscriptions/listen + toolsListChanged only if the tool surface ever becomes dynamic (e.g. a 1:1 button:tool mode). Static meta-tool surface correctly declares nothing today.
  • OTel trace-context _meta passthrough (SEP-414): thread traceparent/tracestate/baggage into stderr logs and press history for correlation.

Testing & docs

  • Extend server_test.go wire tests: modern-era _meta round-trip, -32022 / -32602 / -32600 paths, mid-press cancellation + cancel-after-complete race, dual-era coexistence, server/discover shape.
  • Update the CLAUDE.md MCP section and docs/buttons/mcp.mdx for the 2026-07-28 surface; regenerate docs/cli/buttons_mcp.md if flags change.

References

Activity

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

Metadata

Metadata

Assignees

Labels

Type

Fields

No fields configured for Task.

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions