diff --git a/AGENTS.md b/AGENTS.md index 032fef5..5e9a5ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,20 +288,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ ### Authentication Flow -**Two Types of Auth**: +**Three Types of Auth**: 1. **Bearer Token** (AUTH_TOKEN): - Static token for Lambda authentication - Generated during deployment - Validates client requests to Lambda function - Stored in environment variable + - Required for `/mcp` JSON-RPC endpoint -2. **OAuth Tokens** (Strava): +2. **Authless Mode** (ALLOW_AUTHLESS): + - When `ALLOW_AUTHLESS=true` (default), SSE endpoints bypass auth + - Enables Claude.ai custom connectors (which don't support custom headers) + - Applies to: `/sse`, `/sse/`, `/message` endpoints + - Does NOT apply to: `/mcp` endpoint (still requires Bearer token) + - Security: Keep Lambda URL private when enabled + +3. **OAuth Tokens** (Strava): - Access token (short-lived, ~6 hours) - Refresh token (long-lived) - Automatically managed by StravaClient - Stored in environment variables (CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN) +**Authentication Middleware Logic**: +```typescript +// Simplified auth flow +if (path === '/health') → bypass auth +if (ALLOW_AUTHLESS && (path === '/sse' || path === '/message')) → bypass auth +if (path === '/message' && valid session) → bypass auth +else → require Bearer token +``` + ### Local Development Workflow ```bash @@ -539,6 +556,7 @@ sam delete --stack-name strava-mcp-stack # Delete deployment - `STRAVA_CLIENT_SECRET` - Strava API client secret - `STRAVA_REFRESH_TOKEN` - Strava OAuth refresh token - `AUTH_TOKEN` - Bearer token for Lambda authentication +- `ALLOW_AUTHLESS` - When "true", SSE endpoints bypass auth (default: "true") - `NODE_ENV` - Environment (production/development) ### File Locations diff --git a/README.md b/README.md index a6f09dc..b3426b9 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,32 @@ Need to see the config again? Run: bun run deploy:show-config ``` +### 5. Claude.ai Custom Connectors (Web/Mobile) + +Claude.ai custom connectors use **authless mode** via SSE transport. This is enabled by default (`ALLOW_AUTHLESS=true`). + +**Setup Steps:** +1. Go to **Claude.ai** → **Settings** → **Connectors** → **Add custom connector** +2. Enter just the **base URL** (no path): `https://your-function-url.lambda-url.us-east-1.on.aws` +3. Claude.ai will automatically discover the `/sse` endpoint and connect +4. Verify tools appear and can be invoked + +**Important Notes:** +- Claude.ai does NOT support custom headers (no Bearer token auth) +- Authless mode only applies to SSE endpoints (`/sse`, `/message`) +- The `/mcp` JSON-RPC endpoint still requires Bearer token authentication +- Keep your Lambda Function URL private for security + +**Security Considerations:** +- Authless mode is **high risk** and should be treated as **personal-use-only** +- If authless mode is enabled, anyone with your Lambda URL can access and act on your Strava data +- Authless mode is **not recommended for production**; if you must use it, combine it with stricter controls such as AWS WAF IP allowlisting or an additional shared secret + +**Recommended secure deployment (authless disabled, Bearer tokens required everywhere):** +```bash +sam deploy --parameter-overrides AllowAuthless=false +``` + ## Documentation 📚 **[Full Documentation](https://stealinglight.github.io/StravaMCP)** diff --git a/docs/authentication.md b/docs/authentication.md index f57a3eb..bf6bf92 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -11,7 +11,13 @@ The Lambda function uses **Bearer token authentication** to secure access to you - ✅ Works with Claude web, mobile, and desktop - ✅ Easy to rotate tokens when needed -## How It Works +## Authentication Modes + +The server supports two authentication modes: + +### 1. Bearer Token Authentication (Default for `/mcp` endpoint) + +For Claude Desktop and other MCP clients that support custom headers. ``` ┌──────────────┐ @@ -31,6 +37,33 @@ The Lambda function uses **Bearer token authentication** to secure access to you └──────────────┘ ``` +### 2. Authless Mode (For Claude.ai Custom Connectors) + +Claude.ai custom connectors only support authless or OAuth 2.1 (DCR) connections. Since OAuth 2.1 with DCR is complex to implement, this server provides an **authless mode** for SSE endpoints. + +When `ALLOW_AUTHLESS=true` (default), the SSE transport endpoints (`/sse`, `/sse/`, `/message`) bypass authentication, allowing Claude.ai to connect without Bearer tokens. + +``` +┌──────────────┐ +│ Claude.ai │ +│ (Web/Mobile) │ +└──────┬───────┘ + │ SSE connection (no auth required) + ▼ +┌──────────────┐ +│ Lambda │─── ALLOW_AUTHLESS=true +│ (Middleware)│ Skips auth for SSE +└──────┬───────┘ + │ Direct access + ▼ +┌──────────────┐ +│ MCP Server │ +│ (Strava API) │ +└──────────────┘ +``` + +**Important**: The `/mcp` JSON-RPC endpoint still requires Bearer token authentication for backward compatibility with other clients. + ## Setup Steps ### 1. Deploy and Auto-Generate AUTH_TOKEN @@ -116,6 +149,20 @@ Add as a Custom Connector in Claude Settings: **Important**: The URL must end with `/mcp` - this is the MCP endpoint. +#### Claude.ai Custom Connectors (Authless Mode) + +For Claude.ai web and mobile, use the authless SSE transport: + +1. Go to **Claude.ai** → **Settings** → **Connectors** → **Add custom connector** +2. Enter just the **base URL** (no `/mcp` or `/sse` path): + ``` + https://your-function-url.lambda-url.us-east-1.on.aws + ``` +3. Claude.ai will automatically discover the `/sse` endpoint +4. Verify tools appear in the conversation + +**Note**: Authless mode is enabled by default (`ALLOW_AUTHLESS=true`). Claude.ai cannot send custom headers, so Bearer token auth is not supported for Claude.ai connectors. + ### 4. Test Authentication Test that authentication is working: @@ -171,6 +218,51 @@ To rotate your authentication token: - Use short or predictable tokens - Reuse tokens across multiple services +## Authless Mode Configuration + +### Enabling Authless Mode (Default) + +Authless mode is **enabled by default** for Claude.ai compatibility. The `ALLOW_AUTHLESS` parameter is set to `"true"` in the SAM template. + +When enabled: +- SSE endpoints (`/sse`, `/sse/`, `/message`) do not require authentication +- The `/mcp` JSON-RPC endpoint still requires Bearer token authentication +- Claude.ai custom connectors can connect using the base URL + +### Disabling Authless Mode + +To require Bearer token authentication for all endpoints: + +```bash +# During deployment +sam deploy --parameter-overrides AllowAuthless=false + +# Or update samconfig.toml +[default.deploy.parameters] +parameter_overrides = [ + "AllowAuthless=false", + # ... other parameters +] +``` + +When disabled: +- All endpoints require Bearer token authentication +- Claude.ai custom connectors will NOT work (they cannot send custom headers) +- Only Claude Desktop and clients supporting custom headers can connect + +### Security Implications + +**Authless mode enabled (`ALLOW_AUTHLESS=true`, advanced / opt-in):** +- ⚠️ Not enabled by default. Enabling this on a publicly accessible Lambda URL exposes `/sse`, `/sse/`, and `/message` without authentication. +- ⚠️ Anyone who learns your Lambda URL (via logs, screenshots, or misconfiguration) can access your Strava-backed tools via SSE; URL secrecy alone is **not** sufficient protection. +- ✅ Only enable for strictly local or personal use where you fully control access to the URL. +- 💡 If you must use authless mode in a cloud environment, combine it with additional controls such as AWS WAF/IP allowlisting and/or an extra shared secret (e.g., a query parameter or header checked by your Lambda). + +**Authless mode disabled (`ALLOW_AUTHLESS=false`, recommended default):** +- ✅ All requests require a valid Bearer token +- ❌ Claude.ai custom connectors won't work +- ✅ Maximum security for shared, team, or production environments + ## Security Considerations ### What This Protects diff --git a/src/config/env.ts b/src/config/env.ts index 0cc7bab..a5d227f 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -12,6 +12,9 @@ const envSchema = z.object({ STRAVA_REFRESH_TOKEN: z.string().min(1, 'STRAVA_REFRESH_TOKEN is required'), AUTH_TOKEN: z.string().min(32, 'AUTH_TOKEN must be at least 32 characters'), PORT: z.string().default('3000').transform(Number), + // ALLOW_AUTHLESS: When "true", bypasses auth for SSE endpoints (/sse, /sse/, /message) + // This enables Claude.ai custom connectors which don't support Bearer token auth + ALLOW_AUTHLESS: z.string().default('true').transform((val) => val.toLowerCase() === 'true'), }); /** @@ -25,6 +28,7 @@ export function getConfig() { STRAVA_REFRESH_TOKEN: process.env.STRAVA_REFRESH_TOKEN, AUTH_TOKEN: process.env.AUTH_TOKEN, PORT: process.env.PORT, + ALLOW_AUTHLESS: process.env.ALLOW_AUTHLESS, }); if (!result.success) { diff --git a/src/index.ts b/src/index.ts index 16bf1f2..7d862ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,7 @@ try { const server = new Server( { name: 'strava-mcp-server', - version: '1.0.0', + version: '3.0.0', }, { capabilities: { @@ -325,20 +325,27 @@ async function main() { // Authentication middleware for local dev (optional - can be disabled) // Supports both Authorization header and query parameter + // When ALLOW_AUTHLESS=true, SSE endpoints bypass auth for Claude.ai custom connectors app.use((req: Request, res: Response, next) => { - if (req.path === '/health') { + if (req.path === '/health' || req.path === '/debug') { return next(); } - // For SSE endpoint, authentication happens via query param or header - if (req.path === '/sse' || req.path === '/sse/') { - // SSE authentication happens here before establishing connection - // Continue to token validation below + // Check if authless mode is enabled for SSE endpoints + // This allows Claude.ai custom connectors to connect without Bearer tokens + const isSSEEndpoint = req.path === '/sse' || req.path === '/sse/'; + const isMessageEndpoint = req.path === '/message'; + + if (config.ALLOW_AUTHLESS && (isSSEEndpoint || isMessageEndpoint)) { + if (isSSEEndpoint) { + console.error('[StravaServer] Authless SSE connection allowed (ALLOW_AUTHLESS=true)'); + } + return next(); } - // For SSE message endpoint, trust valid session IDs + // For SSE message endpoint with valid session, trust the session const sessionId = req.query.sessionId as string; - if (req.path === '/message' && sessionId && transports[sessionId]) { + if (isMessageEndpoint && sessionId && transports[sessionId]) { return next(); } @@ -346,29 +353,63 @@ async function main() { // If AUTH_TOKEN is set in .env, validate it if (config.AUTH_TOKEN && config.AUTH_TOKEN.length > 0) { let token: string | undefined; - + const authHeader = req.headers['authorization']; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.substring(7); } else if (req.query.token) { token = req.query.token as string; } - + if (!token || token !== config.AUTH_TOKEN) { console.error('[StravaServer] Invalid or missing token'); - return res.status(401).json({ + return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing token' }); } } - + next(); }); - // Health check endpoint + // Health check endpoint - enhanced with diagnostic info app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'healthy', version: '2.0.0' }); + res.json({ + status: 'healthy', + version: '3.0.0', + runtime: 'local', + authless: config?.ALLOW_AUTHLESS ?? false, + timestamp: new Date().toISOString(), + }); + }); + + // Debug endpoint - helps troubleshoot Claude.ai connection issues + app.get('/debug', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + version: '3.0.0', + authless_enabled: config?.ALLOW_AUTHLESS ?? false, + environment: process.env.NODE_ENV || 'development', + endpoints: { + health: '/health', + debug: '/debug', + sse: '/sse (GET, establishes SSE connection)', + message: '/message (POST, requires sessionId query param)', + mcp: '/mcp (POST, requires Bearer token)', + }, + claude_ai_setup: { + connector_url: `Use base URL only: http://localhost:${config.PORT}`, + auth_mode: config?.ALLOW_AUTHLESS ? 'Authless (SSE endpoints bypass auth)' : 'Bearer token required', + transport: 'SSE', + note: 'For local testing with Claude.ai, use ngrok or similar to expose localhost', + }, + sse_headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); }); // SSE endpoint - establishes the server-to-client event stream @@ -453,7 +494,7 @@ async function main() { }, serverInfo: { name: 'strava-mcp-server', - version: '2.0.0', + version: '3.0.0', }, }; break; @@ -674,7 +715,10 @@ async function main() { app.listen(port, () => { console.error(`[StravaServer] Remote MCP server running on http://localhost:${port}`); console.error(`[StravaServer] MCP endpoint: http://localhost:${port}/mcp`); + console.error(`[StravaServer] SSE endpoint: http://localhost:${port}/sse`); console.error(`[StravaServer] Health check: http://localhost:${port}/health`); + console.error(`[StravaServer] Debug info: http://localhost:${port}/debug`); + console.error(`[StravaServer] Authless mode: ${config.ALLOW_AUTHLESS ? 'ENABLED' : 'DISABLED'}`); }); } diff --git a/src/lambda.ts b/src/lambda.ts index faf4a88..0f6f4b0 100644 --- a/src/lambda.ts +++ b/src/lambda.ts @@ -73,6 +73,19 @@ import { * * Wraps the Express-based MCP server for AWS Lambda using serverless-express. * Implements JSON-RPC over HTTP transport for Claude connector compatibility. + * + * SSE STREAMING NOTE: + * The @codegenie/serverless-express library does NOT support Lambda response streaming + * (see https://github.com/CodeGenieApp/serverless-express/issues/655). + * While the Lambda Function URL is configured with InvokeMode: RESPONSE_STREAM, + * the serverless-express wrapper buffers responses before sending. + * + * CURRENT WORKAROUND: The /mcp JSON-RPC endpoint works reliably for all clients. + * Claude.ai should connect to the base URL and use SSE, but if SSE doesn't work, + * alternative approaches include: + * 1. AWS Lambda Web Adapter (https://github.com/awslabs/aws-lambda-web-adapter) + * 2. Direct Lambda streaming without Express wrapper for SSE endpoints + * 3. Using awslambda.streamifyResponse() for native streaming support */ // Initialize configuration @@ -89,27 +102,39 @@ function initializeServer(): express.Application { const transports: Record = {}; // Authentication middleware - verify Bearer token - // Skip auth for health check endpoint + // Skip auth for health check and debug endpoints // Supports both Authorization header and query parameter for Claude connector compatibility + // When ALLOW_AUTHLESS=true, SSE endpoints bypass auth for Claude.ai custom connectors app.use((req: Request, res: Response, next) => { - if (req.path === '/health') { + if (req.path === '/health' || req.path === '/debug') { return next(); } - // For SSE endpoint, authentication happens via query param or header - if (req.path === '/sse' || req.path === '/sse/') { - // SSE authentication happens here before establishing connection - // Continue to token validation below + if (!config) { + // Initialize config if not already done (shouldn't happen, but safety check) + config = getConfig(); + } + + // Check if authless mode is enabled for SSE endpoints + // This allows Claude.ai custom connectors to connect without Bearer tokens + const isSSEEndpoint = req.path === '/sse' || req.path === '/sse/'; + const isMessageEndpoint = req.path === '/message'; + + if (config.ALLOW_AUTHLESS && (isSSEEndpoint || isMessageEndpoint)) { + if (isSSEEndpoint) { + console.error('[StravaLambda] Authless SSE connection allowed (ALLOW_AUTHLESS=true)'); + } + return next(); } - // For SSE message endpoint, trust valid session IDs + // For SSE message endpoint with valid session, trust the session const sessionId = req.query.sessionId as string; - if (req.path === '/message' && sessionId && transports[sessionId]) { + if (isMessageEndpoint && sessionId && transports[sessionId]) { return next(); } let token: string | undefined; - + // Try Authorization header first (preferred for API clients) const authHeader = req.headers['authorization']; if (authHeader && authHeader.startsWith('Bearer ')) { @@ -119,15 +144,10 @@ function initializeServer(): express.Application { else if (req.query.token) { token = req.query.token as string; } - - if (!config) { - // Initialize config if not already done (shouldn't happen, but safety check) - config = getConfig(); - } if (!token || token !== config.AUTH_TOKEN) { console.error('[StravaLambda] Invalid or missing token'); - return res.status(401).json({ + return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing token. Use: Authorization: Bearer OR ?token=' }); @@ -154,7 +174,7 @@ function initializeServer(): express.Application { mcpServer = new Server( { name: 'strava-mcp-server', - version: '2.0.0', + version: '3.0.0', }, { capabilities: { @@ -377,9 +397,48 @@ function initializeServer(): express.Application { } }); - // Health check endpoint + // Health check endpoint - enhanced with diagnostic info app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'healthy', version: '2.0.0', environment: 'lambda' }); + res.json({ + status: 'healthy', + version: '3.0.0', + runtime: 'lambda', + authless: config?.ALLOW_AUTHLESS ?? false, + timestamp: new Date().toISOString(), + }); + }); + + // Debug endpoint - helps troubleshoot Claude.ai connection issues + app.get('/debug', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + version: '3.0.0', + authless_enabled: config?.ALLOW_AUTHLESS ?? false, + environment: process.env.NODE_ENV || 'development', + endpoints: { + health: '/health', + debug: '/debug', + sse: '/sse (GET, establishes SSE connection)', + message: '/message (POST, requires sessionId query param)', + mcp: '/mcp (POST, requires Bearer token)', + }, + claude_ai_setup: { + connector_url: 'Use base URL only, no path (e.g., https://xyz.lambda-url.us-east-1.on.aws)', + auth_mode: config?.ALLOW_AUTHLESS ? 'Authless (SSE endpoints bypass auth)' : 'Bearer token required', + transport: 'SSE', + note: 'Claude.ai will auto-discover /sse endpoint from base URL', + }, + sse_headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + known_limitations: { + sse_streaming: 'serverless-express does not support Lambda response streaming (see GitHub issue #655)', + workaround: 'If SSE fails, consider AWS Lambda Web Adapter or direct Lambda streaming', + mcp_endpoint: '/mcp JSON-RPC endpoint works reliably for all clients', + }, + }); }); // SSE endpoint - establishes the server-to-client event stream @@ -464,7 +523,7 @@ function initializeServer(): express.Application { }, serverInfo: { name: 'strava-mcp-server', - version: '2.0.0', + version: '3.0.0', }, }; break; diff --git a/template.yaml b/template.yaml index e944fb6..fc3a0b2 100644 --- a/template.yaml +++ b/template.yaml @@ -8,7 +8,7 @@ Description: > Globals: Function: - Timeout: 300 # 5 minutes (Lambda max for Function URLs) + Timeout: 900 # 15 minutes (max for Lambda Function URLs) - needed for long SSE connections MemorySize: 512 # MB (Free tier allows up to 3.2M seconds of 512MB compute) Runtime: nodejs20.x Architectures: @@ -39,6 +39,14 @@ Parameters: NoEcho: true MinLength: 32 + AllowAuthless: + Type: String + Description: When "true", allows SSE connections without authentication (for Claude.ai custom connectors) + Default: "true" + AllowedValues: + - "true" + - "false" + Resources: StravaMCPFunction: Type: AWS::Serverless::Function @@ -53,15 +61,23 @@ Resources: STRAVA_CLIENT_SECRET: !Ref StravaClientSecret STRAVA_REFRESH_TOKEN: !Ref StravaRefreshToken AUTH_TOKEN: !Ref AuthToken + ALLOW_AUTHLESS: !Ref AllowAuthless FunctionUrlConfig: AuthType: NONE # Public URL with Bearer token authentication in application code Cors: AllowOrigins: - - '*' + - '*' # Allow all origins for flexibility (Claude.ai, Claude Desktop, etc.) AllowMethods: - - '*' + - GET + - POST + - OPTIONS AllowHeaders: - - '*' + - Content-Type + - Authorization + - Accept + - X-Requested-With + AllowCredentials: false + MaxAge: 86400 # Cache preflight for 24 hours InvokeMode: RESPONSE_STREAM # Enable streaming for SSE support Tags: Project: StravaMCP