-
Notifications
You must be signed in to change notification settings - Fork 1
feat: move JWT auth from _token parameter to HTTP Authorization header #43
Description
What Exists
JWT tokens are passed via a _token field inside the tool call arguments object (#19):
{
"method": "tools/call",
"params": {
"name": "bash",
"arguments": {
"command": "echo hello",
"_token": "eyJhbGciOiJFUzI1NiIs..."
}
}
}Validation happens inside each tool handler via s.validateJWT(request), which extracts _token from request.GetArguments().
What's Wrong With This
-
Namespace pollution: The
_tokenkey occupies the tool's argument namespace. If a tool ever needs a legitimate_tokenparameter, there's a conflict. The_prefix convention is fragile. -
Not standard MCP: The MCP specification doesn't define per-request auth tokens in tool arguments. The standard approach for HTTP transports is the
Authorizationheader. -
Leaks into attestations: Tool inputs are recorded in attestation predicates (
ToolInput: inputJSON). The JWT token string ends up in the signed attestation, which is unnecessary and increases attestation size. -
Stdio mode can't use headers: In stdio mode (JSON-RPC over stdin/stdout), there are no HTTP headers. The
_tokenapproach works for both transports, which is why it was chosen. A header-based approach would only work for HTTP/SSE mode.
Proposed Fix
For HTTP/SSE mode
Add HTTP middleware in ServeHTTP() that extracts Authorization: Bearer <token> and validates before the request reaches tool handlers:
func (s *Server) ServeHTTP(policyPath string, port int) error {
// ... existing setup ...
sseServer := server.NewSSEServer(s.mcpServer)
// Wrap with auth middleware
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.tokenIssuer != nil {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token != "" {
claims, err := s.tokenIssuer.ValidateTokenForSession(token, s.sessionID)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
// Store claims in request context for tool handlers
ctx := context.WithValue(r.Context(), authClaimsKey, claims)
r = r.WithContext(ctx)
}
}
sseServer.ServeHTTP(w, r)
})
return http.ListenAndServe(addr, handler)
}Blocker: The mcp-go library's server.SSEServer doesn't propagate the http.Request context to tool handlers. This would need either:
- A PR to
mark3labs/mcp-goto pass request context through - A custom SSE server wrapper that injects context
For stdio mode
Keep _token in arguments as a fallback, or introduce a JSON-RPC extension:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "bash",
"arguments": { "command": "echo hello" },
"_meta": { "token": "eyJ..." }
}
}The _meta pattern is used by some MCP extensions for transport-level metadata.
Hybrid approach
Support both: check Authorization header first, fall back to _token in arguments, fall back to _meta.token. Remove _token from arguments before recording in attestations.
Related
- feat: implement JWT-based agent authorization #19 — JWT implementation (current
_tokenapproach) - fix: hook lifecycle has five fail-open bypass paths #27 — HTTP MCP security