From 1373ddf905ccd93cbcdc4448320bdf7ad4e9d2a9 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 25 Jun 2025 13:19:35 -0400 Subject: [PATCH 01/24] mcp: add a TODO (#31) --- mcp/tool.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mcp/tool.go b/mcp/tool.go index ee4967b7..5bf9440b 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -44,6 +44,8 @@ type ServerTool struct { // The input schema for the tool is extracted from the request type for the // handler, and used to unmmarshal and validate requests to the handler. This // schema may be customized using the [Input] option. +// +// TODO(jba): check that structured content is set in response. func NewServerTool[In, Out any](name, description string, handler ToolHandlerFor[In, Out], opts ...ToolOption) *ServerTool { st, err := newServerToolErr[In, Out](name, description, handler, opts...) if err != nil { From abb650e45b60b8d6304c906ffad91e5658e233bd Mon Sep 17 00:00:00 2001 From: qiaodev <159568575+qiaodev@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:54:29 -0700 Subject: [PATCH 02/24] docs: fix README (#32) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 504eccc6..e9d80131 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ func main() { log.Fatal("tool failed") } for _, c := range res.Content { - log.Print(c.Text) + log.Print(c.(*mcp.TextContent).Text) } } ``` @@ -95,7 +95,7 @@ type HiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []*mcp.ContentBlock{mcp.NewTextContent("Hi " + params.Name)}, + Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Name}}, }, nil } From 1478ba7e0eb9e47040f595c83e097268afc5090a Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:56:19 -0400 Subject: [PATCH 03/24] design: add design for completion (#34) --- design/design.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/design/design.md b/design/design.md index 5c77b7de..4ade34ca 100644 --- a/design/design.md +++ b/design/design.md @@ -850,7 +850,30 @@ type ClientOptions struct { ### Completion -Clients call the spec method `Complete` to request completions. Servers automatically handle these requests based on their collections of prompts and resources. +Clients call the spec method `Complete` to request completions. If a server installs a `CompletionHandler`, it will be called when the client sends a completion request. + +```go +// A CompletionHandler handles a call to completion/complete. +type CompletionHandler func(context.Context, *ServerSession, *CompleteParams) (*CompleteResult, error) + +type ServerOptions struct { + ... + // If non-nil, called when a client sends a completion request. + CompletionHandler CompletionHandler +} +``` + +#### Securty Considerations + +Implementors of the CompletionHandler MUST adhere to the following security guidelines: + +- **Validate all completion inputs**: The CompleteRequest received by the handler may contain arbitrary data from the client. Before processing, thoroughly validate all fields. + +- **Implement appropriate rate limiting**: Completion requests can be high-frequency, especially in interactive user experiences. Without rate limiting, a malicious client could potentially overload the server, leading to denial-of-service (DoS) attacks. Consider applying rate limits per client session, IP address, or API key, depending on your deployment model. This can be implemented within the CompletionHandler itself or via middleware (see [Middleware](#middleware)) that wraps the handler. + +- **Control access to sensitive suggestions**: Completion suggestions should only expose information that the requesting client is authorized to access. If your completion logic draws from sensitive data sources (e.g., internal file paths, user data, restricted API endpoints), ensure that the CompletionHandler performs proper authorization checks before generating or returning suggestions. This might involve checking the ServerSession context for authentication details or consulting an external authorization system. + +- **Prevent completion-based information disclosure**: Be mindful that even seemingly innocuous completion suggestions can inadvertently reveal sensitive information. For example, suggesting internal system paths or confidential identifiers could be an attack vector. Ensure that the generated CompleteResult contains only appropriate and non-sensitive information for the given client and context. Avoid revealing internal data structures or error messages that could aid an attacker. **Differences from mcp-go**: the client API is similar. mcp-go has not yet defined its server-side behavior. From 09181c2c2e898fd58ffc7409d86ca6bb2e210c78 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 25 Jun 2025 14:57:07 -0400 Subject: [PATCH 04/24] mcp: use correct ResourceNotFound code (#35) --- internal/jsonrpc2/wire.go | 2 +- mcp/shared.go | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index 58ee41f0..309b8002 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -34,7 +34,7 @@ var ( // ErrUnknown should be used for all non coded errors. ErrUnknown = NewError(-32001, "JSON RPC unknown error") // ErrServerClosing is returned for calls that arrive while the server is closing. - ErrServerClosing = NewError(-32002, "JSON RPC server is closing") + ErrServerClosing = NewError(-32004, "JSON RPC server is closing") // ErrClientClosing is a dummy error returned for calls initiated while the client is closing. ErrClientClosing = NewError(-32003, "JSON RPC client is closing") ) diff --git a/mcp/shared.go b/mcp/shared.go index 07ea5dff..031f8cc5 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -207,13 +207,7 @@ func sessionMethod[S Session, P Params, R Result](f func(S, context.Context, P) // Error codes const ( - // The error code to return when a resource isn't found. - // See https://modelcontextprotocol.io/specification/2025-03-26/server/resources#error-handling - // However, the code they chose is in the wrong space - // (see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/509). - // so we pick a different one, arbitrarily for now (until they fix it). - // The immediate problem is that jsonprc2 defines -32002 as "server closing". - CodeResourceNotFound = -31002 + CodeResourceNotFound = -32002 // The error code if the method exists and was called properly, but the peer does not support it. CodeUnsupportedMethod = -31001 ) From 057f525da2cbeb4d864eb574a01acfe79bc2c32b Mon Sep 17 00:00:00 2001 From: Robert Jackson Date: Wed, 25 Jun 2025 14:31:44 -0500 Subject: [PATCH 05/24] feat(ci): Add check to ensure `go fmt` has been ran (#43) --- .github/workflows/test.yml | 19 ++++++++++++++++++- mcp/client_list_test.go | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b603512d..f2f8cb8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,8 +8,25 @@ on: permissions: contents: read - + jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v5 + - name: Check out code + uses: actions/checkout@v4 + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "The following files are not properly formatted:" + echo "$unformatted" + exit 1 + fi + echo "All Go files are properly formatted" + test: runs-on: ubuntu-latest strategy: diff --git a/mcp/client_list_test.go b/mcp/client_list_test.go index 6153ca54..7e6da95a 100644 --- a/mcp/client_list_test.go +++ b/mcp/client_list_test.go @@ -11,8 +11,8 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" ) func TestList(t *testing.T) { From 600ba61efe43c7d14337e05561197440ad809955 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 25 Jun 2025 16:45:58 -0400 Subject: [PATCH 06/24] mcp: fix CallToolResultFor[T] unmarshaling (#46) Add an UnmarshalJSON method for that type. Add a test. --- mcp/protocol.go | 19 ++++++++++++++++++ mcp/protocol_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/mcp/protocol.go b/mcp/protocol.go index 439d07a0..6b6f4790 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -129,6 +129,25 @@ type CallToolResultFor[Out any] struct { StructuredContent Out `json:"structuredContent,omitempty"` } +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (x *CallToolResultFor[Out]) UnmarshalJSON(data []byte) error { + type res CallToolResultFor[Out] // avoid recursion + var wire struct { + res + Content []*wireContent `json:"content"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { + return err + } + *x = CallToolResultFor[Out](wire.res) + return nil +} + type CancelledParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index d68fb738..823c409c 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -8,6 +8,8 @@ import ( "encoding/json" "maps" "testing" + + "github.com/google/go-cmp/cmp" ) func TestParamsMeta(t *testing.T) { @@ -67,3 +69,47 @@ func TestParamsMeta(t *testing.T) { p.SetProgressToken(int32(1)) p.SetProgressToken(int64(1)) } + +func TestContentUnmarshal(t *testing.T) { + // Verify that types with a Content field round-trip properly. + roundtrip := func(in, out any) { + t.Helper() + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, out); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(in, out); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + } + + content := []Content{&TextContent{Text: "t"}} + + ctr := &CallToolResult{ + Meta: Meta{"m": true}, + Content: content, + IsError: true, + StructuredContent: map[string]any{"s": "x"}, + } + var got CallToolResult + roundtrip(ctr, &got) + + ctrf := &CallToolResultFor[int]{ + Meta: Meta{"m": true}, + Content: content, + IsError: true, + StructuredContent: 3, + } + var gotf CallToolResultFor[int] + roundtrip(ctrf, &gotf) + + pm := &PromptMessage{ + Content: content[0], + Role: "", + } + var gotpm PromptMessage + roundtrip(pm, &gotpm) +} From 10dcf48fdb3504f0d272d075fb4a4a3deb2c15d9 Mon Sep 17 00:00:00 2001 From: Gerard Adam Date: Thu, 26 Jun 2025 03:48:21 +0700 Subject: [PATCH 07/24] mcp: fix tool result in examples (#45) --- examples/hello/main.go | 2 +- examples/sse/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/hello/main.go b/examples/hello/main.go index 1abb0e2d..9af34cc3 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -25,7 +25,7 @@ type HiArgs struct { func SayHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[HiArgs]) (*mcp.CallToolResultFor[struct{}], error) { return &mcp.CallToolResultFor[struct{}]{ Content: []mcp.Content{ - &mcp.TextContent{Text: "Hi " + params.Name}, + &mcp.TextContent{Text: "Hi " + params.Arguments.Name}, }, }, nil } diff --git a/examples/sse/main.go b/examples/sse/main.go index 5e4c851e..97ea1bd0 100644 --- a/examples/sse/main.go +++ b/examples/sse/main.go @@ -22,7 +22,7 @@ type SayHiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[SayHiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ Content: []mcp.Content{ - &mcp.TextContent{Text: "Hi " + params.Name}, + &mcp.TextContent{Text: "Hi " + params.Arguments.Name}, }, }, nil } From 92859ef3917e2b7df24da73daac656532be25d3b Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 25 Jun 2025 16:52:37 -0400 Subject: [PATCH 08/24] mcp: rename LoggingMessage to Log (#42) Use a verb for this method name. --- mcp/logging.go | 2 +- mcp/mcp_test.go | 2 +- mcp/server.go | 8 +++----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/mcp/logging.go b/mcp/logging.go index fb8e5719..65ebe067 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -185,5 +185,5 @@ func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { // documentation says not to. // In this case logging is a service to clients, not a means for debugging the // server, so we want to cancel the log message. - return h.ss.LoggingMessage(ctx, params) + return h.ss.Log(ctx, params) } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 71b99a4a..0aaa47ea 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -370,7 +370,7 @@ func TestEndToEnd(t *testing.T) { mustLog := func(level LoggingLevel, data any) { t.Helper() - if err := ss.LoggingMessage(ctx, &LoggingMessageParams{ + if err := ss.Log(ctx, &LoggingMessageParams{ Logger: "test", Level: level, Data: data, diff --git a/mcp/server.go b/mcp/server.go index c4aeeed9..53088bdb 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -413,7 +413,7 @@ func fileResourceHandler(dir string) ResourceHandler { } // TODO(jba): figure out mime type. Omit for now: Server.readResource will fill it in. return &ReadResourceResult{Contents: []*ResourceContents{ - &ResourceContents{URI: params.URI, Blob: data}, + {URI: params.URI, Blob: data}, }}, nil } } @@ -510,12 +510,10 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag return handleSend[*CreateMessageResult](ctx, ss, methodCreateMessage, orZero[Params](params)) } -// LoggingMessage sends a logging message to the client. +// Log sends a log message to the client. // The message is not sent if the client has not called SetLevel, or if its level // is below that of the last SetLevel. -// -// TODO(jba): rename to Log or LogMessage. A logging message is the thing that is sent to logging. -func (ss *ServerSession) LoggingMessage(ctx context.Context, params *LoggingMessageParams) error { +func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { ss.mu.Lock() logLevel := ss.logLevel ss.mu.Unlock() From 376d9499d6f3180562473a6e9a946ad3a8894632 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 25 Jun 2025 16:57:45 -0400 Subject: [PATCH 09/24] README.md: add description to tool input (#38) --- README.md | 6 +++++- internal/readme/server/server.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e9d80131..e60947cc 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,11 @@ func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParam func main() { // Create a server with a single tool. server := mcp.NewServer("greeter", "v1.0.0", nil) - server.AddTools(mcp.NewServerTool("greet", "say hi", SayHi)) + server.AddTools( + mcp.NewServerTool("greet", "say hi", SayHi, mcp.Input( + mcp.Property("name", mcp.Description("the name of the person to greet")), + )), + ) // Run the server over stdin/stdout, until the client disconnects if err := server.Run(context.Background(), mcp.NewStdioTransport()); err != nil { log.Fatal(err) diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go index b709cd75..177cf8fa 100644 --- a/internal/readme/server/server.go +++ b/internal/readme/server/server.go @@ -25,7 +25,11 @@ func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParam func main() { // Create a server with a single tool. server := mcp.NewServer("greeter", "v1.0.0", nil) - server.AddTools(mcp.NewServerTool("greet", "say hi", SayHi)) + server.AddTools( + mcp.NewServerTool("greet", "say hi", SayHi, mcp.Input( + mcp.Property("name", mcp.Description("the name of the person to greet")), + )), + ) // Run the server over stdin/stdout, until the client disconnects if err := server.Run(context.Background(), mcp.NewStdioTransport()); err != nil { log.Fatal(err) From 81eb55e6bca8a08888248fe3af0ab17f5319c0fc Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 25 Jun 2025 17:37:45 -0400 Subject: [PATCH 10/24] mcp: change default-true fields to pointers (#44) Some logically boolean fields default to true. We cannot use bool for these fields, because it defaults to false. So use *bool. --- mcp/protocol.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 6b6f4790..8c0a1767 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -789,7 +789,7 @@ type ToolAnnotations struct { // (This property is meaningful only when `readOnlyHint == false`) // // Default: true - DestructiveHint bool `json:"destructiveHint,omitempty"` + DestructiveHint *bool `json:"destructiveHint,omitempty"` // If true, calling the tool repeatedly with the same arguments will have no // additional effect on the its environment. // @@ -802,7 +802,7 @@ type ToolAnnotations struct { // a web search tool is open, whereas that of a memory tool is not. // // Default: true - OpenWorldHint bool `json:"openWorldHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` // If true, the tool does not modify its environment. // // Default: false From ff0d746521c4339875ae20d0b8e03405cf680ca4 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 25 Jun 2025 17:38:37 -0400 Subject: [PATCH 11/24] mcp: fix some documentation (#41) - Add some doc strings. - Remove markdown-like notations. --- mcp/protocol.go | 54 ++++++++++++++++++++++++------------------------- mcp/shared.go | 10 ++++++++- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 8c0a1767..babfd55a 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -22,7 +22,7 @@ type Annotations struct { // Describes who the intended customer of this object or data is. // // It can include multiple entries to indicate content useful for multiple - // audiences (e.g., `["user", "assistant"]`). + // audiences (e.g., []Role{"user", "assistant"}). Audience []Role `json:"audience,omitempty"` // The moment the resource was last modified, as an ISO 8601 formatted string. // @@ -70,12 +70,12 @@ type CallToolResult struct { // // If not set, this is assumed to be false (the call was successful). // - // Any errors that originate from the tool SHOULD be reported inside the result - // object, with `isError` set to true, _not_ as an MCP protocol-level error + // Any errors that originate from the tool should be reported inside the result + // object, with isError set to true, not as an MCP protocol-level error // response. Otherwise, the LLM would not be able to see that an error occurred // and self-correct. // - // However, any errors in _finding_ the tool, an error indicating that the + // However, any errors in finding the tool, an error indicating that the // server does not support tool calls, or any other exceptional conditions, // should be reported as an MCP error response. IsError bool `json:"isError,omitempty"` @@ -115,8 +115,8 @@ type CallToolResultFor[Out any] struct { // // If not set, this is assumed to be false (the call was successful). // - // Any errors that originate from the tool SHOULD be reported inside the result - // object, with `isError` set to true, not as an MCP protocol-level error + // Any errors that originate from the tool should be reported inside the result + // object, with isError set to true, not as an MCP protocol-level error // response. Otherwise, the LLM would not be able to see that an error occurred // and self-correct. // @@ -152,12 +152,12 @@ type CancelledParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` - // An optional string describing the reason for the cancellation. This MAY be + // An optional string describing the reason for the cancellation. This may be // logged or presented to the user. Reason string `json:"reason,omitempty"` // The ID of the request to cancel. // - // This MUST correspond to the ID of a request previously issued in the same + // This must correspond to the ID of a request previously issued in the same // direction. RequestID any `json:"requestId"` } @@ -187,21 +187,21 @@ type CreateMessageParams struct { // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` // A request to include context from one or more MCP servers (including the - // caller), to be attached to the prompt. The client MAY ignore this request. + // caller), to be attached to the prompt. The client may ignore this request. IncludeContext string `json:"includeContext,omitempty"` // The maximum number of tokens to sample, as requested by the server. The - // client MAY choose to sample fewer tokens than requested. + // client may choose to sample fewer tokens than requested. MaxTokens int64 `json:"maxTokens"` Messages []*SamplingMessage `json:"messages"` // Optional metadata to pass through to the LLM provider. The format of this // metadata is provider-specific. Metadata struct{} `json:"metadata,omitempty"` - // The server's preferences for which model to select. The client MAY ignore + // The server's preferences for which model to select. The client may ignore // these preferences. ModelPreferences *ModelPreferences `json:"modelPreferences,omitempty"` StopSequences []string `json:"stopSequences,omitempty"` // An optional system prompt the server wants to use for sampling. The client - // MAY modify or omit this prompt. + // may modify or omit this prompt. SystemPrompt string `json:"systemPrompt,omitempty"` Temperature float64 `json:"temperature,omitempty"` } @@ -255,7 +255,7 @@ type InitializeParams struct { Capabilities *ClientCapabilities `json:"capabilities"` ClientInfo *implementation `json:"clientInfo"` // The latest version of the Model Context Protocol that the client supports. - // The client MAY decide to support older versions as well. + // The client may decide to support older versions as well. ProtocolVersion string `json:"protocolVersion"` } @@ -273,11 +273,11 @@ type InitializeResult struct { // // This can be used by clients to improve the LLM's understanding of available // tools, resources, etc. It can be thought of like a "hint" to the model. For - // example, this information MAY be added to the system prompt. + // example, this information may be added to the system prompt. Instructions string `json:"instructions,omitempty"` // The version of the Model Context Protocol that the server wants to use. This // may not match the version that the client requested. If the client cannot - // support this version, it MUST disconnect. + // support this version, it must disconnect. ProtocolVersion string `json:"protocolVersion"` ServerInfo *implementation `json:"serverInfo"` } @@ -443,12 +443,12 @@ func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) type ModelHint struct { // A hint for a model name. // - // The client SHOULD treat this as a substring of a model name; for example: - + // The client should treat this as a substring of a model name; for example: - // `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` - `sonnet` // should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. - // `claude` should match any Claude model // - // The client MAY also map the string to a different provider's model name or a + // The client may also map the string to a different provider's model name or a // different model family, as long as it fills a similar niche; for example: - // `gemini-1.5-flash` could match `claude-3-haiku-20240307` Name string `json:"name,omitempty"` @@ -463,7 +463,7 @@ type ModelHint struct { // on. This interface allows servers to express their priorities across multiple // dimensions to help clients make an appropriate selection for their use case. // -// These preferences are always advisory. The client MAY ignore them. It is also +// These preferences are always advisory. The client may ignore them. It is also // up to the client to decide how to interpret these preferences and how to // balance them against other considerations. type ModelPreferences struct { @@ -472,10 +472,10 @@ type ModelPreferences struct { CostPriority float64 `json:"costPriority,omitempty"` // Optional hints to use for model selection. // - // If multiple hints are specified, the client MUST evaluate them in order (such + // If multiple hints are specified, the client must evaluate them in order (such // that the first match is taken). // - // The client SHOULD prioritize these hints over the numeric priorities, but MAY + // The client should prioritize these hints over the numeric priorities, but may // still use the priorities to select from ambiguous matches. Hints []*ModelHint `json:"hints,omitempty"` // How much to prioritize intelligence and capabilities when selecting a model. @@ -555,7 +555,7 @@ func (x *PromptListChangedParams) SetProgressToken(t any) { setProgressToken(x, // Describes a message returned as part of a prompt. // -// This is similar to `SamplingMessage`, but also supports the embedding of +// This is similar to SamplingMessage, but also supports the embedding of // resources from the MCP server. type PromptMessage struct { Content Content `json:"content"` @@ -628,7 +628,7 @@ type Resource struct { // easily understood, even by those unfamiliar with domain-specific terminology. // // If not provided, the name should be used for display (except for Tool, where - // `annotations.title` should be given precedence over using `name`, if + // Annotations.Title should be given precedence over using name, if // present). Title string `json:"title,omitempty"` // The URI of this resource. @@ -666,7 +666,7 @@ type ResourceTemplate struct { // easily understood, even by those unfamiliar with domain-specific terminology. // // If not provided, the name should be used for display (except for Tool, where - // `annotations.title` should be given precedence over using `name`, if + // Annotations.Title should be given precedence over using name, if // present). Title string `json:"title,omitempty"` // A URI template (according to RFC 6570) that can be used to construct resource @@ -776,9 +776,9 @@ type Tool struct { // Additional properties describing a Tool to clients. // -// NOTE: all properties in ToolAnnotations are **hints**. They are not +// NOTE: all properties in ToolAnnotations are hints. They are not // guaranteed to provide a faithful description of tool behavior (including -// descriptive properties like `title`). +// descriptive properties like title). // // Clients should never make tool use decisions based on ToolAnnotations // received from untrusted servers. @@ -786,14 +786,14 @@ type ToolAnnotations struct { // If true, the tool may perform destructive updates to its environment. If // false, the tool performs only additive updates. // - // (This property is meaningful only when `readOnlyHint == false`) + // (This property is meaningful only when ReadOnlyHint == false.) // // Default: true DestructiveHint *bool `json:"destructiveHint,omitempty"` // If true, calling the tool repeatedly with the same arguments will have no // additional effect on the its environment. // - // (This property is meaningful only when `readOnlyHint == false`) + // (This property is meaningful only when ReadOnlyHint == false.) // // Default: false IdempotentHint bool `json:"idempotentHint,omitempty"` diff --git a/mcp/shared.go b/mcp/shared.go index 031f8cc5..0edc0680 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -236,9 +236,13 @@ func notifySessions[S Session](sessions []S, method string, params Params) { } } +// Meta is additional metadata for requests, responses and other types. type Meta map[string]any -func (m Meta) GetMeta() map[string]any { return m } +// GetMeta returns metadata from a value. +func (m Meta) GetMeta() map[string]any { return m } + +// SetMeta sets the metadata on a value. func (m *Meta) SetMeta(x map[string]any) { *m = x } const progressTokenKey = "progressToken" @@ -263,7 +267,9 @@ func setProgressToken(p Params, pt any) { // Params is a parameter (input) type for an MCP call or notification. type Params interface { + // GetMeta returns metadata from a value. GetMeta() map[string]any + // SetMeta sets the metadata on a value. SetMeta(map[string]any) } @@ -282,7 +288,9 @@ type RequestParams interface { // Result is a result of an MCP call. type Result interface { + // GetMeta returns metadata from a value. GetMeta() map[string]any + // SetMeta sets the metadata on a value. SetMeta(map[string]any) } From 0e2abbb107824a627d2e886872a8397e1b9670a6 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 26 Jun 2025 09:37:21 -0400 Subject: [PATCH 12/24] docs: add GitHub discussions to README.md and CONTRIBUTING.md (#49) Also, update a pkgsite link in the README. --- CONTRIBUTING.md | 9 +++++++++ README.md | 26 +++++++++++++------------- internal/readme/README.src.md | 26 +++++++++++++------------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c876da3..6ac91c00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,15 @@ This process is similar to the [Go proposal process](https://github.com/golang/proposal), but is necessarily lighter weight to accommodate the greater rate of change expected for the SDK. +### Design discussion + +For open ended design discussion (anything that doesn't fall into the issue +categories above), use [GitHub +Discussions](https://github.com/modelcontextprotocol/go-sdk/discussions). +Ideally, each discussion should be focused on one aspect of the design. For +example: Tool Binding and Session APIs would be two separate discussions. +When discussions reach a consensus, they should be promoted into proposals. + ## Contributing code The project uses GitHub pull requests (PRs) to review changes. diff --git a/README.md b/README.md index e60947cc..17181eaa 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,7 @@ # MCP Go SDK - - -[![PkgGoDev](https://pkg.go.dev/badge/golang.org/x/tools)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) This repository contains an unreleased implementation of the official Go software development kit (SDK) for the Model Context Protocol (MCP). @@ -15,6 +12,18 @@ proposals, but don't use it in real projects. See the issue tracker for known issues and missing features. We aim to release a stable version of the SDK in August, 2025. +## Design + +The design doc for this SDK is at [design.md](./design/design.md), which was +initially reviewed at +[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). + +Further design discussion should occur in +[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete +proposals) or +[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for +open-ended discussion. See CONTRIBUTING.md for details. + ## Package documentation The SDK consists of two importable packages: @@ -116,15 +125,6 @@ func main() { The `examples/` directory contains more example clients and servers. -## Design - -The design doc for this SDK is at [design.md](./design/design.md), which was -initially reviewed at -[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). - -Further design discussion should occur in GitHub issues. See CONTRIBUTING.md -for details. - ## Acknowledgements Several existing Go MCP SDKs inspired the development and design of this diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 9938366e..629629a4 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,9 +1,6 @@ # MCP Go SDK - - -[![PkgGoDev](https://pkg.go.dev/badge/golang.org/x/tools)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) This repository contains an unreleased implementation of the official Go software development kit (SDK) for the Model Context Protocol (MCP). @@ -14,6 +11,18 @@ proposals, but don't use it in real projects. See the issue tracker for known issues and missing features. We aim to release a stable version of the SDK in August, 2025. +## Design + +The design doc for this SDK is at [design.md](./design/design.md), which was +initially reviewed at +[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). + +Further design discussion should occur in +[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete +proposals) or +[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for +open-ended discussion. See CONTRIBUTING.md for details. + ## Package documentation The SDK consists of two importable packages: @@ -41,15 +50,6 @@ with its client over stdin/stdout: The `examples/` directory contains more example clients and servers. -## Design - -The design doc for this SDK is at [design.md](./design/design.md), which was -initially reviewed at -[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). - -Further design discussion should occur in GitHub issues. See CONTRIBUTING.md -for details. - ## Acknowledgements Several existing Go MCP SDKs inspired the development and design of this From 9f6dd5c32e5cc0df76926ce2031566dd8f6d42eb Mon Sep 17 00:00:00 2001 From: Robert Jackson Date: Thu, 26 Jun 2025 11:45:44 -0500 Subject: [PATCH 13/24] feat(ci): Ensure that the README.md is in sync with internal/readme (#53) --- .github/workflows/readme-check.yml | 38 ++++++++++++++++++++++++++++++ CONTRIBUTING.md | 13 ++++++++++ 2 files changed, 51 insertions(+) create mode 100644 .github/workflows/readme-check.yml diff --git a/.github/workflows/readme-check.yml b/.github/workflows/readme-check.yml new file mode 100644 index 00000000..b3398ead --- /dev/null +++ b/.github/workflows/readme-check.yml @@ -0,0 +1,38 @@ +name: README Check +on: + workflow_dispatch: + pull_request: + paths: + - 'internal/readme/**' + - 'README.md' + +permissions: + contents: read + +jobs: + readme-check: + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v5 + - name: Check out code + uses: actions/checkout@v4 + - name: Check README is up-to-date + run: | + cd internal/readme + make + if [ -n "$(git status --porcelain)" ]; then + echo "ERROR: README.md is not up-to-date!" + echo "" + echo "The README.md file differs from what would be generated by running 'make' in internal/readme/." + echo "Please update internal/readme/README.src.md instead of README.md directly," + echo "then run 'make' in the internal/readme/ directory to regenerate README.md." + echo "" + echo "Changes:" + git status --porcelain + echo "" + echo "Diff:" + git diff + exit 1 + fi + echo "README.md is up-to-date" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ac91c00..40739735 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,19 @@ copyright header following the format below: // license that can be found in the LICENSE file. ``` +### Updating the README + +The top-level `README.md` file is generated from `internal/readme/README.src.md` +and should not be edited directly. To update the README: + +1. Make your changes to `internal/readme/README.src.md` +2. Run `make` in the `internal/readme/` directory to regenerate `README.md` +3. Commit both files together + +The CI system will automatically check that the README is up-to-date by running +`make` and verifying no changes result. If you see a CI failure about the +README being out of sync, follow the steps above to regenerate it. + ## Code of conduct This project follows the [Go Community Code of Conduct](https://go.dev/conduct). From c47dbcd7b2c8897f1338f7afa3ab5691f7215138 Mon Sep 17 00:00:00 2001 From: Robert Jackson Date: Fri, 27 Jun 2025 07:24:05 -0500 Subject: [PATCH 14/24] mcp: Implement KeepAlive for client and server Implement KeepAlive features from the design doc. Fixes #24. --- mcp/client.go | 24 ++++++++++++ mcp/mcp_test.go | 98 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 22 +++++++++++ mcp/shared.go | 38 +++++++++++++++++++ 4 files changed, 182 insertions(+) diff --git a/mcp/client.go b/mcp/client.go index 17875c38..82fbb01c 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -9,6 +9,7 @@ import ( "iter" "slices" "sync" + "time" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" ) @@ -56,6 +57,10 @@ type ClientOptions struct { ResourceListChangedHandler func(context.Context, *ClientSession, *ResourceListChangedParams) LoggingMessageHandler func(context.Context, *ClientSession, *LoggingMessageParams) ProgressNotificationHandler func(context.Context, *ClientSession, *ProgressNotificationParams) + // If non-zero, defines an interval for regular "ping" requests. + // If the peer fails to respond to pings originating from the keepalive check, + // the session is automatically closed. + KeepAlive time.Duration } // bind implements the binder[*ClientSession] interface, so that Clients can @@ -118,6 +123,11 @@ func (c *Client) Connect(ctx context.Context, t Transport) (cs *ClientSession, e _ = cs.Close() return nil, err } + + if c.opts.KeepAlive > 0 { + cs.startKeepalive(c.opts.KeepAlive) + } + return cs, nil } @@ -131,12 +141,21 @@ type ClientSession struct { conn *jsonrpc2.Connection client *Client initializeResult *InitializeResult + keepaliveCancel context.CancelFunc } // Close performs a graceful close of the connection, preventing new requests // from being handled, and waiting for ongoing requests to return. Close then // terminates the connection. func (cs *ClientSession) Close() error { + // Note: keepaliveCancel access is safe without a mutex because: + // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines + // 3. The keepalive goroutine calls Close on ping failure, but this is safe since + // Close is idempotent and conn.Close() handles concurrent calls correctly + if cs.keepaliveCancel != nil { + cs.keepaliveCancel() + } return cs.conn.Close() } @@ -146,6 +165,11 @@ func (cs *ClientSession) Wait() error { return cs.conn.Wait() } +// startKeepalive starts the keepalive mechanism for this client session. +func (cs *ClientSession) startKeepalive(interval time.Duration) { + startKeepalive(cs, interval, &cs.keepaliveCancel) +} + // AddRoots adds the given roots to the client, // replacing any with the same URIs, // and notifies any connected servers. diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 0aaa47ea..5f42b1b9 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -838,3 +838,101 @@ func falseSchema() *jsonschema.Schema { return &jsonschema.Schema{Not: &jsonsche func nopHandler(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error) { return nil, nil } + +func TestKeepAlive(t *testing.T) { + // TODO: try to use the new synctest package for this test once we upgrade to Go 1.24+. + // synctest would allow us to control time and avoid the time.Sleep calls, making the test + // faster and more deterministic. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ct, st := NewInMemoryTransports() + + serverOpts := &ServerOptions{ + KeepAlive: 100 * time.Millisecond, + } + s := NewServer("testServer", "v1.0.0", serverOpts) + s.AddTools(NewServerTool("greet", "say hi", sayHi)) + + ss, err := s.Connect(ctx, st) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + clientOpts := &ClientOptions{ + KeepAlive: 100 * time.Millisecond, + } + c := NewClient("testClient", "v1.0.0", clientOpts) + cs, err := c.Connect(ctx, ct) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Wait for a few keepalive cycles to ensure pings are working + time.Sleep(300 * time.Millisecond) + + // Test that the connection is still alive by making a call + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatalf("call failed after keepalive: %v", err) + } + if len(result.Content) == 0 { + t.Fatal("expected content in result") + } + if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { + t.Fatalf("unexpected result: %v", result.Content[0]) + } +} + +func TestKeepAliveFailure(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ct, st := NewInMemoryTransports() + + // Server without keepalive (to test one-sided keepalive) + s := NewServer("testServer", "v1.0.0", nil) + s.AddTools(NewServerTool("greet", "say hi", sayHi)) + ss, err := s.Connect(ctx, st) + if err != nil { + t.Fatal(err) + } + + // Client with short keepalive + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + } + c := NewClient("testClient", "v1.0.0", clientOpts) + cs, err := c.Connect(ctx, ct) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Let the connection establish properly first + time.Sleep(30 * time.Millisecond) + + // simulate ping failure + ss.Close() + + // Wait for keepalive to detect the failure and close the client + // check periodically instead of just waiting + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + _, err = cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if errors.Is(err, ErrConnectionClosed) { + return // Test passed + } + time.Sleep(25 * time.Millisecond) + } + + t.Errorf("expected connection to be closed by keepalive, but it wasn't. Last error: %v", err) +} diff --git a/mcp/server.go b/mcp/server.go index 53088bdb..44ec7aa7 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -16,6 +16,7 @@ import ( "path/filepath" "slices" "sync" + "time" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/util" @@ -57,6 +58,10 @@ type ServerOptions struct { RootsListChangedHandler func(context.Context, *ServerSession, *RootsListChangedParams) // If non-nil, called when "notifications/progress" is received. ProgressNotificationHandler func(context.Context, *ServerSession, *ProgressNotificationParams) + // If non-zero, defines an interval for regular "ping" requests. + // If the peer fails to respond to pings originating from the keepalive check, + // the session is automatically closed. + KeepAlive time.Duration } // NewServer creates a new MCP server. The resulting server has no features: @@ -460,6 +465,9 @@ func (s *Server) Connect(ctx context.Context, t Transport) (*ServerSession, erro } func (s *Server) callInitializedHandler(ctx context.Context, ss *ServerSession, params *InitializedParams) (Result, error) { + if s.opts.KeepAlive > 0 { + ss.startKeepalive(s.opts.KeepAlive) + } return callNotificationHandler(ctx, s.opts.InitializedHandler, ss, params) } @@ -492,6 +500,7 @@ type ServerSession struct { logLevel LoggingLevel initializeParams *InitializeParams initialized bool + keepaliveCancel context.CancelFunc } // Ping pings the client. @@ -678,6 +687,14 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLevelParams) (*e // requests from being handled, and waiting for ongoing requests to return. // Close then terminates the connection. func (ss *ServerSession) Close() error { + if ss.keepaliveCancel != nil { + // Note: keepaliveCancel access is safe without a mutex because: + // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines + // 3. The keepalive goroutine calls Close on ping failure, but this is safe since + // Close is idempotent and conn.Close() handles concurrent calls correctly + ss.keepaliveCancel() + } return ss.conn.Close() } @@ -686,6 +703,11 @@ func (ss *ServerSession) Wait() error { return ss.conn.Wait() } +// startKeepalive starts the keepalive mechanism for this server session. +func (ss *ServerSession) startKeepalive(interval time.Duration) { + startKeepalive(ss, interval, &ss.keepaliveCancel) +} + // pageToken is the internal structure for the opaque pagination cursor. // It will be Gob-encoded and then Base64-encoded for use as a string token. type pageToken struct { diff --git a/mcp/shared.go b/mcp/shared.go index 0edc0680..a2d51470 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -310,3 +310,41 @@ type listResult[T any] interface { // Returns a pointer to the param's NextCursor field. nextCursorPtr() *string } + +// keepaliveSession represents a session that supports keepalive functionality. +type keepaliveSession interface { + Ping(ctx context.Context, params *PingParams) error + Close() error +} + +// startKeepalive starts the keepalive mechanism for a session. +// It assigns the cancel function to the provided cancelPtr and starts a goroutine +// that sends ping messages at the specified interval. +func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + // Assign cancel function before starting goroutine to avoid race condition. + // We cannot return it because the caller may need to cancel during the + // window between goroutine scheduling and function return. + *cancelPtr = cancel + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2) + err := session.Ping(pingCtx, nil) + pingCancel() + if err != nil { + // Ping failed, close the session + _ = session.Close() + return + } + } + } + }() +} From 8a3f272dbbcf5e6725fe7efb74167f3681b68113 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 27 Jun 2025 15:43:14 -0400 Subject: [PATCH 15/24] internal/readme: fix server example (#63) Fix an example using params.Name instead of params.Arguments.Name. All credit due to @A11Might for noticing this. Sending a separate PR to keep the internal/readme source consistent. --- README.md | 2 +- internal/readme/server/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17181eaa..d4900674 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ type HiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Name}}, + Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Arguments.Name}}, }, nil } diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go index 177cf8fa..534e0798 100644 --- a/internal/readme/server/server.go +++ b/internal/readme/server/server.go @@ -18,7 +18,7 @@ type HiParams struct { func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[HiParams]) (*mcp.CallToolResultFor[any], error) { return &mcp.CallToolResultFor[any]{ - Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Name}}, + Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + params.Arguments.Name}}, }, nil } From 45f700ea1331df7706d05623d6b02d86a66018c7 Mon Sep 17 00:00:00 2001 From: cryo Date: Mon, 30 Jun 2025 19:51:01 +0800 Subject: [PATCH 16/24] mcp/client: fix typo in changeAndNotify&ReadResource (#68) This PR updates the comments for func changeAndNotify and ReadResource to correct typos and improve clarity. --- mcp/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 82fbb01c..856df567 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -191,7 +191,7 @@ func (c *Client) RemoveRoots(uris ...string) { func() bool { return c.roots.remove(uris...) }) } -// changeAndNotifyClient is called when a feature is added or removed. +// changeAndNotify is called when a feature is added or removed. // It calls change, which should do the work and report whether a change actually occurred. // If there was a change, it notifies a snapshot of the sessions. func (c *Client) changeAndNotify(notification string, params Params, change func() bool) { @@ -347,7 +347,7 @@ func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *List return handleSend[*ListResourceTemplatesResult](ctx, cs, methodListResourceTemplates, orZero[Params](params)) } -// ReadResource ask the server to read a resource and return its contents. +// ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { return handleSend[*ReadResourceResult](ctx, cs, methodReadResource, orZero[Params](params)) } From 032c62c8add888b59d2e6932087adbe986ee9715 Mon Sep 17 00:00:00 2001 From: SADIK KUZU Date: Mon, 30 Jun 2025 15:11:04 +0300 Subject: [PATCH 17/24] Fix typos (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request addresses multiple minor documentation and code comment fixes to correct typos and improve clarity. These changes do not introduce functional modifications but enhance readability and professionalism. ### Documentation Fixes: * Corrected a typo in the "Security Considerations" section header in `design/design.md`. ("Securty" → "Security") * Fixed spelling errors in the "Governance and Community" section of `design/design.md`. ("accomodating" → "accommodating") * Updated the "Proposals" section in `design/design.md` to fix a typo. ("accomodate" → "accommodate") ### Code Comment Fixes: * Corrected a typo in a comment in `mcp/logging.go`. ("LoggingMesssage" → "LoggingMessage") * Fixed a typo in a comment in `mcp/streamable.go`. ("priviledged" → "privileged") ## Motivation and Context This PR is for fixing some typos in the repo ## How Has This Been Tested? by code diff ## Breaking Changes N/A ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [ ] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context --- design/design.md | 6 +++--- mcp/logging.go | 2 +- mcp/streamable.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/design/design.md b/design/design.md index 4ade34ca..b690e514 100644 --- a/design/design.md +++ b/design/design.md @@ -863,7 +863,7 @@ type ServerOptions struct { } ``` -#### Securty Considerations +#### Security Considerations Implementors of the CompletionHandler MUST adhere to the following security guidelines: @@ -952,7 +952,7 @@ In addition to the `List` methods, the SDK provides an iterator method for each # Governance and Community -While the sections above propose an initial implementation of the Go SDK, MCP is evolving rapidly. SDKs need to keep pace, by implementing changes to the spec, fixing bugs, and accomodating new and emerging use-cases. This section proposes how the SDK project can be managed so that it can change safely and transparently. +While the sections above propose an initial implementation of the Go SDK, MCP is evolving rapidly. SDKs need to keep pace, by implementing changes to the spec, fixing bugs, and accommodating new and emerging use-cases. This section proposes how the SDK project can be managed so that it can change safely and transparently. Initially, the Go SDK repository will be administered by the Go team and Anthropic, and they will be the Approvers (the set of people able to merge PRs to the SDK). The policies here are also intended to satisfy necessary constraints of the Go team's participation in the project. @@ -980,7 +980,7 @@ A proposal is an issue that proposes a new API for the SDK, or a change to the s Proposals that are straightforward and uncontroversial may be approved based on GitHub discussion. However, proposals that are deemed to be sufficiently unclear or complicated will be deferred to a regular steering meeting (see below). -This process is similar to the [Go proposal process](https://github.com/golang/proposal), but is necessarily lighter weight to accomodate the greater rate of change expected for the SDK. +This process is similar to the [Go proposal process](https://github.com/golang/proposal), but is necessarily lighter weight to accommodate the greater rate of change expected for the SDK. ### Steering meetings diff --git a/mcp/logging.go b/mcp/logging.go index 65ebe067..4880e179 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -137,7 +137,7 @@ func (h *LoggingHandler) WithGroup(name string) slog.Handler { } // Handle implements [slog.Handler.Handle] by writing the Record to a JSONHandler, -// then calling [ServerSession.LoggingMesssage] with the result. +// then calling [ServerSession.LoggingMessage] with the result. func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error { err := h.handle(ctx, r) // TODO(jba): find a way to surface the error. diff --git a/mcp/streamable.go b/mcp/streamable.go index 63211c25..a1952f7e 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -254,7 +254,7 @@ func (s *StreamableServerTransport) Connect(context.Context) (Connection, error) // // Currently, this is implemented in [ServerSession.handle]. This is not ideal, // because it means that a user of the MCP package couldn't implement the -// streamable transport, as they'd lack this priviledged access. +// streamable transport, as they'd lack this privileged access. // // If we ever wanted to expose this mechanism, we have a few options: // 1. Make ServerSession an interface, and provide an implementation of From bc59932176e7895d56d6368a936c84762c6ea545 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:49:55 -0400 Subject: [PATCH 18/24] design: add design for rate limiting (#36) I added an example for how rate limiting should be implemented using middleware. Fixes #22 ## Motivation and Context Rate limiting can be enabled using middleware. This adds an example on how to do that. ## How Has This Been Tested? N/A ## Breaking Changes No ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context --- design/design.md | 4 +++ examples/rate-limiting/go.mod | 8 +++++ examples/rate-limiting/go.sum | 8 +++++ examples/rate-limiting/main.go | 54 ++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 examples/rate-limiting/go.mod create mode 100644 examples/rate-limiting/go.sum create mode 100644 examples/rate-limiting/main.go diff --git a/design/design.md b/design/design.md index b690e514..b52e9c10 100644 --- a/design/design.md +++ b/design/design.md @@ -470,6 +470,10 @@ server.AddReceivingMiddleware(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. +#### Rate Limiting + +Rate limiting can be configured using middleware. Please see [examples/rate-limiting](] for an example on how to implement this. + ### 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. diff --git a/examples/rate-limiting/go.mod b/examples/rate-limiting/go.mod new file mode 100644 index 00000000..5ec49ddc --- /dev/null +++ b/examples/rate-limiting/go.mod @@ -0,0 +1,8 @@ +module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting + +go 1.25 + +require ( + github.com/modelcontextprotocol/go-sdk v0.0.0-20250625185707-09181c2c2e89 + golang.org/x/time v0.12.0 +) diff --git a/examples/rate-limiting/go.sum b/examples/rate-limiting/go.sum new file mode 100644 index 00000000..c7027682 --- /dev/null +++ b/examples/rate-limiting/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/modelcontextprotocol/go-sdk v0.0.0-20250625185707-09181c2c2e89 h1:kUGBYP25FTv3ZRBhLT4iQvtx4FDl7hPkWe3isYrMxyo= +github.com/modelcontextprotocol/go-sdk v0.0.0-20250625185707-09181c2c2e89/go.mod h1:DcXfbr7yl7e35oMpzHfKw2nUYRjhIGS2uou/6tdsTB0= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/rate-limiting/main.go b/examples/rate-limiting/main.go new file mode 100644 index 00000000..7e91b79f --- /dev/null +++ b/examples/rate-limiting/main.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "errors" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/time/rate" +) + +// GlobalRateLimiterMiddleware creates a middleware that applies a global rate limit. +// Every request attempting to pass through will try to acquire a token. +// If a token cannot be acquired immediately, the request will be rejected. +func GlobalRateLimiterMiddleware[S mcp.Session](limiter *rate.Limiter) mcp.Middleware[S] { + return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] { + return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) { + if !limiter.Allow() { + return nil, errors.New("JSON RPC overloaded") + } + return next(ctx, session, method, params) + } + } +} + +// PerMethodRateLimiterMiddleware creates a middleware that applies rate limiting +// on a per-method basis. +// Methods not specified in limiters will not be rate limited by this middleware. +func PerMethodRateLimiterMiddleware[S mcp.Session](limiters map[string]*rate.Limiter) mcp.Middleware[S] { + return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] { + return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) { + if limiter, ok := limiters[method]; ok { + if !limiter.Allow() { + return nil, errors.New("JSON RPC overloaded") + } + } + return next(ctx, session, method, params) + } + } +} + +func main() { + server := mcp.NewServer("greeter1", "v0.0.1", nil) + server.AddReceivingMiddleware(GlobalRateLimiterMiddleware[*mcp.ServerSession](rate.NewLimiter(rate.Every(time.Second/5), 10))) + server.AddReceivingMiddleware(PerMethodRateLimiterMiddleware[*mcp.ServerSession](map[string]*rate.Limiter{ + "callTool": rate.NewLimiter(rate.Every(time.Second), 5), // once a second with a burst up to 5 + "listTools": rate.NewLimiter(rate.Every(time.Minute), 20), // once a minute with a burst up to 20 + })) + // Run Server logic. +} From beed2f06777e5e4b43788b199b3e3c6d4cb4018b Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 30 Jun 2025 13:47:24 -0400 Subject: [PATCH 19/24] mcp: small changes to CallTool structs (#60) - Use `any` for CallToolResult.StructuredContent. - Change the non-generic structs into aliases for the generic ones. --- mcp/content_test.go | 2 -- mcp/protocol.go | 67 ++++++--------------------------------------- mcp/tool.go | 2 +- 3 files changed, 10 insertions(+), 61 deletions(-) diff --git a/mcp/content_test.go b/mcp/content_test.go index 5270f991..5e6c6d03 100644 --- a/mcp/content_test.go +++ b/mcp/content_test.go @@ -7,7 +7,6 @@ package mcp_test import ( "encoding/json" "fmt" - "log" "testing" "github.com/google/go-cmp/cmp" @@ -94,7 +93,6 @@ func TestContent(t *testing.T) { t.Errorf("json.Marshal(%v) mismatch (-want +got):\n%s", test.in, diff) } result := fmt.Sprintf(`{"content":[%s]}`, string(got)) - log.Println(result) var out mcp.CallToolResult if err := json.Unmarshal([]byte(result), &out); err != nil { t.Fatal(err) diff --git a/mcp/protocol.go b/mcp/protocol.go index babfd55a..73e133ac 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -39,70 +39,18 @@ type Annotations struct { Priority float64 `json:"priority,omitempty"` } -type CallToolParams struct { - // This property is reserved by the protocol to allow clients and servers to - // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` - Arguments any `json:"arguments,omitempty"` - Name string `json:"name"` -} - -func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) } -func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) } +type CallToolParams = CallToolParamsFor[any] type CallToolParamsFor[In any] struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` - Arguments In `json:"arguments,omitempty"` Name string `json:"name"` + Arguments In `json:"arguments,omitempty"` } // The server's response to a tool call. -type CallToolResult struct { - // This property is reserved by the protocol to allow clients and servers to - // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` - // A list of content objects that represent the unstructured result of the tool - // call. - Content []Content `json:"content"` - // Whether the tool call ended in an error. - // - // If not set, this is assumed to be false (the call was successful). - // - // Any errors that originate from the tool should be reported inside the result - // object, with isError set to true, not as an MCP protocol-level error - // response. Otherwise, the LLM would not be able to see that an error occurred - // and self-correct. - // - // However, any errors in finding the tool, an error indicating that the - // server does not support tool calls, or any other exceptional conditions, - // should be reported as an MCP error response. - IsError bool `json:"isError,omitempty"` - // An optional JSON object that represents the structured result of the tool - // call. - // TODO(jba,rfindley): should this be any? - StructuredContent map[string]any `json:"structuredContent,omitempty"` -} - -// UnmarshalJSON handles the unmarshalling of content into the Content -// interface. -func (x *CallToolResult) UnmarshalJSON(data []byte) error { - type res CallToolResult // avoid recursion - var wire struct { - res - Content []*wireContent `json:"content"` - } - if err := json.Unmarshal(data, &wire); err != nil { - return err - } - var err error - if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { - return err - } - *x = CallToolResult(wire.res) - return nil -} +type CallToolResult = CallToolResultFor[any] type CallToolResultFor[Out any] struct { // This property is reserved by the protocol to allow clients and servers to @@ -111,6 +59,9 @@ type CallToolResultFor[Out any] struct { // A list of content objects that represent the unstructured result of the tool // call. Content []Content `json:"content"` + // An optional JSON object that represents the structured result of the tool + // call. + StructuredContent Out `json:"structuredContent,omitempty"` // Whether the tool call ended in an error. // // If not set, this is assumed to be false (the call was successful). @@ -124,9 +75,6 @@ type CallToolResultFor[Out any] struct { // server does not support tool calls, or any other exceptional conditions, // should be reported as an MCP error response. IsError bool `json:"isError,omitempty"` - // An optional JSON object that represents the structured result of the tool - // call. - StructuredContent Out `json:"structuredContent,omitempty"` } // UnmarshalJSON handles the unmarshalling of content into the Content @@ -148,6 +96,9 @@ func (x *CallToolResultFor[Out]) UnmarshalJSON(data []byte) error { return nil } +func (x *CallToolParamsFor[Out]) GetProgressToken() any { return getProgressToken(x) } +func (x *CallToolParamsFor[Out]) SetProgressToken(t any) { setProgressToken(x, t) } + type CancelledParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. diff --git a/mcp/tool.go b/mcp/tool.go index 5bf9440b..a6f228eb 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -17,7 +17,7 @@ import ( // A ToolHandler handles a call to tools/call. // [CallToolParams.Arguments] will contain a map[string]any that has been validated // against the input schema. -// Perhaps this should be an alias for ToolHandlerFor[map[string]any, map[string]any]. +// TODO: Perhaps this should be an alias for ToolHandlerFor[map[string]any, map[string]any]? type ToolHandler func(context.Context, *ServerSession, *CallToolParamsFor[map[string]any]) (*CallToolResult, error) // A ToolHandlerFor handles a call to tools/call with typed arguments and results. From c657cc383655a8448ec0339cc12ed603d2d52f20 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 30 Jun 2025 14:30:30 -0400 Subject: [PATCH 20/24] .github: add PR template (#75) The default template is too elaborate. Add a simpler one. --- .github/pull_request_template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..be13052c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +### PR Tips + +Typically, PRs should consist of a single commit, and so should generally follow +the [rules for Go commit messages](https://go.dev/wiki/CommitMessage), with the following +changes and additions: + +- Markdown is allowed. + +- For a pervasive change, use "all" in the title instead of a package name. + +- The PR description should provide context (why this change?) and describe the changes + at a high level. Changes that are obvious from the diffs don't need to be mentioned. From 2facfc6ffe0b7f62fa8c84aa594612ad96b4c820 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 30 Jun 2025 14:44:40 -0400 Subject: [PATCH 21/24] jsonschema: unexport ForType (#71) There's no evidence that it's needed. Also, improve doc and error messages. --- jsonschema/doc.go | 2 +- jsonschema/infer.go | 49 ++++++++++++++++++++++----------------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/jsonschema/doc.go b/jsonschema/doc.go index a53df32d..f25b000a 100644 --- a/jsonschema/doc.go +++ b/jsonschema/doc.go @@ -40,7 +40,7 @@ or as a value of this type: # Inference -The [For] and [ForType] functions return a [Schema] describing the given Go type. +The [For] function returns a [Schema] describing the given Go type. The type cannot contain any function or channel types, and any map types must have a string key. For example, calling For on the above Player type results in this schema: diff --git a/jsonschema/infer.go b/jsonschema/infer.go index 20ec1345..1334bdf1 100644 --- a/jsonschema/infer.go +++ b/jsonschema/infer.go @@ -15,40 +15,39 @@ import ( // For constructs a JSON schema object for the given type argument. // -// It is a convenience for ForType. -func For[T any]() (*Schema, error) { - return ForType(reflect.TypeFor[T]()) -} - -// ForType constructs a JSON schema object for the given type. // It translates Go types into compatible JSON schema types, as follows: -// - strings have schema type "string" -// - bools have schema type "boolean" -// - signed and unsigned integer types have schema type "integer" -// - floating point types have schema type "number" -// - slices and arrays have schema type "array", and a corresponding schema -// for items -// - maps with string key have schema type "object", and corresponding -// schema for additionalProperties -// - structs have schema type "object", and disallow additionalProperties. +// - Strings have schema type "string". +// - Bools have schema type "boolean". +// - Signed and unsigned integer types have schema type "integer". +// - Floating point types have schema type "number". +// - Slices and arrays have schema type "array", and a corresponding schema +// for items. +// - Maps with string key have schema type "object", and corresponding +// schema for additionalProperties. +// - Structs have schema type "object", and disallow additionalProperties. // Their properties are derived from exported struct fields, using the -// struct field json name. Fields that are marked "omitempty" are +// struct field JSON name. Fields that are marked "omitempty" are // considered optional; all other fields become required properties. // -// It returns an error if t contains (possibly recursively) any of the following Go +// For returns an error if t contains (possibly recursively) any of the following Go // types, as they are incompatible with the JSON schema spec. // - maps with key other than 'string' // - function types // - complex numbers // - unsafe pointers // -// The cannot be any cycles in the types. -// TODO(rfindley): we could perhaps just skip these incompatible fields. -func ForType(t reflect.Type) (*Schema, error) { - return typeSchema(t) +// The types must not have cycles. +func For[T any]() (*Schema, error) { + // TODO: consider skipping incompatible fields, instead of failing. + s, err := forType(reflect.TypeFor[T]()) + if err != nil { + var z T + return nil, fmt.Errorf("For[%T](): %w", z, err) + } + return s, nil } -func typeSchema(t reflect.Type) (*Schema, error) { +func forType(t reflect.Type) (*Schema, error) { // Follow pointers: the schema for *T is almost the same as for T, except that // an explicit JSON "null" is allowed for the pointer. allowNull := false @@ -82,14 +81,14 @@ func typeSchema(t reflect.Type) (*Schema, error) { return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind()) } s.Type = "object" - s.AdditionalProperties, err = typeSchema(t.Elem()) + s.AdditionalProperties, err = forType(t.Elem()) if err != nil { return nil, fmt.Errorf("computing map value schema: %v", err) } case reflect.Slice, reflect.Array: s.Type = "array" - s.Items, err = typeSchema(t.Elem()) + s.Items, err = forType(t.Elem()) if err != nil { return nil, fmt.Errorf("computing element schema: %v", err) } @@ -115,7 +114,7 @@ func typeSchema(t reflect.Type) (*Schema, error) { if s.Properties == nil { s.Properties = make(map[string]*Schema) } - s.Properties[info.Name], err = typeSchema(field.Type) + s.Properties[info.Name], err = forType(field.Type) if err != nil { return nil, err } From bfb9215d160d6e74617496243f513f3b88329022 Mon Sep 17 00:00:00 2001 From: Albert Sundjaja <22909314+albertsundjaja@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:02:26 +1000 Subject: [PATCH 22/24] jsonchema: support additional keywords Support additional (extra) keywords beyond those specified in the `Schema` type. The current behavior is additional keywords are dropped. To follow up: - There are similar util functions in `mcp/util.go` which don't seem to be used. Maybe we should move it to `internal/util` instead of `jsonchema/util.go` package? - The method `[Un]MarshalJSON` is only on `pointer receiver`, is this intended? (that means it will only work if we [un]marshal pointer to the `Schema` instance) - The fix is using the [un]marshalStructWithMap from `mcp/util.go` and there are requirements for the function to work properly but these are not strictly checked e.g. need `json:"-"` tag for the Extra field. Fixes #69. --- jsonschema/schema.go | 7 +- jsonschema/schema_test.go | 13 +++- jsonschema/util.go | 136 ++++++++++++++++++++++++++++++++++++++ jsonschema/util_test.go | 59 +++++++++++++++++ 4 files changed, 211 insertions(+), 4 deletions(-) diff --git a/jsonschema/schema.go b/jsonschema/schema.go index 1a73cc65..26623f1b 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -127,6 +127,9 @@ type Schema struct { // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7 Format string `json:"format,omitempty"` + // Extra allows for additional keywords beyond those specified. + Extra map[string]any `json:"-"` + // computed fields // This schema's base schema. @@ -236,7 +239,7 @@ func (s *Schema) MarshalJSON() ([]byte, error) { Type: typ, schemaWithoutMethods: (*schemaWithoutMethods)(s), } - return json.Marshal(ms) + return marshalStructWithMap(&ms, "Extra") } func (s *Schema) UnmarshalJSON(data []byte) error { @@ -269,7 +272,7 @@ func (s *Schema) UnmarshalJSON(data []byte) error { }{ schemaWithoutMethods: (*schemaWithoutMethods)(s), } - if err := json.Unmarshal(data, &ms); err != nil { + if err := unmarshalStructWithMap(data, &ms, "Extra"); err != nil { return err } // Unmarshal "type" as either Type or Types. diff --git a/jsonschema/schema_test.go b/jsonschema/schema_test.go index 0d3ea090..4ceb1ee1 100644 --- a/jsonschema/schema_test.go +++ b/jsonschema/schema_test.go @@ -26,6 +26,7 @@ func TestGoRoundTrip(t *testing.T) { {Const: Ptr(any(map[string]any{}))}, {Default: mustMarshal(1)}, {Default: mustMarshal(nil)}, + {Extra: map[string]any{"test": "value"}}, } { data, err := json.Marshal(s) if err != nil { @@ -64,11 +65,19 @@ func TestJSONRoundTrip(t *testing.T) { `{"$vocabulary":{"b":true, "a":false}}`, `{"$vocabulary":{"a":false,"b":true}}`, }, - {`{"unk":0}`, `{}`}, // unknown fields are dropped, unfortunately + {`{"unk":0}`, `{"unk":0}`}, // unknown fields are not dropped + { + // known and unknown fields are not dropped + // note that the order will be by the declaration order in the anonymous struct inside MarshalJSON + `{"comment":"test","type":"example","unk":0}`, + `{"type":"example","comment":"test","unk":0}`, + }, + {`{"extra":0}`, `{"extra":0}`}, // extra is not a special keyword and should not be dropped + {`{"Extra":0}`, `{"Extra":0}`}, // Extra is not a special keyword and should not be dropped } { var s Schema mustUnmarshal(t, []byte(tt.in), &s) - data, err := json.Marshal(s) + data, err := json.Marshal(&s) if err != nil { t.Fatal(err) } diff --git a/jsonschema/util.go b/jsonschema/util.go index f884aff5..71c34439 100644 --- a/jsonschema/util.go +++ b/jsonschema/util.go @@ -15,6 +15,9 @@ import ( "math/big" "reflect" "slices" + "sync" + + "github.com/modelcontextprotocol/go-sdk/internal/util" ) // Equal reports whether two Go values representing JSON values are equal according @@ -282,3 +285,136 @@ func assert(cond bool, msg string) { panic("assertion failed: " + msg) } } + +// marshalStructWithMap marshals its first argument to JSON, treating the field named +// mapField as an embedded map. The first argument must be a pointer to +// a struct. The underlying type of mapField must be a map[string]any, and it must have +// a "-" json tag, meaning it will not be marshaled. +// +// For example, given this struct: +// +// type S struct { +// A int +// Extra map[string] any `json:"-"` +// } +// +// and this value: +// +// s := S{A: 1, Extra: map[string]any{"B": 2}} +// +// the call marshalJSONWithMap(s, "Extra") would return +// +// {"A": 1, "B": 2} +// +// It is an error if the map contains the same key as another struct field's +// JSON name. +// +// marshalStructWithMap calls json.Marshal on a value of type T, so T must not +// have a MarshalJSON method that calls this function, on pain of infinite regress. +// +// Note that there is a similar function in mcp/util.go, but they are not the same. +// Here the function requires `-` json tag, does not clear the mapField map, +// and handles embedded struct due to the implementation of jsonNames in this package. +// +// TODO: avoid this restriction on T by forcing it to marshal in a default way. +// See https://go.dev/play/p/EgXKJHxEx_R. +func marshalStructWithMap[T any](s *T, mapField string) ([]byte, error) { + // Marshal the struct and the map separately, and concatenate the bytes. + // This strategy is dramatically less complicated than + // constructing a synthetic struct or map with the combined keys. + if s == nil { + return []byte("null"), nil + } + s2 := *s + vMapField := reflect.ValueOf(&s2).Elem().FieldByName(mapField) + mapVal := vMapField.Interface().(map[string]any) + + // Check for duplicates. + names := jsonNames(reflect.TypeFor[T]()) + for key := range mapVal { + if names[key] { + return nil, fmt.Errorf("map key %q duplicates struct field", key) + } + } + + structBytes, err := json.Marshal(s2) + if err != nil { + return nil, fmt.Errorf("marshalStructWithMap(%+v): %w", s, err) + } + if len(mapVal) == 0 { + return structBytes, nil + } + mapBytes, err := json.Marshal(mapVal) + if err != nil { + return nil, err + } + if len(structBytes) == 2 { // must be "{}" + return mapBytes, nil + } + // "{X}" + "{Y}" => "{X,Y}" + res := append(structBytes[:len(structBytes)-1], ',') + res = append(res, mapBytes[1:]...) + return res, nil +} + +// unmarshalStructWithMap is the inverse of marshalStructWithMap. +// T has the same restrictions as in that function. +// +// Note that there is a similar function in mcp/util.go, but they are not the same. +// Here jsonNames also returns fields from embedded structs, hence this function +// handles embedded structs as well. +func unmarshalStructWithMap[T any](data []byte, v *T, mapField string) error { + // Unmarshal into the struct, ignoring unknown fields. + if err := json.Unmarshal(data, v); err != nil { + return err + } + // Unmarshal into the map. + m := map[string]any{} + if err := json.Unmarshal(data, &m); err != nil { + return err + } + // Delete from the map the fields of the struct. + for n := range jsonNames(reflect.TypeFor[T]()) { + delete(m, n) + } + if len(m) != 0 { + reflect.ValueOf(v).Elem().FieldByName(mapField).Set(reflect.ValueOf(m)) + } + return nil +} + +var jsonNamesMap sync.Map // from reflect.Type to map[string]bool + +// jsonNames returns the set of JSON object keys that t will marshal into, +// including fields from embedded structs in t. +// t must be a struct type. +// +// Note that there is a similar function in mcp/util.go, but they are not the same +// Here the function recurses over embedded structs and includes fields from them. +func jsonNames(t reflect.Type) map[string]bool { + // Lock not necessary: at worst we'll duplicate work. + if val, ok := jsonNamesMap.Load(t); ok { + return val.(map[string]bool) + } + m := map[string]bool{} + for i := range t.NumField() { + field := t.Field(i) + // handle embedded structs + if field.Anonymous { + fieldType := field.Type + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + for n := range jsonNames(fieldType) { + m[n] = true + } + continue + } + info := util.FieldJSONInfo(field) + if !info.Omit { + m[info.Name] = true + } + } + jsonNamesMap.Store(t, m) + return m +} diff --git a/jsonschema/util_test.go b/jsonschema/util_test.go index 189cb26a..03ccb4d7 100644 --- a/jsonschema/util_test.go +++ b/jsonschema/util_test.go @@ -8,7 +8,11 @@ import ( "encoding/json" "hash/maphash" "reflect" + "strings" "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" ) func TestEqual(t *testing.T) { @@ -125,3 +129,58 @@ func TestHash(t *testing.T) { } _ = hash(null) } + +func TestMarshalStructWithMap(t *testing.T) { + type S struct { + A int + B string `json:"b,omitempty"` + u bool + M map[string]any `json:"-"` + } + t.Run("basic", func(t *testing.T) { + s := S{A: 1, B: "two", M: map[string]any{"!@#": true}} + got, err := marshalStructWithMap(&s, "M") + if err != nil { + t.Fatal(err) + } + want := `{"A":1,"b":"two","!@#":true}` + if g := string(got); g != want { + t.Errorf("\ngot %s\nwant %s", g, want) + } + + var un S + if err := unmarshalStructWithMap(got, &un, "M"); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(s, un, cmpopts.IgnoreUnexported(S{})); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + }) + t.Run("duplicate", func(t *testing.T) { + s := S{A: 1, B: "two", M: map[string]any{"b": "dup"}} + _, err := marshalStructWithMap(&s, "M") + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Errorf("got %v, want error with 'duplicate'", err) + } + }) + t.Run("embedded", func(t *testing.T) { + type Embedded struct { + A int + B int + Extra map[string]any `json:"-"` + } + type S struct { + C int + Embedded + } + s := S{C: 1, Embedded: Embedded{A: 2, B: 3, Extra: map[string]any{"d": 4, "e": 5}}} + got, err := marshalStructWithMap(&s, "Extra") + if err != nil { + t.Fatal(err) + } + want := `{"C":1,"A":2,"B":3,"d":4,"e":5}` + if g := string(got); g != want { + t.Errorf("got %v, want %v", g, want) + } + }) +} From 41325b8f34c407162bca2dcab9f383a6e8e21592 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 27 Jun 2025 20:30:32 -0400 Subject: [PATCH 23/24] mcp: add Session.ID Support retrieving the session ID from client and server sessions. For #65. --- mcp/client.go | 12 ++++++++++++ mcp/server.go | 12 ++++++++++++ mcp/shared.go | 3 +++ mcp/sse.go | 6 ++++++ mcp/streamable.go | 30 ++++++++++++++++++++---------- mcp/streamable_test.go | 8 +++++++- mcp/transport.go | 7 +++++++ 7 files changed, 67 insertions(+), 11 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 856df567..87e1af86 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -142,6 +142,18 @@ type ClientSession struct { client *Client initializeResult *InitializeResult keepaliveCancel context.CancelFunc + sessionIDFunc func() string +} + +func (cs *ClientSession) setSessionIDFunc(f func() string) { + cs.sessionIDFunc = f +} + +func (cs *ClientSession) ID() string { + if cs.sessionIDFunc == nil { + return "" + } + return cs.sessionIDFunc() } // Close performs a graceful close of the connection, preventing new requests diff --git a/mcp/server.go b/mcp/server.go index 44ec7aa7..16c49d29 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -496,6 +496,7 @@ func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNot type ServerSession struct { server *Server conn *jsonrpc2.Connection + sessionIDFunc func() string mu sync.Mutex logLevel LoggingLevel initializeParams *InitializeParams @@ -503,6 +504,17 @@ type ServerSession struct { keepaliveCancel context.CancelFunc } +func (ss *ServerSession) setSessionIDFunc(f func() string) { + ss.sessionIDFunc = f +} + +func (ss *ServerSession) ID() string { + if ss.sessionIDFunc == nil { + return "" + } + return ss.sessionIDFunc() +} + // Ping pings the client. func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { _, err := handleSend[*emptyResult](ctx, ss, methodPing, params) diff --git a/mcp/shared.go b/mcp/shared.go index a2d51470..db871ca8 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -36,6 +36,9 @@ type methodHandler any // MethodHandler[*ClientSession] | MethodHandler[*ServerS // A Session is either a ClientSession or a ServerSession. type Session interface { *ClientSession | *ServerSession + // ID returns the session ID, or the empty string if there is none. + ID() string + sendingMethodInfos() map[string]methodInfo receivingMethodInfos() map[string]methodInfo sendingMethodHandler() methodHandler diff --git a/mcp/sse.go b/mcp/sse.go index bdbb6d0f..0508b1e3 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -263,6 +263,9 @@ type sseServerConn struct { t *SSEServerTransport } +// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.) +func (s sseServerConn) sessionID() string { return "" } + // Read implements jsonrpc2.Reader. func (s sseServerConn) Read(ctx context.Context) (JSONRPCMessage, error) { select { @@ -518,6 +521,9 @@ type sseClientConn struct { done chan struct{} // closed when the stream is closed } +// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.) +func (c *sseClientConn) sessionID() string { return "" } + func (c *sseClientConn) isDone() bool { c.mu.Lock() defer c.mu.Unlock() diff --git a/mcp/streamable.go b/mcp/streamable.go index a1952f7e..2ee86de8 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -161,6 +161,10 @@ func NewStreamableServerTransport(sessionID string) *StreamableServerTransport { } } +func (t *StreamableServerTransport) sessionID() string { + return t.id +} + // A StreamableServerTransport implements the [Transport] interface for a // single session. type StreamableServerTransport struct { @@ -331,7 +335,7 @@ func (t *StreamableServerTransport) servePOST(w http.ResponseWriter, req *http.R http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest) return } - var requests = make(map[JSONRPCID]struct{}) + requests := make(map[JSONRPCID]struct{}) for _, msg := range incoming { if req, ok := msg.(*JSONRPCRequest); ok && req.ID.IsValid() { requests[req.ID] = struct{}{} @@ -624,20 +628,26 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er } type streamableClientConn struct { - url string - sessionID string - client *http.Client - incoming chan []byte - done chan struct{} + url string + client *http.Client + incoming chan []byte + done chan struct{} closeOnce sync.Once closeErr error - mu sync.Mutex + mu sync.Mutex + _sessionID string // bodies map[*http.Response]io.Closer err error } +func (c *streamableClientConn) sessionID() string { + c.mu.Lock() + defer c.mu.Unlock() + return c._sessionID +} + // Read implements the [Connection] interface. func (s *streamableClientConn) Read(ctx context.Context) (JSONRPCMessage, error) { select { @@ -658,7 +668,7 @@ func (s *streamableClientConn) Write(ctx context.Context, msg JSONRPCMessage) er return s.err } - sessionID := s.sessionID + sessionID := s._sessionID if sessionID == "" { // Hold lock for the first request. defer s.mu.Unlock() @@ -681,7 +691,7 @@ func (s *streamableClientConn) Write(ctx context.Context, msg JSONRPCMessage) er if sessionID == "" { // locked - s.sessionID = gotSessionID + s._sessionID = gotSessionID } return nil @@ -753,7 +763,7 @@ func (s *streamableClientConn) Close() error { if err != nil { s.closeErr = err } else { - req.Header.Set("Mcp-Session-Id", s.sessionID) + req.Header.Set("Mcp-Session-Id", s._sessionID) if _, err := s.client.Do(req); err != nil { s.closeErr = err } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d496f83e..12c00a2e 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -69,7 +69,10 @@ func TestStreamableTransports(t *testing.T) { t.Fatalf("client.Connect() failed: %v", err) } defer session.Close() - + sid := session.ID() + if sid == "" { + t.Error("empty session ID") + } // 4. The client calls the "greet" tool. params := &CallToolParams{ Name: "greet", @@ -79,6 +82,9 @@ func TestStreamableTransports(t *testing.T) { if err != nil { t.Fatalf("CallTool() failed: %v", err) } + if g := session.ID(); g != sid { + t.Errorf("session ID: got %q, want %q", g, sid) + } // 5. Verify that the correct response is received. want := &CallToolResult{ diff --git a/mcp/transport.go b/mcp/transport.go index 0fadca33..410e8b7a 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -50,6 +50,7 @@ type Connection interface { Read(context.Context) (JSONRPCMessage, error) Write(context.Context, JSONRPCMessage) error Close() error // may be called concurrently by both peers + sessionID() string } // A StdioTransport is a [Transport] that communicates over stdin/stdout using @@ -94,6 +95,7 @@ type binder[T handler] interface { type handler interface { handle(ctx context.Context, req *JSONRPCRequest) (any, error) + setSessionIDFunc(func() string) // so Sessions can get the session ID } func connect[H handler](ctx context.Context, t Transport, b binder[H]) (H, error) { @@ -124,6 +126,7 @@ func connect[H handler](ctx context.Context, t Transport, b binder[H]) (H, error }, }) assert(preempter.conn != nil, "unbound preempter") + h.setSessionIDFunc(conn.sessionID) return h, nil } @@ -200,6 +203,8 @@ type loggingConn struct { w io.Writer } +func (c *loggingConn) sessionID() string { return c.delegate.sessionID() } + // loggingReader is a stream middleware that logs incoming messages. func (s *loggingConn) Read(ctx context.Context) (JSONRPCMessage, error) { msg, err := s.delegate.Read(ctx) @@ -285,6 +290,8 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { } } +func (c *ioConn) sessionID() string { return "" } + // addBatch records a msgBatch for an incoming batch payload. // It returns an error if batch is malformed, containing previously seen IDs. // From aae91beabb2c4a2d2a6f22a92544c665c8c8ee86 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 1 Jul 2025 13:31:00 -0400 Subject: [PATCH 24/24] . --- mcp/client.go | 10 +++++----- mcp/server.go | 10 +++++----- mcp/sse.go | 4 ++-- mcp/streamable.go | 4 ++-- mcp/transport.go | 10 +++++----- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 87e1af86..a8943bcf 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -142,18 +142,18 @@ type ClientSession struct { client *Client initializeResult *InitializeResult keepaliveCancel context.CancelFunc - sessionIDFunc func() string + mcpConn Connection } -func (cs *ClientSession) setSessionIDFunc(f func() string) { - cs.sessionIDFunc = f +func (cs *ClientSession) setConn(c Connection) { + cs.mcpConn = c } func (cs *ClientSession) ID() string { - if cs.sessionIDFunc == nil { + if cs.mcpConn == nil { return "" } - return cs.sessionIDFunc() + return cs.mcpConn.SessionID() } // Close performs a graceful close of the connection, preventing new requests diff --git a/mcp/server.go b/mcp/server.go index 16c49d29..1e09767f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -496,7 +496,7 @@ func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNot type ServerSession struct { server *Server conn *jsonrpc2.Connection - sessionIDFunc func() string + mcpConn Connection mu sync.Mutex logLevel LoggingLevel initializeParams *InitializeParams @@ -504,15 +504,15 @@ type ServerSession struct { keepaliveCancel context.CancelFunc } -func (ss *ServerSession) setSessionIDFunc(f func() string) { - ss.sessionIDFunc = f +func (ss *ServerSession) setConn(c Connection) { + ss.mcpConn = c } func (ss *ServerSession) ID() string { - if ss.sessionIDFunc == nil { + if ss.mcpConn == nil { return "" } - return ss.sessionIDFunc() + return ss.mcpConn.SessionID() } // Ping pings the client. diff --git a/mcp/sse.go b/mcp/sse.go index 0508b1e3..0a1f9b1b 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -264,7 +264,7 @@ type sseServerConn struct { } // TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.) -func (s sseServerConn) sessionID() string { return "" } +func (s sseServerConn) SessionID() string { return "" } // Read implements jsonrpc2.Reader. func (s sseServerConn) Read(ctx context.Context) (JSONRPCMessage, error) { @@ -522,7 +522,7 @@ type sseClientConn struct { } // TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.) -func (c *sseClientConn) sessionID() string { return "" } +func (c *sseClientConn) SessionID() string { return "" } func (c *sseClientConn) isDone() bool { c.mu.Lock() diff --git a/mcp/streamable.go b/mcp/streamable.go index 2ee86de8..da950fb2 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -161,7 +161,7 @@ func NewStreamableServerTransport(sessionID string) *StreamableServerTransport { } } -func (t *StreamableServerTransport) sessionID() string { +func (t *StreamableServerTransport) SessionID() string { return t.id } @@ -642,7 +642,7 @@ type streamableClientConn struct { err error } -func (c *streamableClientConn) sessionID() string { +func (c *streamableClientConn) SessionID() string { c.mu.Lock() defer c.mu.Unlock() return c._sessionID diff --git a/mcp/transport.go b/mcp/transport.go index 410e8b7a..85bfaf65 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -50,7 +50,7 @@ type Connection interface { Read(context.Context) (JSONRPCMessage, error) Write(context.Context, JSONRPCMessage) error Close() error // may be called concurrently by both peers - sessionID() string + SessionID() string } // A StdioTransport is a [Transport] that communicates over stdin/stdout using @@ -95,7 +95,7 @@ type binder[T handler] interface { type handler interface { handle(ctx context.Context, req *JSONRPCRequest) (any, error) - setSessionIDFunc(func() string) // so Sessions can get the session ID + setConn(Connection) } func connect[H handler](ctx context.Context, t Transport, b binder[H]) (H, error) { @@ -126,7 +126,7 @@ func connect[H handler](ctx context.Context, t Transport, b binder[H]) (H, error }, }) assert(preempter.conn != nil, "unbound preempter") - h.setSessionIDFunc(conn.sessionID) + h.setConn(conn) return h, nil } @@ -203,7 +203,7 @@ type loggingConn struct { w io.Writer } -func (c *loggingConn) sessionID() string { return c.delegate.sessionID() } +func (c *loggingConn) SessionID() string { return c.delegate.SessionID() } // loggingReader is a stream middleware that logs incoming messages. func (s *loggingConn) Read(ctx context.Context) (JSONRPCMessage, error) { @@ -290,7 +290,7 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { } } -func (c *ioConn) sessionID() string { return "" } +func (c *ioConn) SessionID() string { return "" } // addBatch records a msgBatch for an incoming batch payload. // It returns an error if batch is malformed, containing previously seen IDs.