Skip to content

feat: move JWT auth from _token parameter to HTTP Authorization header #43

@manzil-infinity180

Description

@manzil-infinity180

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

  1. Namespace pollution: The _token key occupies the tool's argument namespace. If a tool ever needs a legitimate _token parameter, there's a conflict. The _ prefix convention is fragile.

  2. Not standard MCP: The MCP specification doesn't define per-request auth tokens in tool arguments. The standard approach for HTTP transports is the Authorization header.

  3. 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.

  4. Stdio mode can't use headers: In stdio mode (JSON-RPC over stdin/stdout), there are no HTTP headers. The _token approach 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-go to 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions