diff --git a/docs/AUTHORIZATION.md b/docs/AUTHORIZATION.md new file mode 100644 index 00000000..b0b624c9 --- /dev/null +++ b/docs/AUTHORIZATION.md @@ -0,0 +1,221 @@ +# Authorization Architecture + +This document describes how MCP Gateway handles authorization per the MCP specification (2025-03-26). + +## MCP Specification Compliance + +The gateway implements OAuth 2.1-compliant authorization for HTTP-based transports: + +### HTTP Status Codes + +Per MCP spec, the following HTTP status codes are used: + +| Status Code | Description | Usage | +|-------------|-------------|-------| +| 400 Bad Request | Malformed authorization request | - Missing "Bearer " prefix
- Empty token
- Token in query string | +| 401 Unauthorized | Authorization required or token invalid | - Missing Authorization header
- Invalid/expired token | +| 403 Forbidden | Valid token but insufficient permissions | Not yet implemented (requires OAuth scopes) | + +### Authorization Header Format + +**Required Format:** `Authorization: Bearer ` + +- **MUST** use "Bearer " prefix (case-sensitive) +- **MUST NOT** include tokens in URI query strings +- Plain API keys without "Bearer " prefix are **rejected** (returns 400) + +## Two-Layer Authorization Architecture + +The gateway uses a two-layer authorization approach: + +### Layer 1: HTTP Authentication (authMiddleware) + +**Purpose:** Validate that the request includes a valid API key + +**Location:** `internal/server/auth.go` - `authMiddleware()` + +**Behavior:** +- Applied to MCP endpoints when `--api-key` flag is set +- Validates `Authorization: Bearer ` header +- Compares token against configured API key +- Returns 401 if token is missing or invalid +- Returns 400 if header is malformed + +**When Applied:** +- `/mcp` endpoint (unified mode) +- `/mcp/{serverID}` endpoints (routed mode) +- `/close` endpoint + +**Not Applied:** +- `/health` endpoint (always public) +- `/.well-known/oauth-authorization-server` endpoint + +### Layer 2: Session Identification (Transport Layer) + +**Purpose:** Extract Bearer token to use as session ID for request routing + +**Location:** +- `internal/server/transport.go` - `CreateHTTPServerForMCP()` +- `internal/server/routed.go` - `CreateHTTPServerForRoutedMode()` + +**Behavior:** +- Extracts Bearer token from `Authorization` header +- Uses token as session ID to group requests from same client +- Rejects connections without Bearer token (returns nil server) +- **Does NOT validate token value** (that's Layer 1's job) + +**Session Management:** +- Same Bearer token = same session +- Session persists across multiple MCP requests +- DIFC labels accumulate within a session + +## Authorization Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1. Client Request │ +│ POST /mcp/github │ +│ Authorization: Bearer secret123 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. HTTP Auth Layer (if API key configured) │ +│ - Check Bearer format (400 if wrong) │ +│ - Validate token == apiKey (401 if wrong) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. Transport Layer │ +│ - Extract Bearer token → session ID │ +│ - Reject if no token (return nil) │ +│ - Store session ID in context │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. MCP Protocol Handling │ +│ - Route to backend server │ +│ - Execute tool calls │ +│ - Apply DIFC policies (if enabled) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Configuration + +### Without API Key (Development Mode) + +```bash +./awmg --config config.toml +``` + +- **HTTP Auth Layer:** Disabled (all requests accepted) +- **Transport Layer:** Still requires Bearer token for session ID +- **Behavior:** Clients must send `Authorization: Bearer ` for session management + +### With API Key (Production Mode) + +```bash +./awmg --config config.toml --api-key secret123 +``` + +Or via environment: +```bash +MCP_GATEWAY_API_KEY=secret123 ./awmg --config config.toml +``` + +- **HTTP Auth Layer:** Enabled (validates token == apiKey) +- **Transport Layer:** Uses validated Bearer token as session ID +- **Behavior:** Clients must send `Authorization: Bearer secret123` (exact match required) + +## Bearer Token Usage Patterns + +### Pattern 1: Development (No API Key) + +``` +Client A: Authorization: Bearer dev-session-1 +Client B: Authorization: Bearer dev-session-2 +``` + +- Both accepted (no validation) +- Separate sessions maintained + +### Pattern 2: Production (With API Key) + +``` +Client A: Authorization: Bearer secret123 ✅ Accepted +Client B: Authorization: Bearer wrong-key ❌ 401 Unauthorized +``` + +- Only matching API key accepted +- All clients share same session (same token) + +### Pattern 3: Multi-Agent (Future) + +When OAuth scopes are added: + +``` +Agent A: Authorization: Bearer token-with-read-scope ✅ Read operations only +Agent B: Authorization: Bearer token-with-write-scope ✅ Read + Write operations +Agent C: Authorization: Bearer token-no-scopes ❌ 403 Forbidden +``` + +## Why Two Layers? + +The separation provides flexibility: + +1. **Development**: No API key needed, but still requires Bearer token for session management +2. **Production**: API key validation ensures only authorized clients can connect +3. **Future**: OAuth scopes can be added to auth layer without changing transport layer + +## Testing + +See `internal/server/auth_test.go` for comprehensive test coverage: + +- ✅ Bearer token format validation +- ✅ Query string rejection +- ✅ Empty token rejection +- ✅ Invalid token rejection +- ✅ Case sensitivity +- ✅ Whitespace handling + +## Limitations + +### Current Limitations + +1. **Simple API Key Only:** No OAuth scopes or fine-grained permissions +2. **Single API Key:** All clients share same token in production +3. **No Token Expiration:** Tokens don't expire (restart required to change) +4. **No HTTP 403:** All auth failures return 401 or 400 (no permission checks) + +### Future Enhancements + +1. **OAuth Scopes:** Add scope-based permissions (read, write, admin) +2. **Multiple Tokens:** Support different tokens for different clients +3. **Token Expiration:** JWT with expiry claims +4. **HTTP 403 Support:** Return 403 when valid token lacks required scopes +5. **Dynamic Client Registration:** Support RFC7591 for automatic client registration + +## Security Considerations + +### ✅ Compliant + +- Bearer token required for all MCP operations +- Tokens not allowed in query strings (prevents log/history leaks) +- Strict format validation (prevents format confusion attacks) +- Case-sensitive Bearer prefix (prevents case-folding attacks) + +### ⚠️ Recommendations + +- **Always use HTTPS in production** (Bearer tokens in plaintext) +- **Use strong random tokens** (at least 32 bytes of entropy) +- **Rotate tokens regularly** (restart gateway with new token) +- **Restrict token exposure** (don't log full token values) + +## References + +- [MCP Specification 2025-03-26](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx) +- [OAuth 2.1 IETF Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12) +- [RFC8414 - OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) +- [RFC7591 - OAuth 2.0 Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) diff --git a/internal/server/auth.go b/internal/server/auth.go index 24870cc8..25ab99b1 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -9,36 +9,59 @@ import ( "github.com/githubnext/gh-aw-mcpg/internal/logger" ) -// authMiddleware implements API key authentication per spec section 7.1 +// authMiddleware implements API key authentication per MCP spec 2025-03-26 Authorization section +// Spec requirement: Access tokens MUST use the Authorization header field in format "Bearer " +// For HTTP-based transports, implementations SHOULD conform to OAuth 2.1 +// +// HTTP Status Codes per MCP Spec: +// - 400 Bad Request: Malformed authorization request (wrong format, token in query string) +// - 401 Unauthorized: Authorization required or token invalid/expired +// - 403 Forbidden: Valid token but insufficient permissions/scopes (not yet implemented) +// +// Note: HTTP 403 is currently not used because this gateway uses simple API key validation +// without scope-based permissions. When OAuth scopes are added in the future, 403 should be +// returned when a valid token lacks the required scopes for the requested operation. func authMiddleware(apiKey string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // MCP Spec: Access tokens MUST NOT be included in URI query strings + if r.URL.Query().Get("token") != "" || r.URL.Query().Get("access_token") != "" || r.URL.Query().Get("apiKey") != "" { + logger.LogError("auth", "Authentication failed: token in query string, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) + logRuntimeError("authentication_failed", "token_in_query_string", r, nil) + http.Error(w, "Bad Request: tokens must not be included in query string", http.StatusBadRequest) + return + } + // Extract Authorization header authHeader := r.Header.Get("Authorization") if authHeader == "" { - // Spec 7.1: Missing token returns 401 + // MCP Spec: Missing token returns 401 logger.LogError("auth", "Authentication failed: missing Authorization header, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) logRuntimeError("authentication_failed", "missing_auth_header", r, nil) http.Error(w, "Unauthorized: missing Authorization header", http.StatusUnauthorized) return } - // Spec 7.1: Malformed header returns 400 - var token string - if strings.HasPrefix(authHeader, "Bearer ") { - // Bearer token: extract the token after the prefix - token = strings.TrimPrefix(authHeader, "Bearer ") - } else if authHeader == apiKey { - // Plain API key: use the header value directly - token = authHeader - } else { - // Header is neither a Bearer token nor a valid plain API key - logger.LogError("auth", "Authentication failed: malformed Authorization header, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) + // MCP Spec: Authorization header MUST be in format "Bearer " + if !strings.HasPrefix(authHeader, "Bearer ") { + logger.LogError("auth", "Authentication failed: malformed Authorization header (missing 'Bearer ' prefix), remote=%s, path=%s", r.RemoteAddr, r.URL.Path) logRuntimeError("authentication_failed", "malformed_auth_header", r, nil) - http.Error(w, "Bad Request: Authorization header must be 'Bearer ' or plain API key", http.StatusBadRequest) + http.Error(w, "Bad Request: Authorization header must be 'Bearer '", http.StatusBadRequest) + return + } + + // Extract token after "Bearer " prefix + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + + if token == "" { + // Empty token after Bearer prefix + logger.LogError("auth", "Authentication failed: empty token after Bearer prefix, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) + logRuntimeError("authentication_failed", "empty_token", r, nil) + http.Error(w, "Bad Request: Bearer token cannot be empty", http.StatusBadRequest) return } - // Spec 7.1: Invalid token returns 401 + + // MCP Spec: Invalid or expired tokens MUST receive HTTP 401 if token != apiKey { logger.LogError("auth", "Authentication failed: invalid API key, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) logRuntimeError("authentication_failed", "invalid_token", r, nil) diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 00000000..8c9a77ff --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,255 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestAuthMiddleware_MissingHeader tests that missing Authorization header returns 401 +func TestAuthMiddleware_MissingHeader(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + // No Authorization header set + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } + + body := w.Body.String() + if body != "Unauthorized: missing Authorization header\n" { + t.Errorf("Unexpected error message: %s", body) + } +} + +// TestAuthMiddleware_ValidBearerToken tests that valid Bearer token is accepted +func TestAuthMiddleware_ValidBearerToken(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if w.Body.String() != "success" { + t.Errorf("Handler was not called") + } +} + +// TestAuthMiddleware_InvalidBearerToken tests that invalid Bearer token returns 401 +func TestAuthMiddleware_InvalidBearerToken(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } + + body := w.Body.String() + if body != "Unauthorized: invalid API key\n" { + t.Errorf("Unexpected error message: %s", body) + } +} + +// TestAuthMiddleware_PlainAPIKeyNotAccepted tests that plain API key (without Bearer) returns 400 +// Per MCP spec 2025-03-26: "Access tokens MUST use the Authorization header field in format 'Bearer '" +func TestAuthMiddleware_PlainAPIKeyNotAccepted(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", apiKey) // Plain key without "Bearer " prefix + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } + + body := w.Body.String() + if body != "Bad Request: Authorization header must be 'Bearer '\n" { + t.Errorf("Unexpected error message: %s", body) + } +} + +// TestAuthMiddleware_EmptyBearerToken tests that empty token after Bearer prefix returns 400 +func TestAuthMiddleware_EmptyBearerToken(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer ") // Empty token + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } + + body := w.Body.String() + if body != "Bad Request: Bearer token cannot be empty\n" { + t.Errorf("Unexpected error message: %s", body) + } +} + +// TestAuthMiddleware_BearerTokenWithWhitespace tests that Bearer token with whitespace is trimmed +func TestAuthMiddleware_BearerTokenWithWhitespace(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+apiKey+" ") // Extra whitespace + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 (whitespace should be trimmed), got %d", w.Code) + } +} + +// TestAuthMiddleware_TokenInQueryString tests that tokens in query string are rejected +// Per MCP spec 2025-03-26: "Access tokens MUST NOT be included in URI query strings" +func TestAuthMiddleware_TokenInQueryString(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + tests := []struct { + name string + path string + }{ + {"token parameter", "/test?token=secret123"}, + {"access_token parameter", "/test?access_token=secret123"}, + {"apiKey parameter", "/test?apiKey=secret123"}, + {"mixed with other params", "/test?foo=bar&token=secret123&baz=qux"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } + + body := w.Body.String() + if body != "Bad Request: tokens must not be included in query string\n" { + t.Errorf("Unexpected error message: %s", body) + } + }) + } +} + +// TestAuthMiddleware_CaseSensitiveBearer tests that "bearer" (lowercase) is not accepted +func TestAuthMiddleware_CaseSensitiveBearer(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "bearer "+apiKey) // lowercase "bearer" + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d (Bearer must be capitalized)", w.Code) + } +} + +// TestAuthMiddleware_BasicAuthNotAccepted tests that Basic auth is not accepted +func TestAuthMiddleware_BasicAuthNotAccepted(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0") // Base64 encoded "test:test" + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestAuthMiddleware_MultipleAuthHeaders tests behavior with multiple Authorization headers +func TestAuthMiddleware_MultipleAuthHeaders(t *testing.T) { + apiKey := "test-secret-key" + + handler := authMiddleware(apiKey, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + // Add multiple Authorization headers (only first is used by http.Header.Get) + req.Header.Add("Authorization", "Bearer "+apiKey) + req.Header.Add("Authorization", "Bearer wrong-token") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Should use the first header value + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 (first header should be used), got %d", w.Code) + } +} diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go index e8d3fdc0..ee649c7b 100644 --- a/internal/server/integration_test.go +++ b/internal/server/integration_test.go @@ -659,9 +659,9 @@ func TestCloseEndpoint_Integration(t *testing.T) { t.Log("✓ Close endpoint requires authentication when API key is configured") - // Request with auth should succeed + // Request with auth should succeed (using Bearer token format per MCP spec) req2, _ := http.NewRequest(http.MethodPost, ts.URL+"/close", nil) - req2.Header.Set("Authorization", apiKey) + req2.Header.Set("Authorization", "Bearer "+apiKey) resp2, err := http.DefaultClient.Do(req2) if err != nil { t.Fatalf("Failed to call /close with auth: %v", err) diff --git a/internal/server/routed.go b/internal/server/routed.go index ef5b19d7..2e6207aa 100644 --- a/internal/server/routed.go +++ b/internal/server/routed.go @@ -44,8 +44,18 @@ func CreateHTTPServerForRoutedMode(addr string, unifiedServer *UnifiedServer, ap route := fmt.Sprintf("/mcp/%s", backendID) // Create StreamableHTTP handler for this route + // + // IMPORTANT: This callback is for SESSION IDENTIFICATION, not authentication. + // Authentication (token validation) happens in authMiddleware if API key is configured. + // This layer only extracts the Bearer token to use as a session ID for request routing. + // + // Two-layer architecture: + // 1. authMiddleware (below): Validates token == apiKey (if configured) + // 2. This callback: Extracts token → session ID (always required) routeHandler := sdk.NewStreamableHTTPHandler(func(r *http.Request) *sdk.Server { - // Extract Bearer token from Authorization header + // Extract Bearer token from Authorization header (for session identification) + // NOTE: Token validation happens in authMiddleware if API key is configured. + // This layer accepts any non-empty Bearer token as a session ID. authHeader := r.Header.Get("Authorization") var sessionID string @@ -54,7 +64,7 @@ func CreateHTTPServerForRoutedMode(addr string, unifiedServer *UnifiedServer, ap sessionID = strings.TrimSpace(sessionID) } - // Reject requests without valid Bearer token + // Reject requests without Bearer token (required for session management) if sessionID == "" { logger.LogError("client", "Rejected MCP client connection: no Bearer token, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) log.Printf("[%s] %s %s - REJECTED: No Bearer token", r.RemoteAddr, r.Method, r.URL.Path) @@ -91,7 +101,8 @@ func CreateHTTPServerForRoutedMode(addr string, unifiedServer *UnifiedServer, ap Stateless: false, }) - // Apply auth middleware if API key is configured (spec 7.1) + // Apply auth middleware if API key is configured (MCP spec 2025-03-26) + // This validates that the Bearer token matches the configured API key var finalHandler http.Handler = routeHandler if apiKey != "" { finalHandler = authMiddleware(apiKey, routeHandler.ServeHTTP) diff --git a/internal/server/routed_test.go b/internal/server/routed_test.go index aed6771f..de9aee41 100644 --- a/internal/server/routed_test.go +++ b/internal/server/routed_test.go @@ -177,9 +177,9 @@ func TestCloseEndpoint_RequiresAuth(t *testing.T) { t.Errorf("Expected status 401 (Unauthorized), got %d", w.Code) } - // Request with correct auth header + // Request with correct auth header (using Bearer token format per MCP spec) req2 := httptest.NewRequest(http.MethodPost, "/close", nil) - req2.Header.Set("Authorization", apiKey) + req2.Header.Set("Authorization", "Bearer "+apiKey) w2 := httptest.NewRecorder() httpServer.Handler.ServeHTTP(w2, req2) diff --git a/internal/server/transport.go b/internal/server/transport.go index 8a7b558d..4ae883f2 100644 --- a/internal/server/transport.go +++ b/internal/server/transport.go @@ -88,13 +88,23 @@ func CreateHTTPServerForMCP(addr string, unifiedServer *UnifiedServer, apiKey st // Create StreamableHTTP handler for MCP protocol (supports POST requests) // This is what Codex uses with transport = "streamablehttp" + // + // IMPORTANT: This callback is for SESSION IDENTIFICATION, not authentication. + // Authentication (token validation) happens in authMiddleware if API key is configured. + // This layer only extracts the Bearer token to use as a session ID for request routing. + // + // Two-layer architecture: + // 1. authMiddleware (below): Validates token == apiKey (if configured) + // 2. This callback: Extracts token → session ID (always required) streamableHandler := sdk.NewStreamableHTTPHandler(func(r *http.Request) *sdk.Server { // With SSE, this callback fires ONCE per HTTP connection establishment // All subsequent JSON-RPC messages come over the same persistent connection // We use the Bearer token from Authorization header as the session ID - // This groups all routes from the same agent (same token) into one session + // This groups all requests from the same agent (same token) into one session - // Extract Bearer token from Authorization header + // Extract Bearer token from Authorization header (for session identification) + // NOTE: Token validation happens in authMiddleware if API key is configured. + // This layer accepts any non-empty Bearer token as a session ID. authHeader := r.Header.Get("Authorization") var sessionID string @@ -103,7 +113,7 @@ func CreateHTTPServerForMCP(addr string, unifiedServer *UnifiedServer, apiKey st sessionID = strings.TrimSpace(sessionID) } - // Reject requests without valid Bearer token + // Reject requests without Bearer token (required for session management) if sessionID == "" { logger.LogError("client", "MCP connection rejected: no Bearer token, remote=%s, path=%s", r.RemoteAddr, r.URL.Path) log.Printf("[%s] %s %s - REJECTED: No Bearer token", r.RemoteAddr, r.Method, r.URL.Path) @@ -142,7 +152,8 @@ func CreateHTTPServerForMCP(addr string, unifiedServer *UnifiedServer, apiKey st Stateless: false, // Support stateful sessions }) - // Apply auth middleware if API key is configured (spec 7.1) + // Apply auth middleware if API key is configured (MCP spec 2025-03-26) + // This validates that the Bearer token matches the configured API key var finalHandler http.Handler = streamableHandler if apiKey != "" { finalHandler = authMiddleware(apiKey, streamableHandler.ServeHTTP)