internal/mcp/design: weave discussion of differences with mcp-go

Put discussion of mcp-go up front, and address differences in the
individual sections. I think this leads to easier reading for the user
who is concerned about our divergence in design.

Change-Id: Ifd21f6b9eb570ad4a325b8a8800b7868dac12205
Reviewed-on: https://go-review.googlesource.com/c/tools/+/671556
TryBot-Bypass: Robert Findley <rfindley@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Commit-Queue: Robert Findley <rfindley@google.com>
This commit is contained in:
Rob Findley 2025-05-09 16:46:54 +00:00 • committed by Robert Findley
commit 0f2d85336d

View file

@ -2,11 +2,37 @@
This file discusses the design of a Go SDK for the [model context
protocol](https://modelcontextprotocol.io/specification/2025-03-26). It is
intended to seed a GitHub discussion about the official Go MCP SDK, and so
approaches each design aspect from first principles. Of course, there is
significant prior art in various unofficial SDKs, along with other practical
constraints. Nevertheless, if we can first agree on the most natural way to
model the MCP spec, we can then discuss the shortest path to get there.
intended to seed a GitHub discussion about the official Go MCP SDK.
The github.com/modelcontextprotocol/go-sdk/mcp package contains a prototype that we built
to explore the MCP design space. Many of the ideas there are present in this
document. However, we have diverged and expanded on the APIs of that prototype,
and this document should be considered canonical.
## Similarities and differences with mark3labs/mcp-go
The most popular unofficial MCP SDK for Go is
[mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go). As of this
writing, it is imported by over 400 packages that span over 200 modules.
We admire mcp-go, and seriously considered simply adopting it as a starting
point for this SDK. However, as we looked at doing so, we realized that a
significant amount of its API would probably need to change. In some cases,
mcp-go has older APIs that predated newer variations--an obvious opportunity
for cleanup. In others, it took a batteries-included approach that is probably
not viable for an official SDK. In yet others, we simply think there is room for
API refinement, and we should take this opportunity to consider our options.
Therefore, we wrote this document as though it were proposing a new
implementation. Nevertheless, much of the API discussed here originated from or
was inspired by mcp-go and other unofficial SDKs, and if the consensus of this
discussion is close enough to mcp-go or any other unofficial SDK, we can start
from a fork.
Since mcp-go is so influential and popular, we have noted significant
differences from its API in the sections below. Although the API here is not
compatible with mcp-go, translating between them should be straightforward in
most cases.
(Later, we will provide a detailed translation guide.)
# Requirements
@ -35,15 +61,6 @@ the order they are presented by the [official spec](https://modelcontextprotocol
For each, we discuss considerations for the Go implementation. In many cases an
API is suggested, though in some there many be open questions.
<!--
Instructions: for each section below:
- Summarize the spec.
- If applicable, reference prior art or alternatives.
- If possible, propose a Go API, and justify why it meets the requirements
above.
-->
## Foundations
### Package layout
@ -68,6 +85,9 @@ the next section, the only aspects of JSON-RPC that need to be exposed in the
SDK are the message types, for the purposes of defining custom transports. We
can expose these types from the `mcp` package via aliases or wrappers.
**Difference from mcp-go**: Our `mcp` package includes all the functionality of
mcp-go's `mcp`, `client`, `server` and `transport` packages.
### jsonrpc2 and Transports
The MCP is defined in terms of client-server communication over bidirectional
@ -165,21 +185,21 @@ Importantly, since they serve many connections, the HTTP handlers must accept a
callback to get an MCP server for each new session.
```go
// SSEHandler is an http.Handler that serves SSE-based MCP sessions as defined by
// SSEHTTPHandler is an http.Handler that serves SSE-based MCP sessions as defined by
// the 2024-11-05 version of the MCP protocol.
type SSEHandler struct { /* unexported fields */ }
type SSEHTTPHandler struct { /* unexported fields */ }
// NewSSEHandler returns a new [SSEHandler] that is ready to serve HTTP.
// NewSSEHTTPHandler returns a new [SSEHTTPHandler] that is ready to serve HTTP.
//
// The getServer function is used to bind created servers for new sessions. It
// is OK for getServer to return the same server multiple times.
func NewSSEHandler(getServer func(request *http.Request) *Server) *SSEHandler
func NewSSEHTTPHandler(getServer func(request *http.Request) *Server) *SSEHTTPHandler
func (*SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
func (*SSEHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
// Close prevents the SSEHandler from accepting new sessions, closes active
// Close prevents the SSEHTTPHandler from accepting new sessions, closes active
// sessions, and awaits their graceful termination.
func (*SSEHandler) Close() error
func (*SSEHTTPHandler) Close() error
```
Notably absent are options to hook into the request handling for the purposes
@ -240,11 +260,11 @@ more complicated implementation. For brevity, we summarize only the differences
from the equivalent SSE types:
```go
// The StreamableHandler interface is symmetrical to the SSEHandler.
type StreamableHandler struct { /* unexported fields */ }
func NewStreamableHandler(getServer func(request *http.Request) *Server) *StreamableHandler
func (*StreamableHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
func (*StreamableHandler) Close() error
// The StreamableHTTPHandler interface is symmetrical to the SSEHTTPHandler.
type StreamableHTTPHandler struct { /* unexported fields */ }
func NewStreamableHTTPHandler(getServer func(request *http.Request) *Server) *StreamableHTTPHandler
func (*StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
func (*StreamableHTTPHandler) Close() error
// Unlike the SSE transport, the streamable transport constructor accepts a
// session ID, not an endpoint, along with the http response for the request
@ -261,6 +281,32 @@ func NewStreamableClientTransport(url string) *StreamableClientTransport {
func (*StreamableClientTransport) Connect(context.Context) (Stream, error)
```
**Differences from mcp-go**: The Go team has a battle-tested JSON-RPC
implementation that we use for gopls, our Go LSP server. We are using the new
version of this library as part of our MCP SDK. It handles all JSON-RPC 2.0
features, including cancellation.
The `Transport` interface here is lower-level than that of mcp-go, but serves a
similar purpose. We believe the lower-level interface is easier to implement.
In mcp-go, server authors create an `MCPServer`, populate it with tools,
resources and so on, and then wrap it in an `SSEServer` or `StdioServer`. These
also use session IDs, which are exposed. Users can manage their own sessions
with `RegisterSession` and `UnregisterSession`.
We find the similarity in names among the three server types to be confusing,
and we could not discover any uses of the session methods in the open-source
ecosystem. In our design, server authors create a `Server`, and then
connect it to a `Transport`. An `SSEHTTPHandler` manages sessions for
incoming SSE connections, but does not expose them. HTTP handlers accept a
server constructor, rather than Server, to allow for stateful or "per-session"
servers.
Individual handlers and transports here have a minimal smaller API, and do not
expose internal details. Customization of things like handlers or session
management is intended to be implemented with middleware and/or compositional
patterns.
### Protocol types
Types needed for the protocol are generated from the
@ -317,6 +363,9 @@ type Resource struct {
}
```
**Differences from mcp-go**: these types are largely similar, but our type
generation flattens types rather than using struct embedding.
### Clients and Servers
Generally speaking, the SDK is used by creating a `Client` or `Server`
@ -415,17 +464,71 @@ session until the client disconnects:
func (*Server) Run(context.Context, Transport)
```
**Differences from mcp-go**: the Server APIs are very similar to mcp-go,
though the association between servers and transports is different. In
mcp-go, a single server is bound to what we would call an `SSEHTTPHandler`,
and reused for all client sessions. As discussed above, the transport
abstraction here is differentiated from HTTP serving, and the `Server.Connect`
method provides a consistent API for binding to an arbitrary transport. Servers
here do not have methods for sending notifications or calls, because they are
logically distinct from the `ServerSession`. In mcp-go, servers are `n:1`,
but there is no abstraction of a server session: sessions are addressed in
Server APIs through their `sessionID`: `SendNotificationToAllClients`,
`SendNotificationToClient`, `SendNotificationToSpecificClient`.
The client API here is different, since clients and client sessions are
conceptually distinct. The `ClientSession` is closer to mcp-go's notion of
Client.
For both clients and servers, mcp-go uses variadic options to customize
behavior, whereas an options struct is used here. We felt that in this case, an
options struct would be more readable, and result in cleaner package
documentation.
### Middleware
We provide a mechanism to add MCP-level middleware, which runs after the
request has been parsed, but before any normal handling.
```go
// A Handler handles an MCP message call.
type Handler func(ctx context.Context, s *ServerSession, method string, params any) (response any, err error)
// AddMiddleware calls each middleware function from right to left on the previous result, beginning
// with the server's current handler, and installs the result as the new handler.
func (*Server) AddMiddleware(middleware ...func(Handler) Handler))
```
As an example, this code adds server-side logging:
```go
func withLogging(h mcp.Handler) mcp.Handler {
return func(ctx context.Context, s *mcp.ServerSession, method string, params any) (res any, err error) {
log.Printf("request: %s %v", method, params)
defer func() { log.Printf("response: %v, %v", res, err) }()
return h(ctx, s , method, params)
}
}
server.AddMiddleware(withLogging)
```
**Differences from mcp-go**: Version 0.26.0 of mcp-go defines 24 server hooks.
Each hook consists of a field in the `Hooks` struct, a `Hooks.Add` method, and
a type for the hook function. These are rarely used. The most common is
`OnError`, which occurs fewer than ten times in open-source code.
### Errors
With the exception of tool handler errors, protocol errors are handled
transparently as Go errors: errors in server-side feature handlers are
propagated as errors from calls from the `ClientSession`, and vice-versa.
Protocol errors wrap a `JSONRPC2Error` type which exposes its underlying error
Protocol errors wrap a `JSONRPCError` type which exposes its underlying error
code.
```go
type JSONRPC2Error struct {
type JSONRPCError struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
@ -436,6 +539,10 @@ As described by the
[spec](https://modelcontextprotocol.io/specification/2025-03-26/server/tools#error-handling),
tool execution errors are reported in tool results.
**Differences from mcp-go**: the `JSONRPCError` type here does not expose
details that are irrelevant or can be inferred from the caller (ID and Method).
Otherwise, this behavior is similar.
### Cancellation
Cancellation is implemented transparently using context cancellation. The user
@ -504,6 +611,10 @@ type ServerOptions struct {
}
```
**Differences from mcp-go**: in mcp-go the `Ping` method is only provided for
client, not server, and the keepalive option is only provided for SSE servers
(as a variadic option).
## Client Features
### Roots
@ -550,7 +661,7 @@ type ClientOptions struct {
CreateMessageHandler func(context.Context, *ClientSession, *CreateMessageParams) (*CreateMessageResult, error)
}
func (*Server) CreateMessage(context.Context, *CreateMessageParams) (*CreateMessageResult, error)
func (*ServerSession) CreateMessage(context.Context, *CreateMessageParams) (*CreateMessageResult, error)
```
## Server Features
@ -675,6 +786,31 @@ Schemas are validated on the server before the tool handler is called.
Since all the fields of the Tool struct are exported, a Tool can also be created
directly with assignment or a struct literal.
**Differences from mcp-go**: using variadic options to configure tools was
signficantly inspired by mcp-go. However, the distinction between `ToolOption`
and `SchemaOption` allows for recursive application of schema options.
For example, that limitation is visible in [this
code](https://github.com/DCjanus/dida365-mcp-server/blob/master/cmd/mcp/tools.go#L315),
which must resort to untyped maps to express a nested schema.
Additionally, the `NewTool` helper provides a means for building a tool from a
Go function using reflection, that automatically handles parsing and validation
of inputs.
We provide a full JSON Schema implementation for validating tool input schemas
against incoming arguments. The `jsonschema.Schema` type provides exported
features for all keywords in the JSON Schema draft2020-12 spec. Tool definers
can use it to construct any schema they want, so there is no need to provide
options for all of them. When combined with schema inference from input
structs, we found that we needed only three options to cover the common cases,
instead of mcp-go's 23. For example, we will provide `Enum`, which occurs 125
times in open source code, but not MinItems, MinLength or MinProperties, which
each occur only once (and in an SDK that wraps mcp-go).
For registering tools, we provide only `AddTools`; mcp-go's `SetTools`,
`AddTool`, `AddSessionTool`, and `AddSessionTools` are deemed unnecessary.
(similarly for Delete/Remove).
### Prompts
Use `NewPrompt` to create a prompt.
@ -711,6 +847,10 @@ func (*ClientSession) ListPrompts(context.Context, *ListPromptParams) (*ListProm
func (*ClientSession) GetPrompt(context.Context, *GetPromptParams) (*GetPromptResult, error)
```
**Differences from mcp-go**: We provide a `NewPrompt` helper to bind a prompt
handler to a Go function using reflection to derive its arguments. We provide
`RemovePrompts` to remove prompts from the server.
### Resources and resource templates
Servers have Add and Remove methods for resources and resource templates:
@ -749,13 +889,24 @@ type ClientOptions struct {
}
```
**Differences from mcp-go**: mcp-go instead provides a general `OnNotification`
handler. For type-safety, and to hide JSON RPC details, we provide
feature-specific handlers here.
### Completion
Clients call `Complete` to request completions.
```go
func (*ClientSession) Complete(context.Context, *CompleteParams) (*CompleteResult, error)
```
Servers automatically handle these requests based on their collections of
prompts and resources.
**Differences from mcp-go**: the client API is similar. mcp-go has not yet
defined its server-side behavior.
### Logging
Servers have access to a `slog.Logger` that writes to the client. A call to
@ -778,138 +929,3 @@ follows:
### Pagination
<!-- TODO: needs design -->
## Differences with mark3labs/mcp-go
The most popular MCP module for Go is [mark3labs/mcp-go](https://pkg.go.dev/github.com/
mark3labs/mcp-go).
As of this writing, it is imported by over 400 packages that span over 200 modules.
We admire mcp-go, and seriously considered simply adopting it as a starting
point for this SDK. However, as we looked at doing so, we realized that a
significant amount of its API would probably need to change. In some cases,
mcp-go has older APIs that predated newer variations--an obvious opportunity
for cleanup. In others, it took a batteries-included approach that is probably
not viable for an official SDK. In yet others, we simply think there is room for
API refinement, and we should take this opportunity to reconsider. Therefore,
we wrote this SDK design from the perspective of a new implementation.
Nevertheless, much of the API discussed here originated from or was inspired
by mcp-go and other unofficial SDKs, and if the consensus of this discussion is
close enough to mcp-go or any other unofficial SDK, we can start from a fork.
Although our API is not compatible with mcp-go's, translating between them should be
straightforward in most cases.
(Later, we will provide a detailed translation guide.)
### Packages
As we mentioned above, we decided to put most of the API into a single package.
Our `mcp` package includes all the functionality of mcp-go's `mcp`, `client`,
`server` and `transport` packages, but is smaller than the `mcp` package alone.
### Typed tool inputs
We provide a way to supply a struct as the input type of a Tool, as described
in [JSON Schema](#JSON_Schema), above.
The tool handler receives a value of this struct instead of a `map[string]any`,
so it doesn't need to parse its input parameters. Also, we infer the input schema
from the struct, avoiding the need to specify the name, type and required status of
parameters.
### Schema validation
We provide a full JSON Schema implementation for validating tool input schemas against
incoming arguments. The `jsonschema.Schema` type provides exported features for all
keywords in the JSON Schema draft2020-12 spec. Tool definers can use it to construct
any schema they want, so there is no need to provide options for all of them.
When combined with schema inference from input structs,
we found that we needed only three options to cover the common cases,
instead of mcp-go's 23. For example, we provide `Enum`, which occurs 125 times in open source
code, but not MinItems, MinLength or MinProperties, which each occur only once (and in an SDK
that wraps mcp-go).
Moreover, our options can be used to build nested schemas, while
mcp-go's work only at top level. That limitation is visible in
[this code](https://github.com/DCjanus/dida365-mcp-server/blob/master/cmd/mcp/tools.go#L315),
which must resort to untyped maps to express a nested schema:
```go
mcp.WithArray("items",
mcp.Description("Checklist items of the task"),
mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{
"type": "string",
"description": "Unique identifier of the checklist item",
},
"status": map[string]any{
"type": "number",
"description": "Status of the checklist item (0: normal, 1: completed)",
"enum": []float64{0, 1},
},
...
```
### JSON-RPC implementation
The Go team has a battle-tested JSON-RPC implementation that we use for gopls, our
Go LSP server. We are using the new version of this library as part of our MCP SDK.
It handles all JSON-RPC 2.0 features, including cancellation.
### Hooks
Version 0.26.0 of mcp-go defines 24 server hooks. Each hook consists of a field
in the `Hooks` struct, a `Hooks.Add` method, and a type for the hook function.
These are rarely used. The most common is `OnError`, which occurs fewer than ten
times in open-source code.
All of the hooks run before or after the server processes a message,
so instead we provide a single way to intercept this message handling, using
two exported names instead of 72:
```go
// A Handler handles an MCP message call.
type Handler func(ctx context.Context, s *ServerSession, method string, params any) (response any, err error)
// AddMiddleware calls each middleware function from right to left on the previous result, beginning
// with the server's current handler, and installs the result as the new handler.
func (*Server) AddMiddleware(middleware ...func(Handler) Handler))
```
As an example, this code adds server-side logging:
```go
func withLogging(h mcp.Handler) mcp.Handler {
return func(ctx context.Context, s *mcp.ServerSession, method string, params any) (res any, err error) {
log.Printf("request: %s %v", method, params)
defer func() { log.Printf("response: %v, %v", res, err) }()
return h(ctx, s , method, params)
}
}
server.AddMiddleware(withLogging)
```
### Options
In Go, the two most common ways to provide options to a function are option structs (for example,
https://pkg.go.dev/net/http#PushOptions) and
variadic option functions. mcp-go uses option functions exclusively. For example,
the `server.NewMCPServer` function has ten associated functions to provide options.
Our API uses both, depending on the context. We use function options for
constructing tools, where they are most convenient. In most other places, we
prefer structs because they have a smaller API footprint and are less verbose.
### Servers
In mcp-go, server authors create an `MCPServer`, populate it with tools,
resources and so on, and then wrap it in an `SSEServer` or `StdioServer`. These
also use session IDs, which are exposed. Users can manage their own sessions
with `RegisterSession` and `UnregisterSession`.
We find the similarity in names among the three server types to be confusing,
and we could not discover any uses of the session methods in the open-source
ecosystem. In our design, server authors create a `Server`, and then
connect it to a `Transport`. An `SSEHandler` manages sessions for
incoming SSE connections, but does not expose them.