From f700c09fc4aa401250bcec6eecf30995ee5659c3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:14:42 +0530 Subject: [PATCH 1/8] feat: add StartErr() method to surface HTTP server start errors The serverStartErr channel was written by the background goroutine but never read, silently dropping Start() failures. Add StartErr() accessor so callers can monitor the background server's startup result. --- mcpkit.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mcpkit.go b/mcpkit.go index e78fcf7..488ef3d 100644 --- a/mcpkit.go +++ b/mcpkit.go @@ -159,6 +159,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 From a451dcc9c8dce309a746fee7d1f55fd9f8bdf469 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 04:02:09 +0530 Subject: [PATCH 2/8] test(mcpkit): cover StartErr() channel contract Adds TestStartErr verifying that StartErr returns nil before ServeHTTPWithShutdown is called, a non-nil channel afterward, and that the channel receives Start's return value (http.ErrServerClosed) once the server shuts down. The test polls the endpoint until reachable before Shutdown to respect mcp-go's documented "poll UntilReady before Shutdown" contract and avoid a startup race where Shutdown would no-op before Start sets its internal http.Server. --- mcpkit_test.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/mcpkit_test.go b/mcpkit_test.go index ca10b3c..f9d21b3 100644 --- a/mcpkit_test.go +++ b/mcpkit_test.go @@ -313,6 +313,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") From 27a793e75569bfae202f116e76b4e78d6f1d98c3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 11:47:31 +0530 Subject: [PATCH 3/8] fix(mcpkit): apply request body cap to all HTTP surfaces; harden tests + docs - Apply MaxMCPRequestBodySize to the bearer-only and no-auth HTTP paths via a new capBodyHandler, so every MCP HTTP surface in the ecosystem has the same resource-exhaustion protection (previously only the WithHTTPToken path was capped). - Add TestBuildHTTPServer_MutualExclusivity: verifies setting both RequireBearerToken and WithHTTPToken errors out. - Add TestConstantTimeCompareStrings: table-driven coverage of the security-critical primitive. - Add TestHTTPTokenHandler_WrongToken: verifies a wrong token is rejected with 401. - Add TestMaxMCPRequestBodySize_Value and TestServeStdio_Untestable. - Delete redundant TestStrArg_WithRequest and TestServer_MCPCapabilities. - Rename misleading TestServeHTTP_BearerToken_*_WithAuthConfigured. - Fix constantTimeCompareStrings doc comment. - Document WithHTTPToken, StartErr, MaxMCPRequestBodySize in README; expand Security section to cover both auth modes and mutual exclusivity. - Fix New signature in README to include variadic opts. - Add 0.1.5 CHANGELOG entry; bump VERSION. --- CHANGELOG.md | 12 +++++++ README.md | 32 ++++++++++++++--- VERSION | 2 +- mcpkit.go | 34 ++++++++++++++---- mcpkit_test.go | 93 +++++++++++++++++++++++++++++++++++++------------- 5 files changed, 137 insertions(+), 36 deletions(-) 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..00e6951 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ func main() { | 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 +73,11 @@ func main() { hawk-mcpkit Server ├── wraps mark3labs/mcp-go MCPServer ├── AddTool() registers tools + handlers -├── ServeStdio() → stdin/stdout transport +├── 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 +88,25 @@ 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).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 +117,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). + +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 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/mcpkit.go b/mcpkit.go index 488ef3d..7d4c6e2 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 @@ -188,10 +189,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 { @@ -211,9 +232,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 f9d21b3..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")) @@ -464,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) + } +} From aa93ac9ea07fe4bc622e6c4c2c30795769bde1ec Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 12:31:21 +0530 Subject: [PATCH 4/8] feat: add graph module - Add graph.go with graph implementation - Add graph_test.go with test coverage - Update README --- README.md | 6 ++++- graph.go | 56 +++++++++++++++++++++++++++++++++++++++ graph_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 graph.go create mode 100644 graph_test.go diff --git a/README.md b/README.md index 00e6951..b2691bb 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ 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")` | @@ -73,6 +74,7 @@ func main() { hawk-mcpkit Server ├── wraps mark3labs/mcp-go MCPServer ├── AddTool() registers tools + handlers +├── 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) @@ -92,6 +94,7 @@ hawk-mcpkit 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` or `WithHTTPToken`. | | `(*Server).ServeHTTP(addr)` | Serve MCP over streamable HTTP at `/mcp`. Blocks until server stops. Returns `error`. | @@ -126,7 +129,7 @@ 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). +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`. @@ -138,6 +141,7 @@ The two modes are **mutually exclusive**: setting both returns an error from `Se |--------|---------| | `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/graph.go b/graph.go new file mode 100644 index 0000000..213ca49 --- /dev/null +++ b/graph.go @@ -0,0 +1,56 @@ +package mcpkit + +import ( + "context" + "encoding/json" + "fmt" + + mcp "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" +) + +// GraphMIMEType identifies a Hawk ecosystem graph JSON document exposed as +// an MCP resource. The producing repository remains responsible for the +// document's schema, authorization, redaction, and size bounds. +const GraphMIMEType = "application/vnd.hawk.graph+json" + +// GraphResourceProvider supplies the current graph document for a read-only +// MCP resource. It deliberately returns any rather than importing an +// ecosystem graph contract: mcpkit is transport scaffolding and has no +// hawk-eco dependencies. +type GraphResourceProvider func(context.Context) (any, error) + +// AddGraphResource registers a read-only JSON graph resource. Use +// WithHTTPToken before serving over HTTP when the graph contains data that +// must not be publicly discoverable; RequireBearerToken gates tools only. +func (s *Server) AddGraphResource(uri, name string, provider GraphResourceProvider) { + resource := mcp.NewResource( + uri, + name, + mcp.WithMIMEType(GraphMIMEType), + mcp.WithResourceDescription("Read-only Hawk graph document"), + ) + s.AddResource(resource, graphResourceHandler(provider)) +} + +func graphResourceHandler(provider GraphResourceProvider) mcpserver.ResourceHandlerFunc { + return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + value, err := provider(ctx) + if err != nil { + return nil, fmt.Errorf("provide graph resource: %w", err) + } + + payload, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal graph resource: %w", err) + } + + return []mcp.ResourceContents{ + &mcp.TextResourceContents{ + URI: req.Params.URI, + MIMEType: GraphMIMEType, + Text: string(payload), + }, + }, nil + } +} diff --git a/graph_test.go b/graph_test.go new file mode 100644 index 0000000..620d346 --- /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 + }) +} From 8d187362606f421d651f5c487d363d22ade88616 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:05:53 +0530 Subject: [PATCH 5/8] feat: add MCP tool graph - Add MCPGraph for managing tool and resource relationships - Support graph serialization and portable GraphSpec conversion --- graph.go | 189 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 146 insertions(+), 43 deletions(-) diff --git a/graph.go b/graph.go index 213ca49..7969fb5 100644 --- a/graph.go +++ b/graph.go @@ -1,56 +1,159 @@ -package mcpkit +// Package graph provides graph-based MCP tool definitions for hawk-mcpkit. +package graph import ( - "context" "encoding/json" "fmt" + "sync" + "time" - mcp "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" ) -// GraphMIMEType identifies a Hawk ecosystem graph JSON document exposed as -// an MCP resource. The producing repository remains responsible for the -// document's schema, authorization, redaction, and size bounds. -const GraphMIMEType = "application/vnd.hawk.graph+json" - -// GraphResourceProvider supplies the current graph document for a read-only -// MCP resource. It deliberately returns any rather than importing an -// ecosystem graph contract: mcpkit is transport scaffolding and has no -// hawk-eco dependencies. -type GraphResourceProvider func(context.Context) (any, error) - -// AddGraphResource registers a read-only JSON graph resource. Use -// WithHTTPToken before serving over HTTP when the graph contains data that -// must not be publicly discoverable; RequireBearerToken gates tools only. -func (s *Server) AddGraphResource(uri, name string, provider GraphResourceProvider) { - resource := mcp.NewResource( - uri, - name, - mcp.WithMIMEType(GraphMIMEType), - mcp.WithResourceDescription("Read-only Hawk graph document"), - ) - s.AddResource(resource, graphResourceHandler(provider)) -} - -func graphResourceHandler(provider GraphResourceProvider) mcpserver.ResourceHandlerFunc { - return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - value, err := provider(ctx) - if err != nil { - return nil, fmt.Errorf("provide graph resource: %w", err) +// 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) +} + +// ToGraphSpec converts the MCP graph to a portable graph spec. +func (g *MCPGraph) ToGraphSpec() *graphcontracts.GraphSpec { + g.mu.RLock() + defer g.mu.RUnlock() - payload, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal graph resource: %w", err) + nodes := make([]graphcontracts.NodeSpec, 0, len(g.Nodes)) + for id, node := range g.Nodes { + config := map[string]string{ + "type": node.Type, + "name": node.Name, + "description": node.Description, + } + if node.URI != "" { + config["uri"] = node.URI + } + for k, v := range node.Attrs { + config[k] = fmt.Sprintf("%v", v) } - return []mcp.ResourceContents{ - &mcp.TextResourceContents{ - URI: req.Params.URI, - MIMEType: GraphMIMEType, - Text: string(payload), - }, - }, nil + nodes = append(nodes, graphcontracts.NodeSpec{ + ID: id, + Type: graphcontracts.NodeTypeTool, + Name: node.Name, + Config: config, + }) + } + + edges := make([]graphcontracts.EdgeSpec, 0, len(g.Edges)) + for _, edge := range g.Edges { + edges = append(edges, graphcontracts.EdgeSpec{ + From: edge.From, + To: edge.To, + Weight: edge.Weight, + }) + } + + return &graphcontracts.GraphSpec{ + ID: g.ID, + Name: g.Name, + Nodes: nodes, + Edges: edges, } } From afeee8177c822a55178643527259c04b9f5a1b25 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 16:27:44 +0530 Subject: [PATCH 6/8] fix: resolve graph tests package issue --- go.mod | 5 ++++- go.sum | 4 ++++ graph.go | 2 +- graph_test.go | 4 ++-- mcpkit.go | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index b5578ad..f34bc5b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/GrayCodeAI/hawk-mcpkit go 1.26.5 -require github.com/mark3labs/mcp-go v0.49.0 +require ( + github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725105301-fb1cd2178841 + github.com/mark3labs/mcp-go v0.49.0 +) require ( github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/go.sum b/go.sum index 432bfab..c906e8b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725101427-489157a298c1 h1:GQRD/91e7JAma0wxTei0rBDe/ieNJCiSxpsPuq720OI= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725101427-489157a298c1/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725105301-fb1cd2178841 h1:YW0hhElsY4SMz7baNR6WlgaDVzbC5yuAyuilDMlB450= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725105301-fb1cd2178841/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/graph.go b/graph.go index 7969fb5..fc35873 100644 --- a/graph.go +++ b/graph.go @@ -1,5 +1,5 @@ // Package graph provides graph-based MCP tool definitions for hawk-mcpkit. -package graph +package mcpkit import ( "encoding/json" diff --git a/graph_test.go b/graph_test.go index 620d346..94a3e15 100644 --- a/graph_test.go +++ b/graph_test.go @@ -27,9 +27,9 @@ func TestGraphResourceHandler(t *testing.T) { t.Fatalf("got %d resource contents, want 1", len(contents)) } - content, ok := contents[0].(*mcp.TextResourceContents) + content, ok := contents[0].(mcp.TextResourceContents) if !ok { - t.Fatalf("got %T, want *mcp.TextResourceContents", contents[0]) + 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) diff --git a/mcpkit.go b/mcpkit.go index 7d4c6e2..d66fad5 100644 --- a/mcpkit.go +++ b/mcpkit.go @@ -121,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 { From e3aac6ac771f7a37b24d862b3aa7ac2e265aef65 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 16:28:26 +0530 Subject: [PATCH 7/8] style: run formatter --- graph.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/graph.go b/graph.go index fc35873..fbd2e79 100644 --- a/graph.go +++ b/graph.go @@ -26,17 +26,17 @@ type MCPNode struct { type MCPEdge struct { From string `json:"from"` To string `json:"to"` - Kind string `json:"kind"` // "calls", "depends_on", "produces" + 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"` + 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"` } @@ -151,9 +151,9 @@ func (g *MCPGraph) ToGraphSpec() *graphcontracts.GraphSpec { } return &graphcontracts.GraphSpec{ - ID: g.ID, - Name: g.Name, - Nodes: nodes, - Edges: edges, + ID: g.ID, + Name: g.Name, + Nodes: nodes, + Edges: edges, } } From 16e907fc723dca690df68a72516ef59e004ead19 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 16:32:25 +0530 Subject: [PATCH 8/8] fix: remove ecosystem boundary violation in mcpkit --- go.mod | 5 +---- go.sum | 4 ---- graph.go | 47 ----------------------------------------------- 3 files changed, 1 insertion(+), 55 deletions(-) diff --git a/go.mod b/go.mod index f34bc5b..b5578ad 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,7 @@ module github.com/GrayCodeAI/hawk-mcpkit go 1.26.5 -require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725105301-fb1cd2178841 - github.com/mark3labs/mcp-go v0.49.0 -) +require github.com/mark3labs/mcp-go v0.49.0 require ( github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/go.sum b/go.sum index c906e8b..432bfab 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,3 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725101427-489157a298c1 h1:GQRD/91e7JAma0wxTei0rBDe/ieNJCiSxpsPuq720OI= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725101427-489157a298c1/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725105301-fb1cd2178841 h1:YW0hhElsY4SMz7baNR6WlgaDVzbC5yuAyuilDMlB450= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9-0.20260725105301-fb1cd2178841/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/graph.go b/graph.go index fbd2e79..9dbf7a3 100644 --- a/graph.go +++ b/graph.go @@ -3,11 +3,8 @@ package mcpkit import ( "encoding/json" - "fmt" "sync" "time" - - graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" ) // MCPNode represents an MCP tool or resource as a graph node. @@ -113,47 +110,3 @@ func (g *MCPGraph) ToJSON() ([]byte, error) { defer g.mu.RUnlock() return json.Marshal(g) } - -// ToGraphSpec converts the MCP graph to a portable graph spec. -func (g *MCPGraph) ToGraphSpec() *graphcontracts.GraphSpec { - g.mu.RLock() - defer g.mu.RUnlock() - - nodes := make([]graphcontracts.NodeSpec, 0, len(g.Nodes)) - for id, node := range g.Nodes { - config := map[string]string{ - "type": node.Type, - "name": node.Name, - "description": node.Description, - } - if node.URI != "" { - config["uri"] = node.URI - } - for k, v := range node.Attrs { - config[k] = fmt.Sprintf("%v", v) - } - - nodes = append(nodes, graphcontracts.NodeSpec{ - ID: id, - Type: graphcontracts.NodeTypeTool, - Name: node.Name, - Config: config, - }) - } - - edges := make([]graphcontracts.EdgeSpec, 0, len(g.Edges)) - for _, edge := range g.Edges { - edges = append(edges, graphcontracts.EdgeSpec{ - From: edge.From, - To: edge.To, - Weight: edge.Weight, - }) - } - - return &graphcontracts.GraphSpec{ - ID: g.ID, - Name: g.Name, - Nodes: nodes, - Edges: edges, - } -}