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>
diff --git a/internal/mcp/design/design.md b/internal/mcp/design/design.md index 1bb65e5..de3a07b 100644 --- a/internal/mcp/design/design.md +++ b/internal/mcp/design/design.md
@@ -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 golang.org/x/tools/internal/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 @@ 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 @@ 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 @@ 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 @@ 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 (*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 @@ } ``` +**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 @@ 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 @@ [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 @@ } ``` +**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 @@ 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 @@ 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) 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 @@ } ``` +**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 @@ ### 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.