228 lines
9.4 KiB
Go
228 lines
9.4 KiB
Go
package main
|
|
|
|
// auth-demo-server demonstrates two layers of authentication for HTTP MCP servers:
|
|
//
|
|
// 1. CONNECTION-LEVEL AUTH (X-API-Key header)
|
|
// Enforced in HTTP middleware on every request (initialize, tools/list,
|
|
// tools/call). A missing or wrong key is rejected before the MCP server
|
|
// sees the message at all.
|
|
//
|
|
// 2. TOOL-EXECUTION AUTH (X-Tool-Token header)
|
|
// A separate secret token checked exclusively inside sensitive tool handlers
|
|
// at call time. Public tools ignore it; the connection middleware does not
|
|
// inspect it at all. This lets you scope a second credential to tool
|
|
// execution only — distinct from the connection credential.
|
|
//
|
|
// HOW BIFROST SENDS HEADERS
|
|
//
|
|
// Bifrost has a single `headers` field on MCPClientConfig. Those same headers are
|
|
// used in two places:
|
|
// - At connection time: passed to transport.WithHTTPHeaders() so every HTTP
|
|
// request to the server carries them.
|
|
// - At tool-call time: copied onto CallToolRequest.Header so the server can
|
|
// read them inside the tool handler via the request context.
|
|
//
|
|
// This means all configured headers are present on EVERY request — there is no
|
|
// separate "connection-only" vs "tool-only" header mechanism in Bifrost. To
|
|
// distinguish the two auth levels you simply use different header names, both
|
|
// configured in the same `headers` map. The server then enforces each header
|
|
// at the appropriate layer (middleware vs. handler).
|
|
//
|
|
// Bifrost config example:
|
|
//
|
|
// {
|
|
// "name": "auth_demo",
|
|
// "connection_type": "http",
|
|
// "connection_string": "http://localhost:3002/",
|
|
// "auth_type": "headers",
|
|
// "headers": {
|
|
// "X-API-Key": "super-secret-key",
|
|
// "X-Tool-Token": "tool-exec-secret"
|
|
// },
|
|
// "tools_to_execute": ["*"]
|
|
// }
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
)
|
|
|
|
const (
|
|
// connectionAPIKey is checked in HTTP middleware on every request
|
|
// (initialize, tools/list, tools/call).
|
|
// In production, load this from an environment variable or secrets manager.
|
|
connectionAPIKey = "super-secret-key"
|
|
|
|
// toolExecToken is checked exclusively inside sensitive tool handlers —
|
|
// never in the connection middleware. It acts as a second independent
|
|
// credential that gates tool execution only.
|
|
// In production, load this from an environment variable or secrets manager.
|
|
toolExecToken = "tool-exec-secret"
|
|
)
|
|
|
|
// contextKey is a private type so we don't collide with other packages' context keys.
|
|
type contextKey string
|
|
|
|
const requestHeadersKey contextKey = "request_headers"
|
|
|
|
func main() {
|
|
s := server.NewMCPServer("auth-demo-server", "1.0.0")
|
|
|
|
// public_info only requires connection-level auth (X-API-Key).
|
|
// Any authenticated client can call it without a tool execution token.
|
|
publicTool := mcp.NewTool(
|
|
"public_info",
|
|
mcp.WithDescription("Returns non-sensitive public information. Requires connection auth (X-API-Key) only."),
|
|
mcp.WithString("topic", mcp.Required(), mcp.Description("Topic to look up")),
|
|
)
|
|
s.AddTool(publicTool, publicInfoHandler)
|
|
|
|
// secret_data requires BOTH connection-level auth (X-API-Key) AND a
|
|
// dedicated tool-execution token (X-Tool-Token) checked inside the handler.
|
|
// In Bifrost both headers live in the same `headers` map and arrive on
|
|
// every request, so the handler reads X-Tool-Token from context and
|
|
// validates it independently of the connection credential.
|
|
secretTool := mcp.NewTool(
|
|
"secret_data",
|
|
mcp.WithDescription("Returns sensitive data. Requires connection auth (X-API-Key) AND tool-execution auth (X-Tool-Token)."),
|
|
mcp.WithString("resource", mcp.Required(), mcp.Description("Resource name to fetch")),
|
|
)
|
|
s.AddTool(secretTool, secretDataHandler)
|
|
|
|
httpServer := server.NewStreamableHTTPServer(s)
|
|
|
|
// Middleware chain (outermost = first to run):
|
|
// 1. connectionAuthMiddleware — rejects requests with a wrong/missing X-API-Key
|
|
// 2. injectHeadersMiddleware — stores the request headers in context so
|
|
// tool handlers can read them for tool-level auth
|
|
// 3. httpServer — the MCP server itself
|
|
handler := connectionAuthMiddleware(injectHeadersMiddleware(httpServer))
|
|
|
|
addr := "localhost:3002"
|
|
log.Printf("auth-demo-server listening on http://%s/", addr)
|
|
log.Printf("\nAuth layers:")
|
|
log.Printf(" Connection-level: X-API-Key: %s (middleware rejects all requests without it)", connectionAPIKey)
|
|
log.Printf(" Tool-execution: X-Tool-Token: %s (only secret_data checks this, validated inside the handler)", toolExecToken)
|
|
log.Printf("\nNote: Bifrost sends all `headers` on both connection setup AND every tool call.")
|
|
log.Printf("Both X-API-Key and X-Tool-Token go in the same `headers` map.")
|
|
log.Printf("The server enforces each at the right layer: middleware vs. handler.\n")
|
|
log.Printf("Bifrost config:")
|
|
log.Printf(`
|
|
{
|
|
"name": "auth_demo",
|
|
"connection_type": "http",
|
|
"connection_string": "http://%s/",
|
|
"auth_type": "headers",
|
|
"headers": {
|
|
"X-API-Key": "%s",
|
|
"X-Tool-Token": "%s"
|
|
},
|
|
"tools_to_execute": ["*"]
|
|
}
|
|
`, addr, connectionAPIKey, toolExecToken)
|
|
|
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
|
log.Fatalf("Server error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
|
|
// connectionAuthMiddleware enforces connection-level authentication.
|
|
// Every HTTP request — including initialize, tools/list, and tools/call —
|
|
// must carry the correct X-API-Key header. A missing or wrong key results
|
|
// in HTTP 401 before the MCP server processes anything.
|
|
func connectionAuthMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
key := r.Header.Get("X-API-Key")
|
|
if key == "" {
|
|
http.Error(w, "connection auth required: missing X-API-Key header", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if key != connectionAPIKey {
|
|
http.Error(w, "connection auth failed: invalid X-API-Key", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// injectHeadersMiddleware stores the raw HTTP request headers in the context
|
|
// so that tool handlers can read them for tool-level auth checks.
|
|
// This is needed because MCP tool handlers only receive (ctx, CallToolRequest)
|
|
// — they don't have direct access to the http.Request.
|
|
func injectHeadersMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.WithValue(r.Context(), requestHeadersKey, r.Header)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// ─── Tool handlers ────────────────────────────────────────────────────────────
|
|
|
|
// publicInfoHandler handles "public_info". Connection auth has already been
|
|
// verified by middleware, so no further auth check is needed here.
|
|
func publicInfoHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var args struct {
|
|
Topic string `json:"topic"`
|
|
}
|
|
if err := parseArgs(req, &args); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
return mcp.NewToolResultText(fmt.Sprintf(
|
|
"Public info about %q: this data is available to all authenticated clients.", args.Topic,
|
|
)), nil
|
|
}
|
|
|
|
// secretDataHandler handles "secret_data". Connection-level auth (X-API-Key)
|
|
// has already been verified by middleware. Here we additionally check
|
|
// X-Tool-Token — a separate secret dedicated to authorizing tool execution.
|
|
// Bifrost sends it as part of the same `headers` map, so it arrives on every
|
|
// request including this tool call; the middleware intentionally ignores it.
|
|
func secretDataHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
// ── Tool-execution token check ───────────────────────────────────────────
|
|
headers, ok := ctx.Value(requestHeadersKey).(http.Header)
|
|
if !ok {
|
|
return mcp.NewToolResultError("tool auth error: request headers unavailable in context"), nil
|
|
}
|
|
token := headers.Get("X-Tool-Token")
|
|
if token == "" {
|
|
return mcp.NewToolResultError("tool auth required: missing X-Tool-Token header"), nil
|
|
}
|
|
if token != toolExecToken {
|
|
return mcp.NewToolResultError("tool auth failed: invalid X-Tool-Token"), nil
|
|
}
|
|
// ── Auth passed, proceed ─────────────────────────────────────────────────
|
|
|
|
var args struct {
|
|
Resource string `json:"resource"`
|
|
}
|
|
if err := parseArgs(req, &args); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
return mcp.NewToolResultText(fmt.Sprintf(
|
|
"Secret data for resource %q: [classified content — X-API-Key + X-Tool-Token verified]", args.Resource,
|
|
)), nil
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func parseArgs(req mcp.CallToolRequest, dst any) error {
|
|
b, err := json.Marshal(req.Params.Arguments)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal arguments: %w", err)
|
|
}
|
|
if err := json.Unmarshal(b, dst); err != nil {
|
|
return fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
return nil
|
|
}
|