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)