modelcontextprotocol / modelcontextprotocol/go-sdk
proposal: mcp: expose the JSON-RPC request id to receiving middleware and handlers
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5.1k
- Forks
- 543
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 37
Description
I maintain an MCP server built on this SDK and I instrument it with OpenTelemetry through a receiving middleware. There is one attribute the MCP semantic convention asks for that I cannot fill, because nothing the SDK hands a middleware or a handler carries the JSON-RPC id of the message being handled. I would like to propose a small accessor for it, and I am happy to write the change once you tell me which shape you want.
To be clear about the scope: this is not a request for OpenTelemetry support inside the SDK. #59 settled that question and I agree with where it landed. This is the other half of that conversation, the extensibility hook that lets the instrumentation live outside the SDK.
What the convention asks for
The MCP semantic conventions (docs/gen-ai/mcp.md in open-telemetry/semantic-conventions-genai) define an MCP server span, span kind SERVER, "reported by the MCP server when client initiates the request (or notification)". Its attribute table includes:
| Key | Requirement level | Description |
|---|---|---|
jsonrpc.request.id |
Conditionally Required When the client executes a request. |
A string representation of the id property of the request and its corresponding response. |
The note attached to it: "Under the JSON-RPC specification, the id property may be a string, number, null, or omitted entirely. When omitted, the request is treated as a notification. [...] Instrumentations SHOULD NOT capture this attribute when the id is null or omitted."
So this attribute is what tells a request span from a notification span, and it is what ties a client's span to the server's span for the same call: the convention's own stdio tool-call example carries jsonrpc.request.id with the value "3" on both, and when the trace context did not propagate through _meta the id and the session are the only things the two spans share. The key already ships in Go, as semconv.JSONRPCRequestID in go.opentelemetry.io/otel/semconv/v1.41.0 (and in v1.39.0), so the only missing piece is a way to read the value.
What the SDK does today
Line numbers are from main at 5bc078a.
Receiving middleware is handed an mcp.Request (MethodHandler at mcp/shared.go:112-115, Middleware at mcp/shared.go:129-130), and the doc comment on Server.AddReceivingMiddleware says it "is useful for tasks such as authentication, request logging and metrics" (mcp/server.go:1858-1859). The interface offers three accessors and the id is not among them:
// mcp/shared.go:606-614
type Request interface {
isRequest()
GetSession() Session
GetParams() Params
// GetExtra returns the Extra field for ServerRequests, and nil for ClientRequests.
GetExtra() *RequestExtra
}
The value exists one frame above. handleReceive holds the decoded *jsonrpc.Request, takes the method and the params off it, and then builds the Request without the id (mcp/shared.go:206-220):
mh := session.receivingMethodHandler()
re, _ := jreq.Extra.(*RequestExtra)
req := info.newRequest(session, params, re)
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
res, err := mh(ctx, jreq.Method, req)
newRequest takes a session, params and the transport's extra (mcp/shared.go:265, the two constructors at mcp/shared.go:300 and mcp/shared.go:315), and neither ClientRequest nor ServerRequest has a field an id could live in (mcp/shared.go:616-627).
The SDK does keep the id, privately, and its own handlers need it. ServerSession.handle puts it on the context under an unexported key (mcp/server.go:2010), (*Server).subscribe refuses a request that has none (mcp/server.go:1211-1215), and subscriptionsListen reads it the same way (mcp/server.go:1257). The value therefore already reaches my middleware on the context; the key to read it is unexported. The comment on that key reads as a deferred decision rather than a refusal (mcp/streamable.go:1177-1187):
// If we ever wanted to expose this mechanism, we have a few options:
// 1. Make ServerSession an interface, and provide an implementation of
// ServerSession to handlers that closes over the incoming request ID.
// 2. Expose a 'HandlerTransport' interface that allows transports to provide
// a handler middleware, so that we don't hard-code this behavior in
// ServerSession.handle.
// 3. Add a `func ForRequest(context.Context) jsonrpc.ID` accessor that lets
// any transport access the incoming request ID.
//
// For now, by giving only the StreamableServerTransport access to the request
// ID, we avoid having to make this API decision.
The observation that found it
I was filling the server span's attributes in a receiving middleware and ran out of ways to get the id:
srv.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
// req.GetSession() gives the session, req.GetParams() the params, and
// req.GetExtra() the token info and the HTTP header on streamable HTTP.
// Nothing returns the id of the message being handled, and the context
// value the SDK sets for its own handlers is keyed by an unexported type.
return next(ctx, method, req)
}
})
The sharpest form of the gap is this. notifications/cancelled is registered as an ordinary receiving method (mcp/server.go:1887) and the canceller's Preempt returns ErrNotHandled (mcp/transport.go:259-272), so the notification reaches that same middleware, and CancelledParams.RequestID names the call being cancelled (mcp/protocol.go:430). I can read the id of a call I am being told to cancel, and I cannot read the id of the call itself. The id arrives everywhere except at the request it identifies.
I checked the two ways around it and neither works. A wrapping Transport does see every message with its id (Connection.Read returns a jsonrpc.Message, mcp/transport.go:73-78), but calls are dispatched asynchronously (jsonrpc2.Async(ctx) at mcp/server.go:2003-2005), so nothing ties a message read at the transport to a middleware invocation; matching on method and params bytes is a guess, not an identity. And the progress token is not a substitute: it is optional, chosen by the client, and absent from most calls.
For streamable HTTP there is now #1076 and #1101, which expose a parsed request summary including the id to an HTTP-level callback. That is a different layer and does not close this one: it is streamable HTTP only and not stdio, it fires before dispatch rather than around the handler, and it hands the id to code that is not the code building the span. I read it as evidence that the value itself is not considered sensitive, which it is not: it is on the wire in both directions already, and the SDK decodes it to route the response.
What a server built on this SDK cannot do today
It cannot emit jsonrpc.request.id on the MCP server span at all, so a Conditionally Required attribute of the convention is missing from every span it produces. My own server has to document the omission in its telemetry guide, which is not a good place for it.
It cannot correlate a notifications/cancelled with the call it cancelled, so a log line or a span event saying the peer cancelled a call cannot say which call.
It cannot join its server span to an instrumented client's span for the same call when the trace context did not propagate, since the id is the correlator the convention provides for that.
It cannot key per-call state of its own by the protocol's own identity for the call. The only way today is to mint an identifier out of band, which works on HTTP and not on stdio.
Proposal
Add the id to the Request interface and to the two concrete request types:
// A Request is a method request with parameters and additional information, such as the session.
// Request is implemented by [*ClientRequest] and [*ServerRequest].
type Request interface {
isRequest()
GetSession() Session
GetParams() Params
// GetExtra returns the Extra field for ServerRequests, and nil for ClientRequests.
GetExtra() *RequestExtra
// GetID returns the JSON-RPC id of the message being handled, or the zero ID
// for a notification and for a request the SDK itself originates. Use
// [jsonrpc.ID.IsValid] to tell those apart and [jsonrpc.ID.Raw] to read the
// underlying string or int64.
GetID() jsonrpc.ID
}
// A ClientRequest is a request to a client.
type ClientRequest[P Params] struct {
Session *ClientSession
Params P
ID jsonrpc.ID
}
// A ServerRequest is a request to a server.
type ServerRequest[P Params] struct {
Session *ServerSession
Params P
Extra *RequestExtra
ID jsonrpc.ID
}
func (r *ClientRequest[P]) GetID() jsonrpc.ID { return r.ID }
func (r *ServerRequest[P]) GetID() jsonrpc.ID { return r.ID }
What it reads and from where: the value is jreq.ID in handleReceive (mcp/shared.go:206), the same *jsonrpc.Request the method and the params already come from. methodInfo.newRequest (mcp/shared.go:265) grows one parameter, newRequest func(Session, Params, *RequestExtra, jsonrpc.ID) Request; both constructors store it (mcp/shared.go:300 and mcp/shared.go:315); handleReceive passes jreq.ID at mcp/shared.go:218. The MRTR rebuild carries it across beside Extra, for the reason its comment already gives for Extra (mcp/mrtr.go:267). Requests the SDK originates keep the zero ID (mcp/shared.go:492-501), which is the right answer rather than a gap: on the sending side jsonrpc2 mints the id inside Connection.Call (internal/jsonrpc2/conn.go:308-310), after the sending middleware has run, so there is nothing to report there yet. jsonrpc.ID is already exported as an alias carrying IsValid and Raw (jsonrpc/jsonrpc.go:11-22, internal/jsonrpc2/messages.go:89-92), so this introduces no new type, and the zero ID maps exactly onto the convention's "SHOULD NOT capture when the id is null or omitted".
The cost is one interface-shaped field copied once per request, and no new allocation.
I would keep the scope to the receiving side. A client span's jsonrpc.request.id is a separate question, because the id does not exist yet when the sending middleware runs, and I am not asking for it here.
Alternatives I considered
A field on RequestExtra. This looks like the cheaper change and I think it is the worst of the shapes. RequestExtra is documented as information "typically from the transport layer" (mcp/shared.go:629-630), while the id is protocol framing the SDK decodes itself. It is constructed in exactly one place, the streamable HTTP transport (mcp/streamable.go:1585), so on stdio jreq.Extra is nil, GetExtra() returns nil, and a middleware reading req.GetExtra().RequestID panics on the transport most servers run. Making it safe would mean allocating a RequestExtra per request on every transport, and it still could not serve ClientRequest, whose GetExtra returns nil by definition (mcp/shared.go:665).
A method on the concrete types only, with no interface change. func (r *ServerRequest[P]) GetID() jsonrpc.ID on its own would let a middleware write if r, ok := req.(interface{ GetID() jsonrpc.ID }); ok, which works for every instantiation of P and touches no interface. I prefer putting it on the interface because there is no compatibility argument for hiding it: Request is sealed by the unexported isRequest() (mcp/shared.go:609, implemented only at mcp/shared.go:656-657), so nothing outside this module can implement it and adding a method cannot break an implementor. An anonymous interface assertion at every call site is also something nobody discovers from the documentation.
An exported context accessor, option 3 in the comment quoted above: func RequestIDFromContext(ctx context.Context) (jsonrpc.ID, bool). It is the smallest change of all, the value is already on the context for the server side (mcp/server.go:2010), and it serves handlers as well as middleware, which the interface method does not do for a handler that only took its params. Two things put it second for me: it is unityped and undiscoverable next to GetSession, GetParams and GetExtra, and it is set only on the server side today, since ClientSession.handle sets nothing (mcp/client.go:1188-1193), so the client half would still need the same work. If you prefer this shape I will implement it instead, gladly; it answers the same need.
Passing the whole *jsonrpc.Request through. Too much surface. It would expose the raw params bytes and Extra beside the id, and invite mutation of a value the connection still owns.
An ID field with no accessor. A middleware only ever sees mcp.Request, so a field alone is unreachable without a type switch over every instantiation of P. I mention it only to say I rejected it.
One compatibility note on the shape I prefer: adding an exported field to ClientRequest and ServerRequest breaks an unkeyed composite literal of either, if anyone writes one. go vet's composites check catches those, and I found none in this repository.
Offer
I am happy to open the pull request for whichever shape you pick, with tests: a receiving middleware that reads the id of a call and gets the zero ID for a notification, on stdio and on streamable HTTP, plus the MRTR rebuild keeping it across the retry. Tell me which one you want and I will send it.
Part of #1257.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Request, handleReceive, and the request constructors in mcp/shared.go, then inspect the MRTR path in mcp/mrtr.go and the existing request-ID context handling in mcp/server.go. Done means receiving middleware and handlers can access the incoming JSON-RPC ID, while SDK-originated requests retain the zero ID and existing dispatch behavior remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, backend-api-design
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100