Skip to content

feat(mcp): session-scoped notification delivery and wire-format negotiation#273

Open
vitaliyslion wants to merge 1 commit into
zzet:mainfrom
vitaliyslion:feat/mcp-session-notifications
Open

feat(mcp): session-scoped notification delivery and wire-format negotiation#273
vitaliyslion wants to merge 1 commit into
zzet:mainfrom
vitaliyslion:feat/mcp-session-notifications

Conversation

@vitaliyslion

Copy link
Copy Markdown
Contributor

Summary

Daemon-socket MCP sessions (the transport used by gortex mcp proxying and bridge-style clients like Pi) were never registered with mcp-go's internal client-session tracking, so any server-initiated notification — tools/list_changed after a tools_search promotion, resources/updated, etc. — was silently dropped for them. This PR wires those sessions in so pushes actually reach the client, and adds a capability-based mechanism for clients to self-declare their compact wire-format support instead of relying solely on a server-side client-name allowlist.

Changes

  • Added SessionStartedHook (internal/daemon/server.go): an optional hook MCPDispatcher implementations can satisfy to receive a write func([]byte) error callback per connection, invoked after the handshake ack and before the first frame is dispatched. Server-initiated frames now share the connection under one write lock so they never interleave with reply frames.
  • Added ConnectSession / DisconnectSession / WithClientSession (internal/mcp/conn_session.go, new): registers a daemon-socket connection as a live mcp-go client session so SendNotificationToAllClients (which mcp-go gates on session-initialized state) can actually deliver to it.
  • cmd/gortex/daemon_mcp.go: mcpDispatcher now implements SessionStarted/SessionEnded to connect/disconnect the session, and attaches the client session to the request context via WithClientSession before dispatch.
  • Added a gortex/wire MCP capability: a client can declare its supported compact formats during initialize (capabilities["gortex/wire"]: ["gcx", "toon"], in preference order). maybeSnoopInitialize parses it and calls the new Server.NoteSessionWireFormats; resolveSessionFormat prefers this declaration over the hardcoded defaultFormatForClient name allowlist, so new clients get compact output without a server-side code change.
  • internal/mcp/server.go: added sessionState.wireFormats + recordWireFormats/snapshotWireFormats, NoteSessionWireFormats, and formatFromWireCapability (maps the declared list to the first format this server can emit, skipping unknown/future format names).

Testing

  • All tests pass (go test -race ./internal/mcp/... ./internal/daemon/... ./cmd/gortex/...)
  • New tests added for new functionality (internal/mcp/conn_session_test.go, additions to internal/mcp/format_negotiation_test.go and internal/daemon/mcp_roundtrip_test.go)
  • Benchmarks run if performance-relevant

Checklist

  • Code follows existing patterns in the codebase
  • No unnecessary abstractions added
  • Language extractor includes Meta["methods"] for interfaces (if applicable)
  • Methods have EdgeMemberOf edges to their containing type (if applicable)

@vitaliyslion

Copy link
Copy Markdown
Contributor Author

On the gortex/wire capability: this PR adds it purely additively — defaultFormatForClient's hardcoded allowlist (claude-code, cursor, vscode, zed, aider, kilocode, opencode, openclaw, codex, omp-coding-agent) still stands as the fallback, and resolveSessionFormat only prefers a client's self-declared capabilities["gortex/wire"] over it when present.

Once this architecture is approved, the follow-up is to flip the model around: have each of the currently-allowlisted clients declare gortex/wire: ["gcx", ...] themselves during initialize (their bridge/plugin already knows what it can decode), and then delete knownAgentClients / defaultFormatForClient from the server entirely. That removes the need for a server-side release every time a new client wants compact output — the allowlist becomes a distributed fact each client owns, not a table Gortex maintains on their behalf.

Pi's own bridge (next PR) will be the first client built against this from day one — self-declaring instead of getting a server-side entry — so it'll double as the reference implementation for migrating the rest.

@zzet

zzet commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Hey @vitaliyslion,

Once this architecture is approved

The capability placement is hard to change later, and it's a one-door decision. According to the change, the client declares capabilities["gortex/wire"] as a top-level key under capabilities. That's non-standard MCP: custom capabilities belong under capabilities.experimental, and typed SDKs can't even emit a custom top-level key — mcp-go's own ClientCapabilities struct only has experimental/roots/sampling. So the exact clients this feature targets ("new clients without a server-side change") can't use it unless they hand-roll their initialize JSON.
I'd accept capabilities.experimental["gortex/wire"] as well (or instead) before this ships and third parties start depending on the current shape.

Also, such a change requires a documentation actualisation for a public protocol extension. docs/mcp.md:70 documents the client-name allowlist, but nothing anywhere mentions gortex/wire — and the entire point of the feature is that external client authors adopt it. At minimum, a paragraph in docs/mcp.md next to the allowlist description.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants