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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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{...})` |

Expand All @@ -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
```
Expand All @@ -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 <token>` 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 <token>` or `X-API-Key: <token>` 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")
Expand All @@ -108,14 +120,28 @@ _ = s.ServeHTTP(":8080")

Requests without a matching `Authorization: Bearer <token>` 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 <token>` **or** `X-API-Key: <token>` 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

| Symbol | Purpose |
|--------|---------|
| `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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.4
0.1.5
112 changes: 112 additions & 0 deletions graph.go
Original file line number Diff line number Diff line change
@@ -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)
}
72 changes: 72 additions & 0 deletions graph_test.go
Original file line number Diff line number Diff line change
@@ -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
})
}
Loading