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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func main() {
| Add a resource template | `s.AddResourceTemplate(template, handler)` |
| Serve stdio | `s.ServeStdio()` |
| Serve HTTP | `s.ServeHTTP(":8080")` |
| Serve SSE | `s.ServeSSE(":8080")` |
| Require a bearer token on HTTP tool calls | `s.RequireBearerToken("secret")` |
| Extract string arg | `mcpkit.StrArg(req, "key")` |
| Return JSON result | `mcpkit.JSONResult(map[string]any{...})` |

Expand All @@ -89,11 +89,27 @@ hawk-mcpkit Server
| `(*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`. |
| `(*Server).ServeStdio()` | Serve MCP over stdin/stdout. Blocks until stream closes. Returns `error`. Never affected by `RequireBearerToken`. |
| `(*Server).ServeHTTP(addr)` | Serve MCP over streamable HTTP at `/mcp`. Blocks until server stops. Returns `error`. |
| `(*Server).ServeSSE(addr)` | Serve MCP over SSE transport. 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).MCP()` | Escape hatch to the underlying `*mcpserver.MCPServer`. Use only for capabilities mcpkit does not wrap. |

## 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:

```go
s := mcpkit.New("my-server", "0.1.0")
s.RequireBearerToken(os.Getenv("MY_SERVER_TOKEN"))
// ...
_ = 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.

### Handler Helpers

| Symbol | Purpose |
Expand Down
182 changes: 168 additions & 14 deletions mcpkit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,44 @@
package mcpkit

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"

mcp "github.com/mark3labs/mcp-go/mcp"
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.
const MaxMCPRequestBodySize = 1 << 20 // 1 MB

// Server wraps an mcp-go MCPServer with the ecosystem's standard
// transports (stdio and streamable HTTP).
type Server struct {
mcp *mcpserver.MCPServer
mcp *mcpserver.MCPServer
bearerToken string
httpToken string
}

// New creates a named MCP server with tool, prompt, and resource
// capabilities enabled.
func New(name, version string) *Server {
// capabilities enabled. Default capabilities match the ecosystem
// convention (tool + prompt + read-only, no resource-list-changed
// updates). Pass extra mcpserver.ServerOptions to override — they are
// applied after the defaults, so a later option wins over an earlier one.
// This lets repos like yaad (which expose a resource *list* rather than a
// set of subscribable resources) tailor behavior without forks.
func New(name, version string, opts ...mcpserver.ServerOption) *Server {
base := []mcpserver.ServerOption{
mcpserver.WithToolCapabilities(true),
mcpserver.WithPromptCapabilities(true),
mcpserver.WithResourceCapabilities(false, true),
}
return &Server{
mcp: mcpserver.NewMCPServer(
name, version,
mcpserver.WithToolCapabilities(true),
mcpserver.WithPromptCapabilities(true),
mcpserver.WithResourceCapabilities(false, true),
),
mcp: mcpserver.NewMCPServer(name, version, append(base, opts...)...),
}
}

Expand Down Expand Up @@ -57,6 +73,49 @@ func (s *Server) MCP() *mcpserver.MCPServer {
return s.mcp
}

// RequireBearerToken configures ServeHTTP to reject tool
// calls that don't present a matching "Authorization: Bearer <token>"
// header. Pass "" (the default) for no auth requirement.
//
// This only gates tool calls, not resources or prompts: mcp-go's
// resource/prompt middleware are construction-time-only ServerOptions with
// no post-construction equivalent to the tool middleware's Use() method,
// so wiring them here would require restructuring New() itself. Given
// mcpkit's resource capability is already read-only
// (WithResourceCapabilities(false, true)) and tools are the primary
// capability, tool-only gating is the deliberate scope here.
//
// ServeStdio is never affected, regardless of this setting: stdio is a
// locally-spawned child process with no network exposure, not a transport
// this check is meant to protect.
func (s *Server) RequireBearerToken(token string) {
s.bearerToken = token
// Register once against the shared underlying server — not per-serve in
// buildHTTPServer, where a call-stack of ServeHTTP/WithShutdown would
// otherwise append duplicate middleware on every invocation.
if token != "" {
s.mcp.Use(bearerToolMiddleware(token))
}
}

// WithHTTPToken configures ServeHTTP and ServeHTTPWithShutdown to reject
// every request that doesn't present a matching token, either as
// "Authorization: Bearer <token>" or "X-API-Key: <token>". Pass "" (the
// default) for no HTTP-level gate.
//
// Unlike RequireBearerToken (which only gates tool calls via mcp-go's tool
// middleware), WithHTTPToken gates the entire HTTP surface — initialize,
// resources, prompts, and tools alike — 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). It is opt-in and does not affect repos
// that leave it unset.
//
// ServeStdio is never affected, regardless of this setting, for the same
// trust rationale as RequireBearerToken: stdio is a local subprocess pipe.
func (s *Server) WithHTTPToken(token string) {
s.httpToken = token
}

// 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 {
Expand All @@ -65,14 +124,109 @@ func (s *Server) ServeStdio() error {

// ServeHTTP serves MCP over the streamable HTTP transport at
// http://<addr>/mcp and blocks until the server stops.
//
// Auth precedence: if WithHTTPToken was set, every request is gated at the
// transport boundary (bearer or X-API-Key). Otherwise, if RequireBearerToken
// was set, only tool calls without a matching bearer header are rejected.
// The two modes are mutually exclusive at the HTTP boundary — set at most
// one.
func (s *Server) ServeHTTP(addr string) error {
return mcpserver.NewStreamableHTTPServer(s.mcp).Start(addr)
httpServer, err := s.buildHTTPServer(addr)
if err != nil {
return err
}
return httpServer.Start(addr)
}

// ServeHTTPWithShutdown serves MCP over the streamable HTTP transport at
// http://<addr>/mcp and returns the underlying server so the caller can
// invoke Shutdown(ctx) for graceful teardown. It launches the listener in a
// background goroutine and returns immediately (once the server object
// exists), so the caller owns the lifecycle. Poll UntilReady on the returned
// server to wait for the listener to come up before calling Shutdown. See
// ServeHTTP for auth semantics.
func (s *Server) ServeHTTPWithShutdown(addr string) (*mcpserver.StreamableHTTPServer, error) {
httpServer, err := s.buildHTTPServer(addr)
if err != nil {
return nil, err
}
go func() { _ = httpServer.Start(addr) }()
return httpServer, nil
}

// ServeSSE serves MCP over the SSE transport at <addr> and blocks until
// the server stops.
func (s *Server) ServeSSE(addr string) error {
return mcpserver.NewSSEServer(s.mcp).Start(addr)
// 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
// bearer context-func + tool middleware.
func (s *Server) buildHTTPServer(addr string) (*mcpserver.StreamableHTTPServer, error) {
streamable := mcpserver.NewStreamableHTTPServer(s.mcp)
if s.bearerToken != "" && s.httpToken == "" {
// bearerToolMiddleware is registered once in RequireBearerToken; here
// we only attach the per-request context func that feeds it.
streamable = mcpserver.NewStreamableHTTPServer(
s.mcp,
mcpserver.WithHTTPContextFunc(bearerHTTPContextFunc(s.bearerToken)),
)
}
if s.httpToken != "" {
streamable = mcpserver.NewStreamableHTTPServer(s.mcp, mcpserver.WithStreamableHTTPServer(&http.Server{
Addr: addr,
Handler: httpTokenHandler(s.httpToken, streamable),
}))
}
return streamable, nil
}

// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxMCPRequestBodySize)
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if got == "" {
got = r.Header.Get("X-API-Key")
}
if got != token {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}
next.ServeHTTP(w, r)
})
}

type bearerAuthorizedKey struct{}

func checkBearer(r *http.Request, token string) bool {
return r.Header.Get("Authorization") == "Bearer "+token
}

// bearerHTTPContextFunc validates the incoming request's Authorization
// header and stashes the result in context for bearerToolMiddleware to
// check. It never rejects the request itself — mcp-go's HTTPContextFunc
// has no way to do that — enforcement happens in the tool middleware.
func bearerHTTPContextFunc(token string) mcpserver.HTTPContextFunc {
return func(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, bearerAuthorizedKey{}, checkBearer(r, token))
}
}

// bearerToolMiddleware rejects a tool call unless bearerHTTPContextFunc
// marked its context as authorized. A plain Go error
// return (rather than a *mcp.CallToolResult) mirrors mcp-go's own
// WithRecovery middleware, which mcp-go turns into a protocol-level error
// response.
func bearerToolMiddleware(token string) mcpserver.ToolHandlerMiddleware {
return func(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
authorized, _ := ctx.Value(bearerAuthorizedKey{}).(bool)
if !authorized {
return nil, fmt.Errorf("unauthorized: missing or invalid bearer token")
}
return next(ctx, req)
}
}
}

// StrArg extracts a string argument from a tool call request. Returns
Expand Down
Loading