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
@@ -367,7 +367,7 @@
Prompts
Protocol Support
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
| Method | Description | Status |
- | 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 | Client ready notification — consumed silently | ✓ |
+ | ping | Liveness check kept for older clients | ✓ |
| tools/list | Returns metadata for all registered tools | ✓ |
| 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 probe response | ✓ |
| prompts/list | Returns metadata for all registered prompts | ✓ |
| prompts/get | Builds a prompt from arguments | ✓ |
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.