Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand All @@ -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

Expand Down
44 changes: 32 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

---

Expand Down
9 changes: 6 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ <h1>Build <span class="accent">AI tools</span><br>in Go</h1>
<div class="label">dependencies</div>
</div>
<div class="stat">
<div class="num">8</div>
<div class="num">11</div>
<div class="label">MCP methods</div>
</div>
<div class="stat">
Expand Down Expand Up @@ -367,20 +367,23 @@ <h3>Prompts</h3>
<div class="container">
<div class="section-label">Protocol Support</div>
<h2>Full MCP coverage</h2>
<p class="lead">All eight MCP methods implemented. Not a subset. Not a work-in-progress. Complete.</p>
<p class="lead">2026-07-28 plus the legacy initialize handshake. Not a subset. Not a work-in-progress. Backward compatible.</p>

<div style="background: var(--bg-card); border: 1px solid var(--border-card); border-radius: var(--radius-lg); overflow: hidden; margin-top: 32px;">
<table class="protocol-table">
<thead>
<tr><th>Method</th><th>Description</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td class="method">initialize</td><td>MCP handshake — reports server name, version, capabilities</td><td class="check">✓</td></tr>
<tr><td class="method">server/discover</td><td>2026-07-28 capability probe — versions, identity, cache hints</td><td class="check">✓</td></tr>
<tr><td class="method">initialize</td><td>Legacy handshake — reports server name, version, capabilities</td><td class="check">✓</td></tr>
<tr><td class="method">notifications/initialized</td><td>Client ready notification — consumed silently</td><td class="check">✓</td></tr>
<tr><td class="method">ping</td><td>Liveness check kept for older clients</td><td class="check">✓</td></tr>
<tr><td class="method">tools/list</td><td>Returns metadata for all registered tools</td><td class="check">✓</td></tr>
<tr><td class="method">tools/call</td><td>Dispatches a call to the matching tool handler</td><td class="check">✓</td></tr>
<tr><td class="method">resources/list</td><td>Returns metadata for all registered resources</td><td class="check">✓</td></tr>
<tr><td class="method">resources/read</td><td>Reads a resource by URI</td><td class="check">✓</td></tr>
<tr><td class="method">resources/templates/list</td><td>Empty catalog — spec-compliant probe response</td><td class="check">✓</td></tr>
<tr><td class="method">prompts/list</td><td>Returns metadata for all registered prompts</td><td class="check">✓</td></tr>
<tr><td class="method">prompts/get</td><td>Builds a prompt from arguments</td><td class="check">✓</td></tr>
</tbody>
Expand Down
8 changes: 7 additions & 1 deletion examples/db-explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 25 additions & 26 deletions examples/fs-navigator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
},
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions examples/greet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion gomcp/jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}
Loading
Loading