diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d1f49..51aad8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.5] - 2026-07-24 + +### Fixed + +- Request body cap (`MaxMCPRequestBodySize`) is now applied to **all** HTTP surfaces — previously it was only enforced on the `WithHTTPToken` path, leaving the bearer-only and no-auth paths unprotected against resource exhaustion. + +### Added + +- `WithHTTPToken` is now documented in the README Security section and API Reference. +- Tests: `constantTimeCompareStrings`, `BuildHTTPServer` mutual-exclusivity, HTTP-token wrong-token rejection, `MaxMCPRequestBodySize` value, and an intentional-skip note for `ServeStdio`. +- Removed redundant `TestStrArg_WithRequest` and `TestServer_MCPCapabilities`. + ## [0.1.0] - 2026-07-04 ### Added diff --git a/README.md b/README.md index bdec0ae..b2691bb 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,12 @@ func main() { | Add a tool | `s.AddTool(tool, handler)` | | Add a prompt | `s.AddPrompt(prompt, handler)` | | Add a resource | `s.AddResource(resource, handler)` | +| Add a graph resource | `s.AddGraphResource(uri, name, provider)` | | Add a resource template | `s.AddResourceTemplate(template, handler)` | | Serve stdio | `s.ServeStdio()` | | Serve HTTP | `s.ServeHTTP(":8080")` | | Require a bearer token on HTTP tool calls | `s.RequireBearerToken("secret")` | +| Gate the whole HTTP surface with a token | `s.WithHTTPToken("secret")` | | Extract string arg | `mcpkit.StrArg(req, "key")` | | Return JSON result | `mcpkit.JSONResult(map[string]any{...})` | @@ -72,8 +74,12 @@ func main() { hawk-mcpkit Server ├── wraps mark3labs/mcp-go MCPServer ├── AddTool() registers tools + handlers -├── ServeStdio() → stdin/stdout transport +├── AddGraphResource() exposes a typed, read-only graph JSON resource +├── ServeStdio() → stdin/stdout transport (never auth-gated) ├── ServeHTTP(addr) → streamable HTTP at /mcp +│ ├── WithHTTPToken gates the whole surface (bearer or X-API-Key) +│ ├── RequireBearerToken gates only tool calls +│ └── MaxBytesReader caps every request body to 1 MB ├── StrArg() → extract string arguments └── JSONResult() → marshal values as JSON text results ``` @@ -84,20 +90,26 @@ hawk-mcpkit Server | Symbol | Purpose | |--------|---------| -| `New(name, version)` | Create a `*Server` with tool, prompt, and resource capabilities enabled. Returns `*Server`. | +| `New(name, version, opts...)` | Create a `*Server` with tool, prompt, and resource capabilities enabled. `opts` are applied after the defaults so a later option wins. Returns `*Server`. | | `(*Server).AddTool(tool, handler)` | Register a tool and its handler. `handler` is `func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)`. | | `(*Server).AddPrompt(prompt, handler)` | Register a prompt and its handler. `handler` is `func(context.Context, mcp.CallPromptRequest) (mcp.PromptResult, error)`. | | `(*Server).AddResource(resource, handler)` | Register a resource and its handler. `handler` is `func(context.Context, mcp.ReadResourceRequest) ([]mcp.ResourceContent, error)`. | +| `(*Server).AddGraphResource(uri, name, provider)` | Register a read-only graph JSON resource with `GraphMIMEType`. The producer owns schema validation, redaction, authorization, and size bounds. | | `(*Server).AddResourceTemplate(template, handler)` | Register a resource template and its handler. | -| `(*Server).ServeStdio()` | Serve MCP over stdin/stdout. Blocks until stream closes. Returns `error`. Never affected by `RequireBearerToken`. | +| `(*Server).ServeStdio()` | Serve MCP over stdin/stdout. Blocks until stream closes. Returns `error`. Never affected by `RequireBearerToken` or `WithHTTPToken`. | | `(*Server).ServeHTTP(addr)` | Serve MCP over streamable HTTP at `/mcp`. Blocks until server stops. Returns `error`. | | `(*Server).ServeHTTPWithShutdown(addr)` | Serve MCP over streamable HTTP at `/mcp` and return the underlying server for graceful `Shutdown`. Returns `(*mcpserver.StreamableHTTPServer, error)`. | | `(*Server).RequireBearerToken(token)` | Reject tool calls over HTTP that don't present a matching `Authorization: Bearer ` header. Pass `""` (the default) for no auth requirement. See [Security](#security) below. | +| `(*Server).WithHTTPToken(token)` | Reject every HTTP request (initialize, resources, prompts, tools) that doesn't present a matching `Authorization: Bearer ` or `X-API-Key: ` header. Mutually exclusive with `RequireBearerToken`. See [Security](#security) below. | +| `(*Server).StartErr()` | Returns a channel that receives the error (or nil) from the background HTTP server goroutine started by `ServeHTTPWithShutdown`, or `nil` if no server has been started. | | `(*Server).MCP()` | Escape hatch to the underlying `*mcpserver.MCPServer`. Use only for capabilities mcpkit does not wrap. | +| `MaxMCPRequestBodySize` | Cap (1 MB) applied to every HTTP request body, so all MCP HTTP transports in the ecosystem have the same resource-exhaustion protection. | ## Security -`ServeHTTP` and `ServeHTTPWithShutdown` are **unauthenticated by default** — anyone who can reach the listening address can call tools. Call `RequireBearerToken` before serving to require a static bearer token: +`ServeHTTP` and `ServeHTTPWithShutdown` are **unauthenticated by default** — anyone who can reach the listening address can call tools. Choose one of the two mutually exclusive auth modes: + +### `RequireBearerToken` — gate tool calls only ```go s := mcpkit.New("my-server", "0.1.0") @@ -108,7 +120,20 @@ _ = s.ServeHTTP(":8080") Requests without a matching `Authorization: Bearer ` header get a protocol-level error on tool calls. This only gates **tool calls** — mcp-go's resource/prompt middleware can only be wired at server-construction time, not added afterward the way tool middleware can, so gating those would require a larger restructure; mcpkit's resource capability is read-only, so tools are the primary surface this protects. -`ServeStdio` is never gated by `RequireBearerToken` — stdio is a locally-spawned child process, not a network-exposed transport, so bearer-token auth doesn't apply to it. +### `WithHTTPToken` — gate the whole HTTP surface + +```go +s := mcpkit.New("my-server", "0.1.0") +s.WithHTTPToken(os.Getenv("MY_SERVER_TOKEN")) +// ... +_ = s.ServeHTTP(":8080") +``` + +Every request — `initialize`, resources, prompts, and tools — must present a matching `Authorization: Bearer ` **or** `X-API-Key: ` header, or it is rejected with HTTP 401 at the transport boundary. Use this when the server holds data that shouldn't be discoverable without auth (e.g. a per-user memory store). This includes graph resources that contain repository, session, user, or tenant data: `RequireBearerToken` does not protect resources. + +The two modes are **mutually exclusive**: setting both returns an error from `ServeHTTP`. + +`ServeStdio` is never gated by either mode — stdio is a locally-spawned child process, not a network-exposed transport. ### Handler Helpers @@ -116,6 +141,7 @@ Requests without a matching `Authorization: Bearer ` header get a protoco |--------|---------| | `StrArg(req, key)` | Extract a string argument from a tool call request. Returns `""` when absent or not a string. | | `JSONResult(v)` | Marshal `v` as indented JSON and return it as a text tool result. Returns `(*mcp.CallToolResult, error)`. Error only when marshalling fails. | +| `GraphMIMEType` | Media type (`application/vnd.hawk.graph+json`) used by `AddGraphResource`. | ## Ecosystem diff --git a/VERSION b/VERSION index 845639e..9faa1b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4 +0.1.5 diff --git a/graph.go b/graph.go new file mode 100644 index 0000000..9dbf7a3 --- /dev/null +++ b/graph.go @@ -0,0 +1,112 @@ +// Package graph provides graph-based MCP tool definitions for hawk-mcpkit. +package mcpkit + +import ( + "encoding/json" + "sync" + "time" +) + +// MCPNode represents an MCP tool or resource as a graph node. +type MCPNode struct { + ID string `json:"id"` + Type string `json:"type"` // "tool", "resource", "prompt" + Name string `json:"name"` + Description string `json:"description,omitempty"` + URI string `json:"uri,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MCPEdge represents a relationship between MCP tools. +type MCPEdge struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` // "calls", "depends_on", "produces" + Weight float64 `json:"weight"` +} + +// MCPGraph represents a graph of MCP tools and resources. +type MCPGraph struct { + mu sync.RWMutex + ID string `json:"id"` + Name string `json:"name"` + Nodes map[string]*MCPNode `json:"nodes"` + Edges []MCPEdge `json:"edges"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// NewMCPGraph creates a new MCP graph. +func NewMCPGraph(id, name string) *MCPGraph { + return &MCPGraph{ + ID: id, + Name: name, + Nodes: make(map[string]*MCPNode), + Attrs: make(map[string]interface{}), + } +} + +// AddNode adds a node to the graph. +func (g *MCPGraph) AddNode(node *MCPNode) { + g.mu.Lock() + defer g.mu.Unlock() + if node.CreatedAt.IsZero() { + node.CreatedAt = time.Now() + } + node.UpdatedAt = time.Now() + g.Nodes[node.ID] = node +} + +// AddEdge adds an edge to the graph. +func (g *MCPGraph) AddEdge(edge MCPEdge) { + g.mu.Lock() + defer g.mu.Unlock() + g.Edges = append(g.Edges, edge) +} + +// GetNode retrieves a node by ID. +func (g *MCPGraph) GetNode(id string) (*MCPNode, bool) { + g.mu.RLock() + defer g.mu.RUnlock() + node, ok := g.Nodes[id] + return node, ok +} + +// GetNodes returns all nodes. +func (g *MCPGraph) GetNodes() []*MCPNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := make([]*MCPNode, 0, len(g.Nodes)) + for _, node := range g.Nodes { + result = append(result, node) + } + return result +} + +// GetEdges returns all edges. +func (g *MCPGraph) GetEdges() []MCPEdge { + g.mu.RLock() + defer g.mu.RUnlock() + return g.Edges +} + +// FindByType finds all nodes of a specific type. +func (g *MCPGraph) FindByType(nodeType string) []*MCPNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := []*MCPNode{} + for _, node := range g.Nodes { + if node.Type == nodeType { + result = append(result, node) + } + } + return result +} + +// ToJSON serializes the MCP graph to JSON. +func (g *MCPGraph) ToJSON() ([]byte, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return json.Marshal(g) +} diff --git a/graph_test.go b/graph_test.go new file mode 100644 index 0000000..94a3e15 --- /dev/null +++ b/graph_test.go @@ -0,0 +1,72 @@ +package mcpkit + +import ( + "context" + "errors" + "strings" + "testing" + + mcp "github.com/mark3labs/mcp-go/mcp" +) + +func TestGraphResourceHandler(t *testing.T) { + handler := graphResourceHandler(func(context.Context) (any, error) { + return map[string]any{ + "schema_version": "graph/v1", + "nodes": []map[string]string{{"id": "node-1"}}, + }, nil + }) + request := mcp.ReadResourceRequest{} + request.Params.URI = "hawk://graph/current" + + contents, err := handler(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if len(contents) != 1 { + t.Fatalf("got %d resource contents, want 1", len(contents)) + } + + content, ok := contents[0].(mcp.TextResourceContents) + if !ok { + t.Fatalf("got %T, want mcp.TextResourceContents", contents[0]) + } + if content.URI != request.Params.URI { + t.Errorf("URI = %q, want %q", content.URI, request.Params.URI) + } + if content.MIMEType != GraphMIMEType { + t.Errorf("MIME type = %q, want %q", content.MIMEType, GraphMIMEType) + } + if !strings.Contains(content.Text, `"schema_version": "graph/v1"`) { + t.Errorf("unexpected graph payload: %s", content.Text) + } +} + +func TestGraphResourceHandlerProviderError(t *testing.T) { + want := errors.New("graph unavailable") + handler := graphResourceHandler(func(context.Context) (any, error) { + return nil, want + }) + + _, err := handler(context.Background(), mcp.ReadResourceRequest{}) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want wrapped %v", err, want) + } +} + +func TestGraphResourceHandlerMarshalError(t *testing.T) { + handler := graphResourceHandler(func(context.Context) (any, error) { + return make(chan int), nil + }) + + if _, err := handler(context.Background(), mcp.ReadResourceRequest{}); err == nil { + t.Fatal("expected marshal error") + } +} + +func TestAddGraphResource(t *testing.T) { + server := New("test-server", "0.0.1") + server.AddGraphResource("hawk://graph/current", "Current graph", func(context.Context) (any, error) { + return map[string]string{"schema_version": "graph/v1"}, nil + }) +} diff --git a/mcpkit.go b/mcpkit.go index e78fcf7..d66fad5 100644 --- a/mcpkit.go +++ b/mcpkit.go @@ -17,9 +17,10 @@ import ( mcpserver "github.com/mark3labs/mcp-go/server" ) -// MaxMCPRequestBodySize caps the body of any MCP-over-HTTP request. Shared by -// the transports and the optional HTTP-level auth wrapper so every MCP HTTP -// surface in the ecosystem has the same resource-exhaustion protection. +// MaxMCPRequestBodySize caps the body of any MCP-over-HTTP request so every +// MCP HTTP surface in the ecosystem has the same resource-exhaustion +// protection. It is applied in httpTokenHandler (HTTP-token path) and +// capBody (bearer-only and no-auth paths). const MaxMCPRequestBodySize = 1 << 20 // 1 MB // Server wraps an mcp-go MCPServer with the ecosystem's standard @@ -120,6 +121,38 @@ func (s *Server) WithHTTPToken(token string) { s.httpToken = token } +// GraphMIMEType is the ecosystem standard media type for graph projections. +const GraphMIMEType = "application/vnd.hawk.graph+json" + +// AddGraphResource registers a read-only graph JSON resource. +// The provider function must return a struct or map that can be marshaled to JSON. +func (s *Server) AddGraphResource(uri, name string, provider func(context.Context) (any, error)) { + s.mcp.AddResource( + mcp.NewResource(uri, name, mcp.WithMIMEType(GraphMIMEType)), + graphResourceHandler(provider), + ) +} + +func graphResourceHandler(provider func(context.Context) (any, error)) mcpserver.ResourceHandlerFunc { + return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + graph, err := provider(ctx) + if err != nil { + return nil, err + } + data, err := json.MarshalIndent(graph, "", " ") + if err != nil { + return nil, err + } + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: req.Params.URI, + MIMEType: GraphMIMEType, + Text: string(data), + }, + }, nil + } +} + // ServeStdio serves MCP over stdin/stdout and blocks until the stream // closes or the context that mcp-go derives internally is done. func (s *Server) ServeStdio() error { @@ -159,6 +192,13 @@ func (s *Server) ServeHTTPWithShutdown(addr string) (*mcpserver.StreamableHTTPSe return httpServer, nil } +// StartErr returns a channel that receives the error (or nil) from the +// background HTTP server goroutine started by ServeHTTPWithShutdown. +// Returns nil if no HTTP server has been started. +func (s *Server) StartErr() <-chan error { + return s.serverStartErr +} + // buildHTTPServer constructs the streamable HTTP transport, applying the // configured auth mode. WithHTTPToken gates the whole HTTP handler; // otherwise RequireBearerToken (if set) gates tool calls via mcp-go's @@ -181,10 +221,30 @@ func (s *Server) buildHTTPServer(addr string) (*mcpserver.StreamableHTTPServer, Addr: addr, Handler: httpTokenHandler(s.httpToken, streamable), })) + } else { + // Cap the request body on the bearer-only and no-auth paths so that + // every MCP HTTP surface has the same resource-exhaustion protection + // as the HTTP-token path (httpTokenHandler applies MaxBytesReader + // internally). + streamable = mcpserver.NewStreamableHTTPServer(s.mcp, mcpserver.WithStreamableHTTPServer(&http.Server{ + Addr: addr, + Handler: capBodyHandler(streamable), + })) } return streamable, nil } +// capBodyHandler wraps next so that every request body is bounded by +// MaxMCPRequestBodySize, protecting against resource exhaustion from +// oversized payloads. It mirrors the cap that httpTokenHandler applies on +// the HTTP-token path. +func capBodyHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, MaxMCPRequestBodySize) + next.ServeHTTP(w, r) + }) +} + // httpTokenHandler wraps a streamable MCP handler so that every request must // present a matching bearer or X-API-Key token, and caps the request body. func httpTokenHandler(token string, next http.Handler) http.Handler { @@ -204,9 +264,10 @@ func httpTokenHandler(token string, next http.Handler) http.Handler { }) } -// constantTimeCompareStrings compares two strings in constant time. Returns -// false when lengths differ without attempting the byte-level compare, which -// is safe here because consumers must enforce equal-length tokens. +// constantTimeCompareStrings compares two strings in constant time. It returns +// false immediately when the lengths differ (leaking only the length mismatch, +// which is required because subtle.ConstantTimeCompare needs equal-length +// inputs). The caller is responsible for providing equal-length tokens. func constantTimeCompareStrings(a, b string) bool { if len(a) != len(b) { return false diff --git a/mcpkit_test.go b/mcpkit_test.go index ca10b3c..5b78848 100644 --- a/mcpkit_test.go +++ b/mcpkit_test.go @@ -112,18 +112,6 @@ func TestAddResourceTemplate(t *testing.T) { }) } -func TestServer_MCPCapabilities(t *testing.T) { - s := New("test-server", "0.0.1") - mcpServer := s.MCP() - if mcpServer == nil { - t.Fatal("MCP() returned nil") - } - tool := mcp.NewTool("test_tool", mcp.WithDescription("test tool")) - s.AddTool(tool, func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return mcp.NewToolResultText("ok"), nil - }) -} - func TestRequireBearerToken_DefaultsToNoAuth(t *testing.T) { s := New("test-server", "0.0.1") if s.bearerToken != "" { @@ -224,7 +212,7 @@ func TestBearerToolMiddleware_AllowsAuthorized(t *testing.T) { // malformed/unauthenticated POST rather than silently accepting it, as a // smoke test on top of the unit tests above which cover the actual // auth-decision logic precisely. -func TestServeHTTP_BearerToken_ServerStartsWithAuthConfigured(t *testing.T) { +func TestServeHTTP_BearerToken_ServerStarts(t *testing.T) { s := New("test-server", "0.0.1") s.RequireBearerToken("secret-123") tool := mcp.NewTool("ping", mcp.WithDescription("ping")) @@ -313,6 +301,77 @@ func TestServeHTTPWithShutdown_Reachable(t *testing.T) { t.Fatalf("server from ServeHTTPWithShutdown never became reachable: %v", lastErr) } +// TestStartErr verifies that StartErr surfaces the HTTP server's start result: +// nil before ServeHTTPWithShutdown is called, and a channel that receives the +// Start return value (http.ErrServerClosed on graceful shutdown) once the +// server has stopped. +func TestStartErr(t *testing.T) { + s := New("test-server", "0.0.1") + + // Before any HTTP server is started, StartErr reports nothing. + if ch := s.StartErr(); ch != nil { + t.Fatalf("expected nil StartErr before ServeHTTPWithShutdown, got %v", ch) + } + + addr := "127.0.0.1:18831" + srv, err := s.ServeHTTPWithShutdown(addr) + if err != nil { + t.Fatalf("ServeHTTPWithShutdown returned error: %v", err) + } + defer func() { _ = srv.Shutdown(context.Background()) }() + + ch := s.StartErr() + if ch == nil { + t.Fatal("expected non-nil StartErr after ServeHTTPWithShutdown") + } + + // Start runs in a background goroutine and only sets the internal + // http.Server once it begins ListenAndServe. Poll the endpoint until it is + // reachable so that the subsequent Shutdown actually targets a live server + // (per the documented "poll UntilReady before Shutdown" contract). + conn := &http.Client{Timeout: 500 * time.Millisecond} + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "test", "version": "0.0.1"}, + }, + }) + ready := false + for i := 0; i < 40; i++ { + req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := conn.Do(req) + if err == nil { + _ = resp.Body.Close() + ready = true + break + } + time.Sleep(25 * time.Millisecond) + } + if !ready { + t.Fatal("server never became reachable before Shutdown") + } + + // Start blocks until the server stops, so StartErr only receives once the + // server has shut down. A graceful Shutdown makes Start return + // http.ErrServerClosed. + if err := srv.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown returned error: %v", err) + } + + select { + case startErr := <-ch: + if startErr == nil { + t.Fatal("expected a non-nil StartErr (http.ErrServerClosed) after shutdown") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for StartErr to receive the shutdown result") + } +} + func TestWithHTTPToken_RejectsMissing(t *testing.T) { s := New("test-server", "0.0.1") s.WithHTTPToken("secret-123") @@ -393,24 +452,81 @@ func TestWithHTTPToken_AcceptsAPIKey(t *testing.T) { } } -func TestStrArg_WithRequest(t *testing.T) { +func TestServeStdio_Untestable(t *testing.T) { + // ServeStdio blocks on stdin/stdout until the stream closes, so it cannot + // be exercised by a unit test. This documents the intentional gap. + t.Skip("ServeStdio blocks on stdin/stdout; covered by consumer integration tests") +} + +func TestMaxMCPRequestBodySize_Value(t *testing.T) { + if MaxMCPRequestBodySize != 1<<20 { + t.Fatalf("MaxMCPRequestBodySize = %d, want %d", MaxMCPRequestBodySize, 1<<20) + } +} + +func TestBuildHTTPServer_MutualExclusivity(t *testing.T) { + s := New("test-server", "0.0.1") + s.RequireBearerToken("bearer-secret") + s.WithHTTPToken("http-secret") + err := s.ServeHTTP("127.0.0.1:18899") + if err == nil { + t.Fatal("expected error when both RequireBearerToken and WithHTTPToken are set") + } +} + +func TestConstantTimeCompareStrings(t *testing.T) { tests := []struct { name string - args map[string]any - key string - want string + a, b string + want bool }{ - {name: "present string", args: map[string]any{"name": "test"}, key: "name", want: "test"}, - {name: "missing key", args: map[string]any{}, key: "name", want: ""}, + {name: "equal strings", a: "secret", b: "secret", want: true}, + {name: "different equal-length", a: "secret", b: "secreT", want: false}, + {name: "different length", a: "short", b: "longer", want: false}, + {name: "empty vs empty", a: "", b: "", want: true}, + {name: "empty vs non-empty", a: "", b: "x", want: false}, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - req := mcp.CallToolRequest{} - req.Params.Arguments = tc.args - if got := StrArg(req, tc.key); got != tc.want { - t.Errorf("StrArg(%q) = %q, want %q", tc.key, got, tc.want) + if got := constantTimeCompareStrings(tc.a, tc.b); got != tc.want { + t.Errorf("constantTimeCompareStrings(%q,%q) = %v, want %v", tc.a, tc.b, got, tc.want) } }) } } + +func TestHTTPTokenHandler_WrongToken(t *testing.T) { + got := func(authz string) int { + s := New("test-server", "0.0.1") + s.WithHTTPToken("secret-123") + srv, err := s.ServeHTTPWithShutdown("127.0.0.1:18841") + if err != nil { + t.Fatalf("ServeHTTPWithShutdown: %v", err) + } + defer func() { _ = srv.Shutdown(context.Background()) }() + + conn := &http.Client{Timeout: 2 * time.Second} + body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + for i := 0; i < 40; i++ { + req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:18841/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if authz != "" { + req.Header.Set("Authorization", authz) + } + resp, err := conn.Do(req) + if err != nil { + time.Sleep(25 * time.Millisecond) + continue + } + code := resp.StatusCode + _ = resp.Body.Close() + return code + } + t.Fatal("server never became reachable") + return 0 + } + + if code := got("Bearer wrong-token"); code != http.StatusUnauthorized { + t.Errorf("wrong bearer token: got %d, want %d", code, http.StatusUnauthorized) + } +}