diff --git a/AGENTS.md b/AGENTS.md index 4bd9069..7746265 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,15 +15,20 @@ stdio (os.Stdin/os.Stdout) newline-delimited JSON-RPC 2.0 ← one message per line (MCP stdio framing) │ ▼ -Server.runWithIO() ← dispatch loop - ├── unparseable line → -32700 / -32600, loop keeps serving - ├── "initialize" → handshake - ├── "tools/list" → registered tools metadata - ├── "tools/call" → dispatch to Tool.Handler - ├── "resources/list" → registered resources metadata - ├── "resources/read" → dispatch to Resource.Handler - ├── "prompts/list" → registered prompts metadata - └── "prompts/get" → dispatch to Prompt.Handler +Server.run() ← dispatch loop + ├── unparseable line → -32700 / -32600, loop keeps serving + ├── oversized line → -32600, remainder discarded + ├── "initialize" → legacy handshake (version negotiated) + ├── "notifications/initialized" → consumed silently + ├── "server/discover" → 2026-07-28 capability probe + ├── "ping" → empty result (legacy clients) + ├── "tools/list" → registered tools metadata + ├── "tools/call" → dispatch to Tool.Handler + ├── "resources/list" → registered resources metadata + ├── "resources/read" → dispatch to Resource.Handler + ├── "resources/templates/list" → empty catalog (spec-compliant) + ├── "prompts/list" → registered prompts metadata + └── "prompts/get" → dispatch to Prompt.Handler ``` ## Code Map @@ -32,8 +37,11 @@ Server.runWithIO() ← dispatch loop |------|---------| | `gomcp/types.go` | Tool, Resource, Prompt, InputSchema, handler signatures | | `gomcp/jsonrpc.go` | JSON-RPC 2.0 request/response/error types | +| `gomcp/protocol.go` | Protocol versions, `_meta` negotiation, pagination | +| `gomcp/path.go` | `SafeJoin` — path-traversal-safe filesystem helper | | `gomcp/server.go` | Server struct, Run(), all JSON-RPC method handlers | | `gomcp/server_test.go` | Unit + integration tests (pipe-based) | +| `gomcp/protocol_test.go` | 2026-07-28 + security tests | | `gomcp/e2e_test.go` | Subprocess E2E test | | `examples/greet/main.go` | Canonical example MCP server | @@ -46,7 +54,8 @@ Server.runWithIO() ← dispatch loop - **Bad input never kills the loop.** A malformed line is answered in-band and the server keeps serving; `RunWithIO` returns only on EOF or a read/write failure. - **Inbound messages are size-capped.** One line may carry at most `Server.MaxRequestBytes` bytes (default `DefaultMaxRequestBytes`, 10 MiB; negative disables — not recommended). An oversized line is answered with `-32600` (id null), its remainder discarded, and the loop keeps serving — a single line can never exhaust memory. - **Go naming.** Exported types are PascalCase. Unexported internals are camelCase. Test functions are `TestXxx`. -- **Protocol version pinned.** `2024-11-05` hardcoded — update manually when MCP spec revs. +- **Protocol versions.** Default is `2026-07-28`. Legacy `initialize` still works and echoes `2024-11-05`, `2025-03-26`, or `2025-11-25` when the client asks for them. 2026-only fields (`resultType`, `ttlMs`, `cacheScope`, result `_meta`) are emitted only when the request declares `2026-07-28`. +- **Handlers must not kill the loop.** A panicking or timed-out handler is answered in-band; registration maps are mutex-protected. ## Testing diff --git a/README.md b/README.md index 4877d66..2135670 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,12 @@ srv.AddTool(gomcp.Tool{ Required: []string{"path"}, }, Handler: func(ctx context.Context, args map[string]any) (string, error) { - path := args["path"].(string) - // Prevent path traversal - if strings.Contains(path, "..") { - return "", fmt.Errorf("path traversal not allowed") + path, _ := args["path"].(string) + full, err := gomcp.SafeJoin("/var/project", path) + if err != nil { + return "", err } - data, err := os.ReadFile(filepath.Join("/var/project", path)) + data, err := os.ReadFile(full) return string(data), err }, }) @@ -243,10 +243,13 @@ Register a callable tool. The AI sees the `Name`, `Description`, and `InputSchem ```go type Tool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema InputSchema `json:"inputSchema"` - Handler ToolHandler `json:"-"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + InputSchema InputSchema `json:"inputSchema"` + OutputSchema any `json:"outputSchema,omitempty"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` + Handler ToolHandler `json:"-"` } type ToolHandler func(ctx context.Context, args map[string]any) (string, error) @@ -301,22 +304,39 @@ func NewTextContent(text string) map[string]any Starts the server loop. Reads JSON-RPC 2.0 from `os.Stdin`, writes responses to `os.Stdout`. Blocks until stdin closes (EOF). +### `srv.SetInstructions(text string)` + +Optional natural-language guidance returned by `initialize` and `server/discover`. + +### `gomcp.SafeJoin(root, userPath string) (string, error)` + +Resolves a model-supplied path against a root directory and rejects traversal, absolute escapes, and symlink escapes. + +### `srv.RunContext(ctx) error` / `srv.RunWithIOContext(ctx, r, w) error` + +Same as `Run` / `RunWithIO`, but handler invocations inherit `ctx`. Set `Server.HandlerTimeout` to bound a single handler. + --- ## Protocol Support +Default protocol version: **2026-07-28**. Legacy clients that still send `initialize` keep working — the server echoes `2024-11-05`, `2025-03-26`, or `2025-11-25` when asked. + | Method | Description | |--------|-------------| -| `initialize` | MCP handshake — reports server name, version, capabilities | +| `server/discover` | 2026-07-28 capability probe — versions, identity, cache hints | +| `initialize` | Legacy handshake — reports server name, version, capabilities | | `notifications/initialized` | Consumed silently | -| `tools/list` | Returns metadata for all registered tools | +| `ping` | Liveness check (kept for older clients) | +| `tools/list` | Returns metadata for all registered tools (deterministic order, optional cursor) | | `tools/call` | Dispatches a call to the matching tool handler | | `resources/list` | Returns metadata for all registered resources | | `resources/read` | Reads a resource by URI | +| `resources/templates/list` | Empty catalog (spec-compliant; templates are not registered yet) | | `prompts/list` | Returns metadata for all registered prompts | | `prompts/get` | Builds a prompt from arguments | -All eight methods implemented. **No partial support.** +2026-only result fields (`resultType`, `ttlMs`, `cacheScope`, `_meta`) are emitted only when the client declares protocol version `2026-07-28`. Older clients keep seeing the pre-2026 JSON shape. --- diff --git a/docs/index.html b/docs/index.html index 259b6e4..7c35c12 100644 --- a/docs/index.html +++ b/docs/index.html @@ -212,7 +212,7 @@

Build AI tools
in Go

dependencies
-
8
+
11
MCP methods
@@ -367,7 +367,7 @@

Prompts

Full MCP coverage

-

All eight MCP methods implemented. Not a subset. Not a work-in-progress. Complete.

+

2026-07-28 plus the legacy initialize handshake. Not a subset. Not a work-in-progress. Backward compatible.

@@ -375,12 +375,15 @@

Full MCP coverage

- + + + + diff --git a/examples/db-explorer/main.go b/examples/db-explorer/main.go index 3b4c1e4..30c5541 100644 --- a/examples/db-explorer/main.go +++ b/examples/db-explorer/main.go @@ -56,9 +56,15 @@ func main() { Handler: func(ctx context.Context, args map[string]any) (string, error) { query := args["query"].(string) trimmed := strings.TrimSpace(query) - if !strings.HasPrefix(strings.ToUpper(trimmed), "SELECT") { + upper := strings.ToUpper(trimmed) + if !strings.HasPrefix(upper, "SELECT") { return "", fmt.Errorf("only SELECT queries are allowed. Received: %s", trimmed[:min(50, len(trimmed))]) } + // Reject stacked statements. This is not a SQL parser — callers + // must still treat the tool as a trust boundary. + if strings.Contains(trimmed, ";") { + return "", fmt.Errorf("multiple statements are not allowed") + } rows, err := db.QueryContext(ctx, query) if err != nil { diff --git a/examples/fs-navigator/main.go b/examples/fs-navigator/main.go index e1fdfbc..dcead6f 100644 --- a/examples/fs-navigator/main.go +++ b/examples/fs-navigator/main.go @@ -11,6 +11,7 @@ package main import ( "context" "fmt" + "io" "os" "path/filepath" "sort" @@ -39,8 +40,8 @@ func main() { Required: []string{"path"}, }, Handler: func(ctx context.Context, args map[string]any) (string, error) { - dirPath := args["path"].(string) - fullPath, err := safePath(root, dirPath) + dirPath, _ := args["path"].(string) + fullPath, err := gomcp.SafeJoin(root, dirPath) if err != nil { return "", err } @@ -91,19 +92,25 @@ func main() { Required: []string{"path"}, }, Handler: func(ctx context.Context, args map[string]any) (string, error) { - filePath := args["path"].(string) - fullPath, err := safePath(root, filePath) + filePath, _ := args["path"].(string) + fullPath, err := gomcp.SafeJoin(root, filePath) if err != nil { return "", err } - data, err := os.ReadFile(fullPath) + f, err := os.Open(fullPath) if err != nil { return "", fmt.Errorf("read file: %w", err) } + defer f.Close() - if len(data) > 100*1024 { // 100KB limit - return fmt.Sprintf("(file too large: %d bytes, showing first 100KB)\n\n%s", len(data), string(data[:100*1024])), nil + const limit = 100 * 1024 + data, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return "", fmt.Errorf("read file: %w", err) + } + if len(data) > limit { + return fmt.Sprintf("(file too large, showing first %d bytes)\n\n%s", limit, data[:limit]), nil } return string(data), nil }, @@ -121,13 +128,20 @@ func main() { Required: []string{"pattern"}, }, Handler: func(ctx context.Context, args map[string]any) (string, error) { - pattern := args["pattern"].(string) + pattern, _ := args["pattern"].(string) + if strings.Contains(pattern, "..") { + return "", fmt.Errorf("invalid pattern") + } + const maxMatches = 200 var matches []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip inaccessible files } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } relPath, _ := filepath.Rel(root, path) // Skip hidden directories @@ -137,6 +151,9 @@ func main() { matched, _ := filepath.Match(pattern, info.Name()) if matched { + if len(matches) >= maxMatches { + return filepath.SkipAll + } matches = append(matches, relPath) } return nil @@ -214,21 +231,3 @@ func main() { os.Exit(1) } } - -// safePath resolves a user-supplied relative path against the root, -// preventing path traversal attacks. -func safePath(root, userPath string) (string, error) { - if strings.Contains(userPath, "..") { - return "", fmt.Errorf("path traversal not allowed: %s", userPath) - } - - fullPath := filepath.Join(root, userPath) - cleaned := filepath.Clean(fullPath) - - // Ensure we're still under root - if !strings.HasPrefix(cleaned, filepath.Clean(root)) { - return "", fmt.Errorf("path escapes root directory: %s", userPath) - } - - return cleaned, nil -} diff --git a/examples/greet/main.go b/examples/greet/main.go index 08d69b5..ad697d8 100644 --- a/examples/greet/main.go +++ b/examples/greet/main.go @@ -10,10 +10,12 @@ import ( func main() { srv := gomcp.NewServer("greeter", "1.0.0") + srv.SetInstructions("Use the greet tool to say hello. Read greeting://world for a canned greeting.") // Tool: greet srv.AddTool(gomcp.Tool{ Name: "greet", + Title: "Greet someone", Description: "Greet a person by name", InputSchema: gomcp.InputSchema{ Type: "object", diff --git a/gomcp/jsonrpc.go b/gomcp/jsonrpc.go index d1d2dd0..34ca8c4 100644 --- a/gomcp/jsonrpc.go +++ b/gomcp/jsonrpc.go @@ -24,20 +24,28 @@ type JSONRPCError struct { Error RPCErrorDetail `json:"error"` } -// RPCErrorDetail holds the error code and message. +// RPCErrorDetail holds the error code, message, and optional data. type RPCErrorDetail struct { Code int `json:"code"` Message string `json:"message"` + Data any `json:"data,omitempty"` } // NewJSONRPCError creates a new JSON-RPC error response. func NewJSONRPCError(id any, code int, message string) *JSONRPCError { + return NewJSONRPCErrorWithData(id, code, message, nil) +} + +// NewJSONRPCErrorWithData creates a JSON-RPC error response with a data +// payload (used by MCP for UnsupportedProtocolVersionError, etc.). +func NewJSONRPCErrorWithData(id any, code int, message string, data any) *JSONRPCError { return &JSONRPCError{ JSONRPC: "2.0", ID: id, Error: RPCErrorDetail{ Code: code, Message: message, + Data: data, }, } } diff --git a/gomcp/path.go b/gomcp/path.go new file mode 100644 index 0000000..b306462 --- /dev/null +++ b/gomcp/path.go @@ -0,0 +1,75 @@ +package gomcp + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// SafeJoin resolves userPath against root and returns an absolute path that +// is guaranteed to stay inside root. Path traversal (`..`), absolute inputs +// outside root, and symlink escapes are rejected. Empty userPath names the +// root itself. +// +// Use this when a tool argument is a filesystem path supplied by a model. +// +// SafeJoin is a check, not an open: a concurrent writer can still replace a +// path component between this call and a later os.Open. For hostile trees, +// open with O_NOFOLLOW (or an openat walk) after joining. +func SafeJoin(root, userPath string) (string, error) { + if strings.ContainsRune(userPath, 0) { + return "", fmt.Errorf("invalid path") + } + + root = filepath.Clean(root) + if !filepath.IsAbs(root) { + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + root = abs + } + + var cleaned string + switch { + case userPath == "": + cleaned = root + case filepath.IsAbs(userPath): + // Keep the caller's absolute path only when it already sits + // inside root. Do not Join() it — on Unix Join(root, "/etc/passwd") + // becomes root+"/etc/passwd" and would hide the intent. + cleaned = filepath.Clean(userPath) + default: + cleaned = filepath.Clean(filepath.Join(root, userPath)) + } + + if err := underRoot(root, cleaned); err != nil { + return "", fmt.Errorf("path escapes root directory: %s", userPath) + } + + // Resolve existing symlinks so a link planted inside root cannot + // point the caller at /etc/passwd. A path that does not yet exist + // (write-to-create) is returned as cleaned after the prefix check. + if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { + if err := underRoot(root, resolved); err != nil { + return "", fmt.Errorf("path escapes root directory: %s", userPath) + } + return resolved, nil + } + + return cleaned, nil +} + +func underRoot(root, path string) error { + rel, err := filepath.Rel(root, path) + if err != nil { + return errEscapesRoot + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return errEscapesRoot + } + return nil +} + +var errEscapesRoot = fmt.Errorf("path escapes root") diff --git a/gomcp/path_test.go b/gomcp/path_test.go new file mode 100644 index 0000000..c562f89 --- /dev/null +++ b/gomcp/path_test.go @@ -0,0 +1,135 @@ +package gomcp + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSafeJoinStaysInsideRoot(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "ok.txt"), []byte("hi"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := SafeJoin(root, "ok.txt") + if err != nil { + t.Fatalf("SafeJoin: %v", err) + } + if got != filepath.Join(root, "ok.txt") { + t.Fatalf("got %q, want file inside root", got) + } +} + +func TestSafeJoinRejectsTraversal(t *testing.T) { + root := t.TempDir() + cases := []string{ + "..", + "../etc/passwd", + "foo/../../etc/passwd", + filepath.Join("..", "..", "etc", "passwd"), + } + // An absolute path that is not under root must also be rejected. + // On Unix Join(root, "/etc/passwd") becomes /etc/passwd. + cases = append(cases, "/etc/passwd") + + for _, p := range cases { + got, err := SafeJoin(root, p) + if err == nil { + t.Errorf("SafeJoin(%q) = %q, want error", p, got) + } + } +} + +func TestSafeJoinRejectsAdjacentPrefix(t *testing.T) { + // /tmp/project vs /tmp/project-evil — a naive HasPrefix check fails here. + base := t.TempDir() + root := filepath.Join(base, "project") + evil := filepath.Join(base, "project-evil") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(evil, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evil, "secret"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := SafeJoin(root, filepath.Join("..", "project-evil", "secret")) + if err == nil { + t.Fatalf("adjacent-prefix escape succeeded: %s", got) + } +} + +func TestSafeJoinRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + target := filepath.Join(outside, "secret") + if err := os.WriteFile(target, []byte("classified"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "leak") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + got, err := SafeJoin(root, "leak") + if err == nil { + t.Fatalf("symlink escape succeeded: %s", got) + } + if strings.Contains(got, "secret") { + t.Fatalf("escaped path leaked in return value: %s", got) + } +} + +func TestSafeJoinAbsoluteInsideRoot(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "ok.txt") + if err := os.WriteFile(target, []byte("hi"), 0o600); err != nil { + t.Fatal(err) + } + got, err := SafeJoin(root, target) + if err != nil { + t.Fatalf("absolute path inside root: %v", err) + } + if got != target { + t.Fatalf("got %q, want %q", got, target) + } +} + +func TestSafeJoinRootIsFilesystemRoot(t *testing.T) { + // filepath.Rel("/", "/etc/passwd") is "etc/passwd", not an escape. + // A naive root+separator prefix becomes "//" and rejects everything. + got, err := SafeJoin("/", "etc/passwd") + if err != nil { + t.Fatalf("SafeJoin(/, etc/passwd): %v", err) + } + if got != "/etc/passwd" && !strings.HasPrefix(got, "/etc/") { + // EvalSymlinks may resolve /etc/passwd; either form is inside /. + t.Fatalf("got %q, want a path under /", got) + } + + if _, err := SafeJoin("/tmp", "/etc/passwd"); err == nil { + t.Fatal("absolute path outside /tmp must be rejected") + } +} + +func TestSafeJoinEmptyIsRoot(t *testing.T) { + root := t.TempDir() + got, err := SafeJoin(root, "") + if err != nil { + t.Fatal(err) + } + if got != root { + t.Fatalf("got %q, want root %q", got, root) + } +} + +func TestSafeJoinRejectsNUL(t *testing.T) { + root := t.TempDir() + if _, err := SafeJoin(root, "foo\x00bar"); err == nil { + t.Fatal("expected error for NUL in path") + } +} diff --git a/gomcp/protocol.go b/gomcp/protocol.go new file mode 100644 index 0000000..f5bacb9 --- /dev/null +++ b/gomcp/protocol.go @@ -0,0 +1,162 @@ +package gomcp + +import ( + "encoding/json" + "errors" + "strconv" +) + +// Published MCP protocol versions this server can speak. Newest first — +// server/discover advertises them in this order so clients can pick the +// highest mutually supported revision. +const ( + ProtocolVersion20260728 = "2026-07-28" + ProtocolVersion20251125 = "2025-11-25" + ProtocolVersion20250326 = "2025-03-26" + ProtocolVersion20241105 = "2024-11-05" +) + +// DefaultProtocolVersion is the MCP protocol version this server speaks +// when the client does not request a specific revision. +const DefaultProtocolVersion = ProtocolVersion20260728 + +// SupportedProtocolVersions is the set of protocol revisions this server +// can negotiate. 2026-07-28 is the current spec; older dates remain so +// existing clients that still send initialize keep working. +var SupportedProtocolVersions = []string{ + ProtocolVersion20260728, + ProtocolVersion20251125, + ProtocolVersion20250326, + ProtocolVersion20241105, +} + +// JSON-RPC / MCP application error codes. +const ( + // ErrCodeParse is JSON-RPC -32700 (broken JSON). + ErrCodeParse = -32700 + // ErrCodeInvalidRequest is JSON-RPC -32600. + ErrCodeInvalidRequest = -32600 + // ErrCodeMethodNotFound is JSON-RPC -32601. + ErrCodeMethodNotFound = -32601 + // ErrCodeInvalidParams is JSON-RPC -32602. + ErrCodeInvalidParams = -32602 + // ErrCodeApplication is an implementation-defined server error. + ErrCodeApplication = -32000 + // ErrCodeUnsupportedProtocolVersion is MCP -32022 (SEP-2575). + ErrCodeUnsupportedProtocolVersion = -32022 +) + +// DefaultListTTLMs is the cache freshness hint (SEP-2549) advertised on +// list and discover results when Server.ListTTLMs is unset. +const DefaultListTTLMs int64 = 60_000 + +const ( + resultTypeComplete = "complete" + cacheScopePublic = "public" + cacheScopePrivate = "private" +) + +// protocolVersionFromParams reads the version a client declared. 2026-07-28 +// clients put it in params._meta; initialize (legacy) puts it at the top +// level of params. An empty string means the client did not declare one. +func protocolVersionFromParams(params json.RawMessage) string { + if ver := metaProtocolVersion(params); ver != "" { + return ver + } + if len(params) == 0 { + return "" + } + var envelope struct { + ProtocolVersion string `json:"protocolVersion"` + } + if err := json.Unmarshal(params, &envelope); err != nil { + return "" + } + return envelope.ProtocolVersion +} + +// metaProtocolVersion reads only the 2026-07-28 per-request _meta version. +// A missing key is not an error — legacy clients omit _meta entirely. +func metaProtocolVersion(params json.RawMessage) string { + if len(params) == 0 { + return "" + } + var envelope struct { + Meta struct { + ProtocolVersion string `json:"io.modelcontextprotocol/protocolVersion"` + } `json:"_meta"` + } + if err := json.Unmarshal(params, &envelope); err != nil { + return "" + } + return envelope.Meta.ProtocolVersion +} + +// cursorFromParams reads the optional pagination cursor from list params. +func cursorFromParams(params json.RawMessage) string { + if len(params) == 0 { + return "" + } + var envelope struct { + Cursor string `json:"cursor"` + } + if err := json.Unmarshal(params, &envelope); err != nil { + return "" + } + return envelope.Cursor +} + +// isSupportedProtocolVersion reports whether v is one of the revisions +// this server can speak. +func isSupportedProtocolVersion(v string) bool { + for _, s := range SupportedProtocolVersions { + if s == v { + return true + } + } + return false +} + +// negotiateProtocolVersion picks the version the server will use for a +// legacy initialize handshake. A supported client version is echoed so +// older clients keep seeing the revision they asked for. Anything else +// (empty or unknown) falls back to the server's configured default. +func negotiateProtocolVersion(requested, fallback string) string { + if isSupportedProtocolVersion(requested) { + return requested + } + if fallback != "" { + return fallback + } + return DefaultProtocolVersion +} + +// speaks2026 reports whether this request is using the 2026-07-28 wire +// shape. Legacy clients omit _meta entirely; their responses stay in the +// pre-2026 shape so extra fields cannot trip a strict decoder. +func speaks2026(params json.RawMessage) bool { + return metaProtocolVersion(params) == ProtocolVersion20260728 +} + +// paginate returns items[start:end] and an opaque next cursor. pageSize +// <= 0 means "return the rest" (the historical one-shot list). An empty +// or missing cursor starts at 0. A cursor that is not a decimal offset +// in [0, len(items)] is rejected. +func paginate[T any](items []T, cursor string, pageSize int) (page []T, next string, err error) { + start := 0 + if cursor != "" { + n, perr := strconv.Atoi(cursor) + if perr != nil || n < 0 || n > len(items) { + return nil, "", errInvalidCursor + } + start = n + } + rest := len(items) - start + if pageSize <= 0 || pageSize >= rest { + return items[start:], "", nil + } + end := start + pageSize + return items[start:end], strconv.Itoa(end), nil +} + +var errInvalidCursor = errors.New("invalid cursor") diff --git a/gomcp/protocol_test.go b/gomcp/protocol_test.go new file mode 100644 index 0000000..704f972 --- /dev/null +++ b/gomcp/protocol_test.go @@ -0,0 +1,699 @@ +package gomcp + +import ( + "context" + "encoding/json" + "io" + "strings" + "testing" + "time" +) + +func modernMeta() string { + return `{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}` +} + +func TestServerDiscover(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.SetInstructions("Use tools to greet people.") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":"discover-1","method":"server/discover","params":` + modernMeta() + `}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + result := resp["result"].(map[string]any) + if result["resultType"] != "complete" { + t.Errorf("resultType = %v, want complete", result["resultType"]) + } + versions, _ := result["supportedVersions"].([]any) + found := false + for _, v := range versions { + if v == ProtocolVersion20260728 { + found = true + } + } + if !found { + t.Errorf("supportedVersions missing 2026-07-28: %v", versions) + } + if result["instructions"] != "Use tools to greet people." { + t.Errorf("instructions = %v", result["instructions"]) + } + if _, ok := result["ttlMs"]; !ok { + t.Error("discover result missing ttlMs") + } + if result["cacheScope"] != "public" { + t.Errorf("cacheScope = %v, want public", result["cacheScope"]) + } + meta := result["_meta"].(map[string]any) + info := meta["io.modelcontextprotocol/serverInfo"].(map[string]any) + if info["name"] != "test-server" { + t.Errorf("serverInfo.name = %v", info["name"]) + } + + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestServerDiscoverWithoutMeta(t *testing.T) { + // stdio compatibility probe: a client that does not yet know our + // version must still get a discover response. + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"server/discover"}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := resp["result"]; !ok { + t.Fatalf("discover without _meta must succeed, got %v", resp) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestUnsupportedProtocolVersion(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"1999-01-01"}}}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + errObj := resp["error"].(map[string]any) + if errObj["code"].(float64) != ErrCodeUnsupportedProtocolVersion { + t.Errorf("code = %v, want %d", errObj["code"], ErrCodeUnsupportedProtocolVersion) + } + data := errObj["data"].(map[string]any) + if data["requested"] != "1999-01-01" { + t.Errorf("requested = %v", data["requested"]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestInitializeNegotiatesLegacyVersion(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + result := resp["result"].(map[string]any) + if result["protocolVersion"] != ProtocolVersion20241105 { + t.Errorf("protocolVersion = %v, want %s", result["protocolVersion"], ProtocolVersion20241105) + } + // Legacy initialize must not grow 2026-only fields. + if _, ok := result["resultType"]; ok { + t.Error("initialize result must stay in the pre-2026 shape") + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestModernToolsListHasCacheHints(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.AddTool(Tool{Name: "zeta", Description: "z"}) + srv.AddTool(Tool{Name: "alpha", Description: "a", Title: "Alpha Tool", + Annotations: &ToolAnnotations{ReadOnlyHint: true}}) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":` + modernMeta() + `}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + result := resp["result"].(map[string]any) + if result["resultType"] != "complete" { + t.Errorf("resultType = %v", result["resultType"]) + } + if result["ttlMs"].(float64) != float64(DefaultListTTLMs) { + t.Errorf("ttlMs = %v", result["ttlMs"]) + } + tools := result["tools"].([]any) + if len(tools) != 2 { + t.Fatalf("tools = %d", len(tools)) + } + // Deterministic order by name. + if tools[0].(map[string]any)["name"] != "alpha" { + t.Errorf("first tool = %v, want alpha", tools[0]) + } + if tools[1].(map[string]any)["name"] != "zeta" { + t.Errorf("second tool = %v, want zeta", tools[1]) + } + if tools[0].(map[string]any)["title"] != "Alpha Tool" { + t.Errorf("title = %v", tools[0].(map[string]any)["title"]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestLegacyToolsListOmitsModernFields(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.AddTool(Tool{Name: "greet"}) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + // No initialize, no _meta — the 2026-07-28 stateless path, but + // a legacy client that skipped the version field. + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + result := resp["result"].(map[string]any) + if _, ok := result["resultType"]; ok { + t.Error("legacy tools/list must omit resultType") + } + if _, ok := result["ttlMs"]; ok { + t.Error("legacy tools/list must omit ttlMs") + } + if _, ok := result["_meta"]; ok { + t.Error("legacy tools/list must omit _meta") + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestToolsListWithoutInitialize(t *testing.T) { + // 2026-07-28 dropped the required handshake. tools/list must work + // without a prior initialize. + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.AddTool(Tool{Name: "greet"}) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := resp["error"]; ok { + t.Fatalf("tools/list without initialize must succeed, got %v", resp) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestListPagination(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.ListPageSize = 1 + srv.AddTool(Tool{Name: "a"}) + srv.AddTool(Tool{Name: "b"}) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"cursor":"1"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"nope"}}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 3) + page1 := msgs[0]["result"].(map[string]any) + tools1 := page1["tools"].([]any) + if len(tools1) != 1 || tools1[0].(map[string]any)["name"] != "a" { + t.Errorf("page 1 = %v", tools1) + } + if page1["nextCursor"] != "1" { + t.Errorf("nextCursor = %v, want 1", page1["nextCursor"]) + } + + page2 := msgs[1]["result"].(map[string]any) + tools2 := page2["tools"].([]any) + if len(tools2) != 1 || tools2[0].(map[string]any)["name"] != "b" { + t.Errorf("page 2 = %v", tools2) + } + if _, ok := page2["nextCursor"]; ok { + t.Errorf("last page must omit nextCursor, got %v", page2["nextCursor"]) + } + + errObj := msgs[2]["error"].(map[string]any) + if errObj["code"].(float64) != ErrCodeInvalidParams { + t.Errorf("bad cursor code = %v", errObj["code"]) + } + + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestResourceTemplatesList(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"resources/templates/list"}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + result := resp["result"].(map[string]any) + templates := result["resourceTemplates"].([]any) + if len(templates) != 0 { + t.Errorf("expected empty templates, got %v", templates) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestHandlerPanicKeepsServing(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.AddTool(Tool{ + Name: "boom", + Handler: func(ctx context.Context, args map[string]any) (string, error) { + panic("kaboom") + }, + }) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"boom"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"ping"}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 2) + result := msgs[0]["result"].(map[string]any) + if result["isError"] != true { + t.Error("expected isError after panic") + } + text := result["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "panicked") { + t.Errorf("error text = %q", text) + } + if strings.Contains(text, "kaboom") { + t.Errorf("panic value must not leak to the client: %q", text) + } + if msgs[1]["id"].(float64) != 2 { + t.Errorf("server did not survive handler panic: %v", msgs[1]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestNilHandlerAndNilArguments(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.AddTool(Tool{ + Name: "echo", + Handler: func(ctx context.Context, args map[string]any) (string, error) { + if args == nil { + t.Error("arguments must not be nil") + } + return "ok", nil + }, + }) + srv.AddTool(Tool{Name: "nohandler"}) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"nohandler"}}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 2) + text := msgs[0]["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if text != "ok" { + t.Errorf("echo = %q", text) + } + errText := msgs[1]["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(errText, "no handler") { + t.Errorf("nil handler text = %q", errText) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestHandlerTimeout(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.HandlerTimeout = 20 * time.Millisecond + srv.AddTool(Tool{ + Name: "slow", + Handler: func(ctx context.Context, args map[string]any) (string, error) { + <-ctx.Done() + return "", ctx.Err() + }, + }) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"slow"}}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + result := resp["result"].(map[string]any) + if result["isError"] != true { + t.Error("expected timeout to surface as isError") + } + text := result["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "deadline") && !strings.Contains(text, "canceled") { + t.Errorf("timeout text = %q", text) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestInvalidJSONRPCRejected(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"1.0","id":1,"method":"ping"}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 3) + if msgs[0]["error"].(map[string]any)["code"].(float64) != ErrCodeInvalidRequest { + t.Errorf("jsonrpc 1.0: %v", msgs[0]) + } + if msgs[1]["error"].(map[string]any)["code"].(float64) != ErrCodeInvalidRequest { + t.Errorf("missing method: %v", msgs[1]) + } + if _, ok := msgs[2]["result"]; !ok { + t.Errorf("valid ping after rejects: %v", msgs[2]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestConcurrentRegisterAndList(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + // Register while the server is serving. The race detector must stay quiet. + go func() { + for i := 0; i < 50; i++ { + srv.AddTool(Tool{Name: "t", Handler: func(ctx context.Context, args map[string]any) (string, error) { + return "ok", nil + }}) + } + }() + + go func() { + for i := 0; i < 20; i++ { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n")) + } + inWriter.Close() + }() + + dec := json.NewDecoder(outReader) + for i := 0; i < 20; i++ { + var resp map[string]any + if err := dec.Decode(&resp); err != nil { + t.Fatalf("decode %d: %v", i, err) + } + if _, ok := resp["result"]; !ok { + t.Fatalf("list %d failed: %v", i, resp) + } + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestModernToolsCallDecoratesResult(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.CacheScope = "private" + srv.ListTTLMs = -1 + srv.AddTool(Tool{ + Name: "echo", + Handler: func(ctx context.Context, args map[string]any) (string, error) { + return "hi", nil + }, + }) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":` + modernMeta() + `}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 2) + call := msgs[0]["result"].(map[string]any) + if call["resultType"] != "complete" { + t.Errorf("tools/call resultType = %v", call["resultType"]) + } + if call["_meta"] == nil { + t.Error("tools/call missing _meta") + } + list := msgs[1]["result"].(map[string]any) + if list["ttlMs"].(float64) != 0 { + t.Errorf("negative ListTTLMs should send ttlMs 0, got %v", list["ttlMs"]) + } + if list["cacheScope"] != "private" { + t.Errorf("cacheScope = %v", list["cacheScope"]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestInitializedAndCancelledWithID(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialized"}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"notifications/cancelled","params":{"requestId":1}}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 2) + if _, ok := msgs[0]["result"]; !ok { + t.Errorf("initialized with id must be answered: %v", msgs[0]) + } + if _, ok := msgs[1]["result"]; !ok { + t.Errorf("cancelled with id must be answered: %v", msgs[1]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestResourceAndPromptPanicRecovery(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.AddResource(Resource{ + URI: "file:///boom", + Name: "Boom", + Handler: func(ctx context.Context) (string, error) { + panic("resource boom") + }, + }) + srv.AddResource(Resource{URI: "file:///empty", Name: "Empty"}) + srv.AddPrompt(Prompt{ + Name: "boom", + Handler: func(ctx context.Context, args map[string]any) ([]PromptMessage, error) { + panic("prompt boom") + }, + }) + srv.AddPrompt(Prompt{Name: "empty"}) + done := make(chan error, 1) + go func() { done <- srv.RunWithIO(inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///boom"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"file:///empty"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"boom"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":4,"method":"prompts/get","params":{"name":"empty"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":5,"method":"resources/list","params":{"cursor":"bad"}}` + "\n")) + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":6,"method":"prompts/list","params":{"cursor":"bad"}}` + "\n")) + inWriter.Close() + }() + + msgs := readResponses(t, outReader, 6) + for i, want := range []string{"resource handler panicked", "no handler", "prompt handler panicked", "no handler"} { + msg := msgs[i]["error"].(map[string]any)["message"].(string) + if !strings.Contains(msg, want) { + t.Errorf("msg %d = %q, want %q", i+1, msg, want) + } + } + if msgs[4]["error"].(map[string]any)["code"].(float64) != ErrCodeInvalidParams { + t.Errorf("resources/list bad cursor: %v", msgs[4]) + } + if msgs[5]["error"].(map[string]any)["code"].(float64) != ErrCodeInvalidParams { + t.Errorf("prompts/list bad cursor: %v", msgs[5]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIO: %v", err) + } +} + +func TestRunContextCancel(t *testing.T) { + srv := NewServer("test-server", "1.0.0") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := srv.RunWithIOContext(ctx, strings.NewReader(""), io.Discard); err == nil { + t.Fatal("expected cancelled context to stop RunWithIOContext") + } +} + +func TestSetProtocolVersionAndRunContextNil(t *testing.T) { + inReader, inWriter := io.Pipe() + outReader, outWriter := io.Pipe() + + srv := NewServer("test-server", "1.0.0") + srv.SetProtocolVersion(ProtocolVersion20250326) + done := make(chan error, 1) + go func() { done <- srv.RunWithIOContext(nil, inReader, outWriter) }() + + go func() { + inWriter.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}` + "\n")) + inWriter.Close() + }() + + var resp map[string]any + if err := json.NewDecoder(outReader).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + // Unknown initialize version falls back to the configured default. + if resp["result"].(map[string]any)["protocolVersion"] != ProtocolVersion20250326 { + t.Errorf("fallback version = %v", resp["result"]) + } + if err := <-done; err != nil { + t.Fatalf("RunWithIOContext: %v", err) + } +} + +func TestPaginate(t *testing.T) { + items := []string{"a", "b", "c"} + page, next, err := paginate(items, "", 2) + if err != nil || next != "2" || strings.Join(page, "") != "ab" { + t.Fatalf("first page: %v %q %v", page, next, err) + } + page, next, err = paginate(items, "2", 2) + if err != nil || next != "" || strings.Join(page, "") != "c" { + t.Fatalf("second page: %v %q %v", page, next, err) + } + if _, _, err := paginate(items, "x", 2); err == nil { + t.Fatal("expected invalid cursor") + } + if _, _, err := paginate(items, "9", 2); err == nil { + t.Fatal("expected cursor past end to fail") + } + // A huge page size must not overflow start+pageSize and panic. + page, next, err = paginate(items, "1", int(^uint(0)>>1)) + if err != nil || next != "" || strings.Join(page, "") != "bc" { + t.Fatalf("max page size: %v %q %v", page, next, err) + } +} diff --git a/gomcp/server.go b/gomcp/server.go index a374cae..3154ea0 100644 --- a/gomcp/server.go +++ b/gomcp/server.go @@ -17,12 +17,11 @@ import ( "fmt" "io" "os" + "sort" "sync" + "time" ) -// DefaultProtocolVersion is the MCP protocol version this server speaks. -const DefaultProtocolVersion = "2025-03-26" - // DefaultMaxRequestBytes caps a single inbound JSON-RPC message when // Server.MaxRequestBytes is unset. 10 MiB matches what a typical MCP client // accepts for a response — requests larger than that are almost certainly @@ -38,14 +37,15 @@ var errMessageTooLarge = errors.New("message exceeds maximum size") // It handles the MCP handshake and dispatches tools, resources, and prompts // to registered handlers. type Server struct { - name string - version string - protocolVer string - tools map[string]Tool - resources map[string]Resource - prompts map[string]Prompt - initialized bool - mu sync.Mutex + name string + version string + protocolVer string + instructions string + tools map[string]Tool + resources map[string]Resource + prompts map[string]Prompt + initialized bool + mu sync.RWMutex // MaxRequestBytes caps one inbound JSON-RPC message (one newline- // delimited line). Zero selects DefaultMaxRequestBytes; a negative value @@ -53,10 +53,39 @@ type Server struct { // exhaust memory). An oversized message is answered in-band with a // -32600 error (id null) and the dispatch loop keeps serving. MaxRequestBytes int64 + + // HandlerTimeout is a cooperative bound on a single tool, resource, + // or prompt handler. Zero disables it (historical behavior). The + // handler's context is cancelled when the deadline hits; a handler + // that ignores ctx (blocking syscall, busy loop) is not preempted + // and will still stall the sequential dispatch loop. + HandlerTimeout time.Duration + + // ListPageSize caps items returned by tools/list, resources/list, and + // prompts/list. Zero (default) returns the full list in one page — + // the historical behavior. When set, clients page with the opaque + // nextCursor from the previous response. + ListPageSize int + + // ListTTLMs is the cache freshness hint (milliseconds) on list and + // discover results for 2026-07-28 clients. Zero selects + // DefaultListTTLMs (60s). A negative value sends ttlMs: 0 (always stale). + ListTTLMs int64 + + // ReadTTLMs is the cache freshness hint on resources/read for + // 2026-07-28 clients. Zero (default) means immediately stale — + // resource content is often dynamic. + ReadTTLMs int64 + + // CacheScope is the SEP-2549 cache scope advertised on cacheable + // 2026-07-28 results. Empty selects "public". Use "private" when + // list or read results are caller-specific. + CacheScope string } // NewServer creates a new MCP server with the given name and version. -// These are reported to the client during the initialize handshake. +// These are reported to the client during the initialize handshake and +// on 2026-07-28 result _meta. func NewServer(name, version string) *Server { return &Server{ name: name, @@ -68,35 +97,61 @@ func NewServer(name, version string) *Server { } } -// SetProtocolVersion overrides the default MCP protocol version. +// SetProtocolVersion overrides the default MCP protocol version returned +// when a client does not request a specific supported revision. func (s *Server) SetProtocolVersion(v string) { s.mu.Lock() defer s.mu.Unlock() s.protocolVer = v } +// SetInstructions sets optional natural-language guidance returned by +// initialize and server/discover. Use it to tell the model how to use +// this server effectively. +func (s *Server) SetInstructions(text string) { + s.mu.Lock() + defer s.mu.Unlock() + s.instructions = text +} + // AddTool registers a tool with the server. Tools are callable functions -// that the AI client can invoke with arguments. +// that the AI client can invoke with arguments. Safe to call concurrently +// with request handling. func (s *Server) AddTool(tool Tool) { + s.mu.Lock() + defer s.mu.Unlock() s.tools[tool.Name] = tool } // AddResource registers a resource with the server. Resources are readable -// data sources identified by URI. +// data sources identified by URI. Safe to call concurrently with request +// handling. func (s *Server) AddResource(res Resource) { + s.mu.Lock() + defer s.mu.Unlock() s.resources[res.URI] = res } // AddPrompt registers a prompt template with the server. Prompts are -// pre-defined conversation templates. +// pre-defined conversation templates. Safe to call concurrently with +// request handling. func (s *Server) AddPrompt(prompt Prompt) { + s.mu.Lock() + defer s.mu.Unlock() s.prompts[prompt.Name] = prompt } // Run starts the MCP server using os.Stdin and os.Stdout. It blocks until // stdin closes. Errors are returned if reading or writing fails. func (s *Server) Run() error { - return s.RunWithIO(os.Stdin, os.Stdout) + return s.RunContext(context.Background()) +} + +// RunContext is Run with a caller-supplied context. Handler invocations +// inherit ctx; cancelling it cancels in-flight handlers. The read loop +// still returns on stdin EOF. +func (s *Server) RunContext(ctx context.Context) error { + return s.run(ctx, os.Stdin, os.Stdout) } // RunWithIO starts the MCP server with custom I/O readers and writers, @@ -109,20 +164,37 @@ func (s *Server) Run() error { // server keeps serving. RunWithIO returns only on clean EOF (nil), a read // failure on r, or a write failure on w. func (s *Server) RunWithIO(r io.Reader, w io.Writer) error { + return s.run(context.Background(), r, w) +} + +// RunWithIOContext is RunWithIO with a caller-supplied context. Handler +// invocations inherit ctx. +func (s *Server) RunWithIOContext(ctx context.Context, r io.Reader, w io.Writer) error { + return s.run(ctx, r, w) +} + +func (s *Server) run(ctx context.Context, r io.Reader, w io.Writer) error { + if ctx == nil { + ctx = context.Background() + } encoder := json.NewEncoder(w) br := bufio.NewReader(r) // lineBuf is reused across messages. Handlers run synchronously before // the next read, so nothing retains a reference to it across iterations. var lineBuf []byte - s.mu.Lock() + s.mu.RLock() maxReq := s.MaxRequestBytes - s.mu.Unlock() + s.mu.RUnlock() if maxReq == 0 { maxReq = DefaultMaxRequestBytes } for { + if err := ctx.Err(); err != nil { + return err + } + line, err := readMessage(br, lineBuf, maxReq) if err != nil { if err == io.EOF { @@ -132,7 +204,7 @@ func (s *Server) RunWithIO(r io.Reader, w io.Writer) error { // Bad input never kills the loop: answer in-band and keep // serving. The oversized line is unparseable by definition, // so the response carries a null id. - if werr := encoder.Encode(NewJSONRPCError(nil, -32600, + if werr := encoder.Encode(NewJSONRPCError(nil, ErrCodeInvalidRequest, fmt.Sprintf("Invalid Request: message exceeds maximum size of %d bytes", maxReq))); werr != nil { return fmt.Errorf("write error response: %w", werr) } @@ -155,40 +227,82 @@ func (s *Server) RunWithIO(r io.Reader, w io.Writer) error { continue } - // Notifications have no ID — we silently consume them + // Notifications have no ID — consume them (including the legacy + // initialized notice) and do not write a response. if req.ID == nil { + s.handleNotification(req) continue } + if req.JSONRPC != "2.0" || req.Method == "" { + if werr := encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidRequest, "Invalid Request")); werr != nil { + return fmt.Errorf("write error response: %w", werr) + } + continue + } + + // 2026-07-28 clients declare a version in params._meta. An + // unknown _meta version is a hard error except on server/discover, + // which is the compatibility probe that advertises what we speak. + // The legacy initialize.protocolVersion field is negotiated, not + // rejected — older clients send whatever they speak and expect a + // successful handshake back. + if req.Method != "server/discover" { + if ver := metaProtocolVersion(req.Params); ver != "" && !isSupportedProtocolVersion(ver) { + if werr := encoder.Encode(unsupportedVersionError(req.ID, ver)); werr != nil { + return fmt.Errorf("write error response: %w", werr) + } + continue + } + } + var respErr error switch req.Method { case "initialize": respErr = s.handleInitialize(req, encoder) - case "initialized": - // Notification — silently mark as initialized - s.mu.Lock() - s.initialized = true - s.mu.Unlock() + case "initialized", "notifications/initialized": + // A client that attaches an id to the initialized notice + // is treating it as a request — answer so it is not left + // hanging. The notice itself is still recorded. + s.handleNotification(req) + respErr = encoder.Encode(JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: emptyObject{}, + }) + case "notifications/cancelled": + // Sequential dispatch: the named request has already + // finished by the time this line is read. Acknowledge + // so a mis-framed notice-with-id does not hang. + respErr = encoder.Encode(JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: emptyObject{}, + }) case "ping": respErr = encoder.Encode(JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, Result: emptyObject{}, }) + case "server/discover": + respErr = s.handleDiscover(req, encoder) case "tools/list": - respErr = s.handleToolsList(req, encoder) + respErr = s.handleToolsList(ctx, req, encoder) case "tools/call": - respErr = s.handleToolsCall(req, encoder) + respErr = s.handleToolsCall(ctx, req, encoder) case "resources/list": respErr = s.handleResourcesList(req, encoder) case "resources/read": - respErr = s.handleResourcesRead(req, encoder) + respErr = s.handleResourcesRead(ctx, req, encoder) + case "resources/templates/list": + respErr = s.handleResourceTemplatesList(req, encoder) case "prompts/list": respErr = s.handlePromptsList(req, encoder) case "prompts/get": - respErr = s.handlePromptsGet(req, encoder) + respErr = s.handlePromptsGet(ctx, req, encoder) default: - errResp := NewJSONRPCError(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method)) + errResp := NewJSONRPCError(req.ID, ErrCodeMethodNotFound, fmt.Sprintf("Method not found: %s", req.Method)) if err := encoder.Encode(errResp); err != nil { return fmt.Errorf("write error response: %w", err) } @@ -200,6 +314,24 @@ func (s *Server) RunWithIO(r io.Reader, w io.Writer) error { } } +func (s *Server) handleNotification(req JSONRPCRequest) { + switch req.Method { + case "initialized", "notifications/initialized": + s.mu.Lock() + s.initialized = true + s.mu.Unlock() + } +} + +func unsupportedVersionError(id any, requested string) *JSONRPCError { + return NewJSONRPCErrorWithData(id, ErrCodeUnsupportedProtocolVersion, + fmt.Sprintf("Unsupported protocol version: %s", requested), + map[string]any{ + "supported": SupportedProtocolVersions, + "requested": requested, + }) +} + // readMessage reads one newline-terminated message from br into buf, // returning the bytes without the trailing newline. A final message not // terminated by newline is still returned at EOF; a subsequent call then @@ -241,52 +373,189 @@ func readMessage(br *bufio.Reader, buf []byte, max int64) ([]byte, error) { // well-formed JSON that cannot be a Request object. The id is null — the // request was never understood, so there is nothing to correlate against. func writeDecodeError(encoder *json.Encoder, derr error) error { - code, message := -32700, "Parse error" + code, message := ErrCodeParse, "Parse error" var typeErr *json.UnmarshalTypeError if errors.As(derr, &typeErr) { - code, message = -32600, "Invalid Request" + code, message = ErrCodeInvalidRequest, "Invalid Request" } return encoder.Encode(NewJSONRPCError(nil, code, message)) } -// handleInitialize responds to the MCP initialize handshake. +func (s *Server) snapshotInfo() (name, version, proto, instructions string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.name, s.version, s.protocolVer, s.instructions +} + +func (s *Server) resultMeta() *resultMeta { + name, version, _, _ := s.snapshotInfo() + return &resultMeta{ServerInfo: serverInfo{Name: name, Version: version}} +} + +func (s *Server) cacheScope() string { + s.mu.RLock() + scope := s.CacheScope + s.mu.RUnlock() + if scope == cacheScopePrivate { + return cacheScopePrivate + } + return cacheScopePublic +} + +func (s *Server) listTTL() int64 { + s.mu.RLock() + ttl := s.ListTTLMs + s.mu.RUnlock() + if ttl < 0 { + return 0 + } + if ttl == 0 { + return DefaultListTTLMs + } + return ttl +} + +func (s *Server) readTTL() int64 { + s.mu.RLock() + defer s.mu.RUnlock() + if s.ReadTTLMs < 0 { + return 0 + } + return s.ReadTTLMs +} + +func (s *Server) listPageSize() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ListPageSize +} + +// decorateCacheable fills 2026-07-28 cache / resultType / _meta fields. +// Legacy clients keep the pre-2026 shape (all of these stay empty/nil). +func (s *Server) decorateCacheable(params json.RawMessage, ttl int64) (resultType string, ttlMs *int64, scope string, meta *resultMeta) { + if !speaks2026(params) { + return "", nil, "", nil + } + t := ttl + return resultTypeComplete, &t, s.cacheScope(), s.resultMeta() +} + +func (s *Server) decorateResult(params json.RawMessage) (resultType string, meta *resultMeta) { + if !speaks2026(params) { + return "", nil + } + return resultTypeComplete, s.resultMeta() +} + +func (s *Server) capabilities() serverCapabilities { + return serverCapabilities{ + Tools: emptyObject{}, + Resources: emptyObject{}, + Prompts: emptyObject{}, + } +} + +func (s *Server) handlerContext(parent context.Context, override time.Duration) (context.Context, context.CancelFunc) { + s.mu.RLock() + timeout := s.HandlerTimeout + s.mu.RUnlock() + if override < 0 { + timeout = 0 + } else if override > 0 { + timeout = override + } + if timeout > 0 { + return context.WithTimeout(parent, timeout) + } + return context.WithCancel(parent) +} + +// handleInitialize responds to the legacy MCP initialize handshake. +// 2026-07-28 made this optional; it is kept so existing clients continue +// to connect. The client's protocolVersion is echoed when we support it. func (s *Server) handleInitialize(req JSONRPCRequest, encoder *json.Encoder) error { + var params struct { + ProtocolVersion string `json:"protocolVersion"` + } + _ = json.Unmarshal(req.Params, ¶ms) + s.mu.Lock() s.initialized = true - ver := s.protocolVer + fallback := s.protocolVer + name := s.name + version := s.version + instructions := s.instructions s.mu.Unlock() + ver := negotiateProtocolVersion(params.ProtocolVersion, fallback) + resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, Result: initializeResult{ ProtocolVersion: ver, ServerInfo: serverInfo{ - Name: s.name, - Version: s.version, + Name: name, + Version: version, }, + Capabilities: s.capabilities(), + Instructions: instructions, }, } return encoder.Encode(resp) } -// handleToolsList returns metadata for all registered tools. -func (s *Server) handleToolsList(req JSONRPCRequest, encoder *json.Encoder) error { - s.mu.Lock() - init := s.initialized - s.mu.Unlock() - if !init { - return encoder.Encode(NewJSONRPCError(req.ID, -32600, "Not initialized")) +// handleDiscover responds to server/discover (2026-07-28). Always succeeds: +// this is the stdio backward-compatibility probe and must not reject a +// client that has not yet chosen a version. +func (s *Server) handleDiscover(req JSONRPCRequest, encoder *json.Encoder) error { + _, _, _, instructions := s.snapshotInfo() + ttl := s.listTTL() + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: discoverResult{ + ResultType: resultTypeComplete, + SupportedVersions: append([]string(nil), SupportedProtocolVersions...), + Capabilities: s.capabilities(), + Instructions: instructions, + TTLMs: ttl, + CacheScope: s.cacheScope(), + Meta: *s.resultMeta(), + }, } + return encoder.Encode(resp) +} +// handleToolsList returns metadata for all registered tools. +func (s *Server) handleToolsList(ctx context.Context, req JSONRPCRequest, encoder *json.Encoder) error { + _ = ctx + s.mu.RLock() toolList := make([]Tool, 0, len(s.tools)) for _, tool := range s.tools { toolList = append(toolList, tool) } + s.mu.RUnlock() + + sort.Slice(toolList, func(i, j int) bool { return toolList[i].Name < toolList[j].Name }) + + page, next, err := paginate(toolList, cursorFromParams(req.Params), s.listPageSize()) + if err != nil { + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, "Invalid cursor")) + } + + rt, ttl, scope, meta := s.decorateCacheable(req.Params, s.listTTL()) resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, - Result: toolsListResult{Tools: toolList}, + Result: toolsListResult{ + Tools: page, + NextCursor: next, + ResultType: rt, + TTLMs: ttl, + CacheScope: scope, + Meta: meta, + }, } return encoder.Encode(resp) } @@ -298,23 +567,22 @@ type toolsCallParams struct { } // handleToolsCall dispatches a tool call to the registered handler. -func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) error { - s.mu.Lock() - init := s.initialized - s.mu.Unlock() - if !init { - return encoder.Encode(NewJSONRPCError(req.ID, -32600, "Not initialized")) - } - +func (s *Server) handleToolsCall(ctx context.Context, req JSONRPCRequest, encoder *json.Encoder) error { var params toolsCallParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - errResp := NewJSONRPCError(req.ID, -32602, "Invalid params") - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, "Invalid params")) + } + if params.Arguments == nil { + params.Arguments = map[string]any{} } + s.mu.RLock() tool, ok := s.tools[params.Name] + s.mu.RUnlock() if !ok { - // Return in-band error per MCP convention + // Return in-band error per MCP convention (kept for compatibility + // with existing clients; 2026 also allows -32602 here). + rt, meta := s.decorateResult(req.Params) resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, @@ -322,16 +590,17 @@ func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) erro Content: []textContent{ {Type: "text", Text: fmt.Sprintf("Unknown tool: %s", params.Name)}, }, - IsError: true, + IsError: true, + ResultType: rt, + Meta: meta, }, } return encoder.Encode(resp) } - ctx := context.Background() - result, err := tool.Handler(ctx, params.Arguments) + result, err := s.invokeTool(ctx, tool, params.Arguments) + rt, meta := s.decorateResult(req.Params) if err != nil { - // Return in-band error per MCP convention resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, @@ -339,7 +608,9 @@ func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) erro Content: []textContent{ {Type: "text", Text: fmt.Sprintf("Error: %v", err)}, }, - IsError: true, + IsError: true, + ResultType: rt, + Meta: meta, }, } return encoder.Encode(resp) @@ -352,48 +623,98 @@ func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) erro Content: []textContent{ {Type: "text", Text: result}, }, + ResultType: rt, + Meta: meta, }, } return encoder.Encode(resp) } +func (s *Server) invokeTool(ctx context.Context, tool Tool, args map[string]any) (result string, err error) { + defer func() { + if rec := recover(); rec != nil { + fmt.Fprintf(os.Stderr, "gomcp: tool %q panicked: %v\n", tool.Name, rec) + err = fmt.Errorf("tool handler panicked") + } + }() + if tool.Handler == nil { + return "", fmt.Errorf("tool has no handler") + } + hctx, cancel := s.handlerContext(ctx, tool.Timeout) + defer cancel() + return tool.Handler(hctx, args) +} + // handleResourcesList returns metadata for all registered resources. func (s *Server) handleResourcesList(req JSONRPCRequest, encoder *json.Encoder) error { + s.mu.RLock() resourceList := make([]Resource, 0, len(s.resources)) for _, res := range s.resources { resourceList = append(resourceList, res) } + s.mu.RUnlock() + + sort.Slice(resourceList, func(i, j int) bool { return resourceList[i].URI < resourceList[j].URI }) + + page, next, err := paginate(resourceList, cursorFromParams(req.Params), s.listPageSize()) + if err != nil { + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, "Invalid cursor")) + } + + rt, ttl, scope, meta := s.decorateCacheable(req.Params, s.listTTL()) + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: resourcesListResult{ + Resources: page, + NextCursor: next, + ResultType: rt, + TTLMs: ttl, + CacheScope: scope, + Meta: meta, + }, + } + return encoder.Encode(resp) +} + +func (s *Server) handleResourceTemplatesList(req JSONRPCRequest, encoder *json.Encoder) error { + rt, ttl, scope, meta := s.decorateCacheable(req.Params, s.listTTL()) resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, - Result: resourcesListResult{Resources: resourceList}, + Result: resourceTemplatesListResult{ + ResourceTemplates: []resourceTemplate{}, + ResultType: rt, + TTLMs: ttl, + CacheScope: scope, + Meta: meta, + }, } return encoder.Encode(resp) } // handleResourcesRead reads a registered resource by URI and returns its content. -func (s *Server) handleResourcesRead(req JSONRPCRequest, encoder *json.Encoder) error { +func (s *Server) handleResourcesRead(ctx context.Context, req JSONRPCRequest, encoder *json.Encoder) error { var params struct { URI string `json:"uri"` } if err := json.Unmarshal(req.Params, ¶ms); err != nil { - errResp := NewJSONRPCError(req.ID, -32602, "Invalid params") - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, "Invalid params")) } + s.mu.RLock() res, ok := s.resources[params.URI] + s.mu.RUnlock() if !ok { - errResp := NewJSONRPCError(req.ID, -32602, fmt.Sprintf("Unknown resource: %s", params.URI)) - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, fmt.Sprintf("Unknown resource: %s", params.URI))) } - ctx := context.Background() - content, err := res.Handler(ctx) + content, err := s.invokeResource(ctx, res) if err != nil { - errResp := NewJSONRPCError(req.ID, -32000, err.Error()) - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeApplication, err.Error())) } + rt, ttl, scope, meta := s.decorateCacheable(req.Params, s.readTTL()) resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, @@ -405,53 +726,111 @@ func (s *Server) handleResourcesRead(req JSONRPCRequest, encoder *json.Encoder) Text: content, }, }, + ResultType: rt, + TTLMs: ttl, + CacheScope: scope, + Meta: meta, }, } return encoder.Encode(resp) } +func (s *Server) invokeResource(ctx context.Context, res Resource) (content string, err error) { + defer func() { + if rec := recover(); rec != nil { + fmt.Fprintf(os.Stderr, "gomcp: resource %q panicked: %v\n", res.URI, rec) + err = fmt.Errorf("resource handler panicked") + } + }() + if res.Handler == nil { + return "", fmt.Errorf("resource has no handler") + } + hctx, cancel := s.handlerContext(ctx, res.Timeout) + defer cancel() + return res.Handler(hctx) +} + // handlePromptsList returns metadata for all registered prompts. func (s *Server) handlePromptsList(req JSONRPCRequest, encoder *json.Encoder) error { + s.mu.RLock() promptList := make([]Prompt, 0, len(s.prompts)) for _, p := range s.prompts { promptList = append(promptList, p) } + s.mu.RUnlock() + + sort.Slice(promptList, func(i, j int) bool { return promptList[i].Name < promptList[j].Name }) + + page, next, err := paginate(promptList, cursorFromParams(req.Params), s.listPageSize()) + if err != nil { + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, "Invalid cursor")) + } + + rt, ttl, scope, meta := s.decorateCacheable(req.Params, s.listTTL()) resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, - Result: promptsListResult{Prompts: promptList}, + Result: promptsListResult{ + Prompts: page, + NextCursor: next, + ResultType: rt, + TTLMs: ttl, + CacheScope: scope, + Meta: meta, + }, } return encoder.Encode(resp) } // handlePromptsGet builds and returns a prompt from the registered handler. -func (s *Server) handlePromptsGet(req JSONRPCRequest, encoder *json.Encoder) error { +func (s *Server) handlePromptsGet(ctx context.Context, req JSONRPCRequest, encoder *json.Encoder) error { var params struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } if err := json.Unmarshal(req.Params, ¶ms); err != nil { - errResp := NewJSONRPCError(req.ID, -32602, "Invalid params") - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, "Invalid params")) + } + if params.Arguments == nil { + params.Arguments = map[string]any{} } + s.mu.RLock() prompt, ok := s.prompts[params.Name] + s.mu.RUnlock() if !ok { - errResp := NewJSONRPCError(req.ID, -32602, fmt.Sprintf("Unknown prompt: %s", params.Name)) - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeInvalidParams, fmt.Sprintf("Unknown prompt: %s", params.Name))) } - ctx := context.Background() - messages, err := prompt.Handler(ctx, params.Arguments) + messages, err := s.invokePrompt(ctx, prompt, params.Arguments) if err != nil { - errResp := NewJSONRPCError(req.ID, -32000, err.Error()) - return encoder.Encode(errResp) + return encoder.Encode(NewJSONRPCError(req.ID, ErrCodeApplication, err.Error())) } + rt, meta := s.decorateResult(req.Params) resp := JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, - Result: promptsGetResult{Messages: messages}, + Result: promptsGetResult{ + Messages: messages, + ResultType: rt, + Meta: meta, + }, } return encoder.Encode(resp) } + +func (s *Server) invokePrompt(ctx context.Context, prompt Prompt, args map[string]any) (messages []PromptMessage, err error) { + defer func() { + if rec := recover(); rec != nil { + fmt.Fprintf(os.Stderr, "gomcp: prompt %q panicked: %v\n", prompt.Name, rec) + err = fmt.Errorf("prompt handler panicked") + } + }() + if prompt.Handler == nil { + return nil, fmt.Errorf("prompt has no handler") + } + hctx, cancel := s.handlerContext(ctx, prompt.Timeout) + defer cancel() + return prompt.Handler(hctx, args) +} diff --git a/gomcp/types.go b/gomcp/types.go index 6053fbc..3affcff 100644 --- a/gomcp/types.go +++ b/gomcp/types.go @@ -1,6 +1,9 @@ package gomcp -import "context" +import ( + "context" + "time" +) // ToolHandler is the function signature for tool implementations. // Receives the request context and parsed arguments, returns a text result. @@ -38,10 +41,29 @@ type serverCapabilities struct { } // initializeResult is the result payload for the initialize handshake. +// The shape is the pre-2026 handshake so existing clients keep working. type initializeResult struct { ProtocolVersion string `json:"protocolVersion"` ServerInfo serverInfo `json:"serverInfo"` Capabilities serverCapabilities `json:"capabilities"` + Instructions string `json:"instructions,omitempty"` +} + +// resultMeta is the 2026-07-28 result _meta object. Servers SHOULD identify +// themselves on every modern result (SEP-2575). +type resultMeta struct { + ServerInfo serverInfo `json:"io.modelcontextprotocol/serverInfo"` +} + +// discoverResult is the result payload for server/discover (2026-07-28). +type discoverResult struct { + ResultType string `json:"resultType"` + SupportedVersions []string `json:"supportedVersions"` + Capabilities serverCapabilities `json:"capabilities"` + Instructions string `json:"instructions,omitempty"` + TTLMs int64 `json:"ttlMs"` + CacheScope string `json:"cacheScope"` + Meta resultMeta `json:"_meta"` } // textContent is a single text content block returned by tool calls. @@ -51,20 +73,56 @@ type textContent struct { } // toolCallResult is the result payload for a tools/call response. IsError is -// omitted on success and set to true for in-band tool errors. +// omitted on success and set to true for in-band tool errors. ResultType and +// Meta are populated only for 2026-07-28 clients. type toolCallResult struct { - Content []textContent `json:"content"` - IsError bool `json:"isError,omitempty"` + Content []textContent `json:"content"` + IsError bool `json:"isError,omitempty"` + ResultType string `json:"resultType,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` } // toolsListResult is the result payload for tools/list. +// TTLMs is a pointer so a zero value is still emitted for 2026-07-28 +// clients (omitempty on int64 would drop ttlMs: 0). type toolsListResult struct { - Tools []Tool `json:"tools"` + Tools []Tool `json:"tools"` + NextCursor string `json:"nextCursor,omitempty"` + ResultType string `json:"resultType,omitempty"` + TTLMs *int64 `json:"ttlMs,omitempty"` + CacheScope string `json:"cacheScope,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` } // resourcesListResult is the result payload for resources/list. type resourcesListResult struct { - Resources []Resource `json:"resources"` + Resources []Resource `json:"resources"` + NextCursor string `json:"nextCursor,omitempty"` + ResultType string `json:"resultType,omitempty"` + TTLMs *int64 `json:"ttlMs,omitempty"` + CacheScope string `json:"cacheScope,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` +} + +// resourceTemplatesListResult is the result payload for resources/templates/list. +type resourceTemplatesListResult struct { + ResourceTemplates []resourceTemplate `json:"resourceTemplates"` + NextCursor string `json:"nextCursor,omitempty"` + ResultType string `json:"resultType,omitempty"` + TTLMs *int64 `json:"ttlMs,omitempty"` + CacheScope string `json:"cacheScope,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` +} + +// resourceTemplate is a URI-template resource entry. This server does not +// yet register templates; the type exists so resources/templates/list can +// return a spec-compliant empty catalog instead of -32601. +type resourceTemplate struct { + URITemplate string `json:"uriTemplate"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + MimeType string `json:"mimeType,omitempty"` } // resourceContent is a single content block returned by resources/read. @@ -76,17 +134,28 @@ type resourceContent struct { // resourcesReadResult is the result payload for resources/read. type resourcesReadResult struct { - Contents []resourceContent `json:"contents"` + Contents []resourceContent `json:"contents"` + ResultType string `json:"resultType,omitempty"` + TTLMs *int64 `json:"ttlMs,omitempty"` + CacheScope string `json:"cacheScope,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` } // promptsListResult is the result payload for prompts/list. type promptsListResult struct { - Prompts []Prompt `json:"prompts"` + Prompts []Prompt `json:"prompts"` + NextCursor string `json:"nextCursor,omitempty"` + ResultType string `json:"resultType,omitempty"` + TTLMs *int64 `json:"ttlMs,omitempty"` + CacheScope string `json:"cacheScope,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` } // promptsGetResult is the result payload for prompts/get. type promptsGetResult struct { - Messages []PromptMessage `json:"messages"` + Messages []PromptMessage `json:"messages"` + ResultType string `json:"resultType,omitempty"` + Meta *resultMeta `json:"_meta,omitempty"` } // Property describes a single input parameter for a tool or prompt argument. @@ -102,29 +171,53 @@ type InputSchema struct { Required []string `json:"required,omitempty"` } +// ToolAnnotations are optional hints about a tool's behavior. Clients must +// treat them as untrusted hints, never as a security boundary. +type ToolAnnotations struct { + Title string `json:"title,omitempty"` + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + DestructiveHint bool `json:"destructiveHint,omitempty"` + IdempotentHint bool `json:"idempotentHint,omitempty"` + OpenWorldHint bool `json:"openWorldHint,omitempty"` +} + // Tool defines a callable tool registered with the MCP server. type Tool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema any `json:"inputSchema"` - Handler ToolHandler `json:"-"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + InputSchema any `json:"inputSchema"` + OutputSchema any `json:"outputSchema,omitempty"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` + Handler ToolHandler `json:"-"` + // Timeout overrides Server.HandlerTimeout for this tool. Zero means + // inherit; a negative value disables the timeout for this tool. + Timeout time.Duration `json:"-"` } // Resource defines a readable resource registered with the MCP server. type Resource struct { URI string `json:"uri"` Name string `json:"name"` + Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` MimeType string `json:"mimeType,omitempty"` Handler ResourceHandler `json:"-"` + // Timeout overrides Server.HandlerTimeout for this resource. Zero + // means inherit; a negative value disables the timeout. + Timeout time.Duration `json:"-"` } // Prompt defines a prompt template registered with the MCP server. type Prompt struct { Name string `json:"name"` + Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Arguments []PromptArg `json:"arguments,omitempty"` Handler PromptHandler `json:"-"` + // Timeout overrides Server.HandlerTimeout for this prompt. Zero means + // inherit; a negative value disables the timeout. + Timeout time.Duration `json:"-"` } // PromptArg describes an argument that a prompt template accepts.
MethodDescriptionStatus
initializeMCP handshake — reports server name, version, capabilities
server/discover2026-07-28 capability probe — versions, identity, cache hints
initializeLegacy handshake — reports server name, version, capabilities
notifications/initializedClient ready notification — consumed silently
pingLiveness check kept for older clients
tools/listReturns metadata for all registered tools
tools/callDispatches a call to the matching tool handler
resources/listReturns metadata for all registered resources
resources/readReads a resource by URI
resources/templates/listEmpty catalog — spec-compliant probe response
prompts/listReturns metadata for all registered prompts
prompts/getBuilds a prompt from arguments