diff --git a/.env.example b/.env.example index 8b1817f..035ec01 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,19 @@ STRAVA_REFRESH_TOKEN=your_refresh_token_here # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" AUTH_TOKEN=your_secure_random_token_here +# OAuth configuration (Claude Web/Mobile) +OAUTH_ENABLED=false +OAUTH_CLIENTS_TABLE=strava-mcp-oauth-clients +OAUTH_CODES_TABLE=strava-mcp-oauth-codes +OAUTH_TOKENS_TABLE=strava-mcp-oauth-tokens +OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600 +OAUTH_REFRESH_TOKEN_TTL_SECONDS=2592000 +OAUTH_ALLOWED_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback +# Optional: require a bearer token for dynamic client registration +# OAUTH_REGISTRATION_TOKEN=your-registration-token + +# Optional: pull Strava credentials from AWS Secrets Manager JSON payload +# SECRETS_MANAGER_ARN=arn:aws:secretsmanager:us-east-1:123456789012:secret:your-secret-id + # Server configuration (optional, defaults to 3000) PORT=3000 diff --git a/AGENTS.md b/AGENTS.md index 5e9a5ce..554c075 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ ``` StravaMCP/ ├── src/ -│ ├── lambda.ts # Lambda entry point (JSON-RPC over HTTP) +│ ├── lambda-web.ts # Lambda entry point (JSON-RPC over HTTP) │ ├── index.ts # Local dev server (JSON-RPC over HTTP) │ ├── lib/ │ │ └── strava-client.ts # OAuth client with auto-refresh @@ -78,7 +78,7 @@ StravaMCP/ ## Key Components -### 1. Lambda Handler (src/lambda.ts) +### 1. Lambda Handler (src/lambda-web.ts) **Purpose**: Serverless entry point with remote MCP support @@ -266,7 +266,7 @@ export const myNewTools = [ ]; ``` -**Step 2**: Register in `src/lambda.ts` and `src/index.ts`: +**Step 2**: Register in `src/lambda-web.ts` and `src/index.ts`: ```typescript import { myNewTools } from './tools/my-new-tool.js'; @@ -452,7 +452,7 @@ Lambda functions "sleep" after inactivity: ### Fix Authentication Issue 1. Check `samconfig.toml` has AUTH_TOKEN -2. Verify middleware in `lambda.ts` validates correctly +2. Verify middleware in `lambda-web.ts` validates correctly 3. Test: `curl -H "Authorization: Bearer " /health` 4. Check CloudWatch logs: `sam logs -n StravaMCPFunction --stack-name strava-mcp-stack --tail` @@ -561,7 +561,7 @@ sam delete --stack-name strava-mcp-stack # Delete deployment ### File Locations -- Lambda handler: `src/lambda.ts` +- Lambda handler: `src/lambda-web.ts` - Local dev server: `src/index.ts` - Tools: `src/tools/*.ts` - StravaClient: `src/lib/strava-client.ts` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20dc620..04ae54d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -367,7 +367,7 @@ export const myTools = [ ### 2. Register Tool -Update `src/lambda.ts` and `src/index.ts`: +Update `src/lambda-web.ts` and `src/index.ts`: ```typescript import { myTools } from './tools/my-tool.js'; diff --git a/README.md b/README.md index b3426b9..64fd1d0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Perfect for portfolios - demonstrates cloud architecture, serverless deployment, - 🔐 **Bearer Token Authentication** - Secure access to your data (100% free) - 🔄 **Automatic OAuth Token Refresh** - Set it and forget it - ☁️ **AWS Lambda Deployment** - $0/month on free tier -- 📱 **Claude Web & Mobile Support** - Use anywhere +- 📱 **Claude Web & Mobile OAuth** - Secure connector support - 🤖 **ChatGPT Compatible** - Works with OpenAI's ChatGPT connectors - 🏃 **13 Strava API Tools** - Complete API coverage (11 Strava-specific + 2 OpenAI-required) - 🔍 **Smart Search** - Natural language activity search for ChatGPT @@ -125,9 +125,11 @@ The deployment automatically shows your complete configuration! Simply **copy the JSON configuration** displayed after deployment and paste it into: -**Claude Desktop**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Claude Desktop**: `~/Library/Application Support/Claude/claude_desktop_config.json` +Use the `/mcp` endpoint and Bearer token authentication. -**Claude Web/Mobile**: Settings → MCP Servers (add URL and Authorization header) +**Claude Web/Mobile (OAuth)**: Settings → Connectors → Add custom connector +Use the **base URL only** (no `/mcp` or `/sse`), and Claude will complete the OAuth flow. Need to see the config again? Run: ```bash @@ -274,7 +276,7 @@ Traditional MCP servers can't be used with Claude web/mobile because they run lo ``` /StravaMCP ├── src/ -│ ├── lambda.ts # Lambda handler (Streamable HTTP) +│ ├── lambda-web.ts # Lambda handler (Streamable HTTP) │ ├── index.ts # Express server (local dev) │ ├── lib/ # Strava client with OAuth │ ├── tools/ # MCP tool definitions diff --git a/docs/authentication.md b/docs/authentication.md index bf6bf92..759df7c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,14 +1,19 @@ # Authentication Guide -This guide explains how to secure your Strava MCP server with Bearer token authentication. +This guide explains how to secure your Strava MCP server with OAuth (Claude Web/Mobile) and Bearer token authentication (desktop clients). ## Overview -The Lambda function uses **Bearer token authentication** to secure access to your Strava data. This provides: +The Lambda function supports two authentication modes: + +1. **OAuth 2.1 (Claude Web/Mobile)** via Dynamic Client Registration and the authorization code flow. +2. **Bearer token authentication** for Claude Desktop and other MCP clients that support custom headers. + +Bearer token authentication provides: - ✅ Simple, effective security without complex AWS services - ✅ 100% free (no additional costs) -- ✅ Works with Claude web, mobile, and desktop +- ✅ Works with Claude Desktop and other MCP clients that support headers - ✅ Easy to rotate tokens when needed ## Authentication Modes @@ -37,32 +42,13 @@ For Claude Desktop and other MCP clients that support custom headers. └──────────────┘ ``` -### 2. Authless Mode (For Claude.ai Custom Connectors) +### OAuth (Claude Web/Mobile) -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. +Claude Web connects using OAuth: -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. +1. Metadata discovery at `/.well-known/oauth-authorization-server` +2. Dynamic client registration at `/register` +3. Authorization code + PKCE flow via `/authorize` and `/token` ## Setup Steps @@ -87,8 +73,8 @@ bun run deploy:show-config This displays: - Complete Claude Desktop JSON configuration -- Claude Web/Mobile connection details -- Your AUTH_TOKEN +- Claude Web/Mobile OAuth base URL +- Your AUTH_TOKEN (for desktop clients) - Health check test command ### 2. Deploy with Authentication @@ -133,7 +119,7 @@ parameter_overrides = [ After deployment, get your Function URL from the deployment output. -#### All Claude Platforms +#### Claude Desktop (Bearer Token) Add as a Custom Connector in Claude Settings: @@ -149,19 +135,12 @@ 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: +#### Claude Web/Mobile (OAuth) -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 +Settings → Connectors → Add custom connector: -**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. +- URL: `https://your-function-url.lambda-url.us-east-1.on.aws` (base URL only) +- Claude will complete OAuth automatically ### 4. Test Authentication diff --git a/docs/deployment.md b/docs/deployment.md index 0cb948e..252fe4b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -154,8 +154,8 @@ CloudFormation outputs from deployed stack Outputs --------------------------------------------------------- Key ClaudeConnectionUrl -Description URL to use in Claude web/mobile for MCP connection -Value https://abc123xyz.lambda-url.us-east-1.on.aws/mcp +Description Base URL for Claude OAuth connector +Value https://abc123xyz.lambda-url.us-east-1.on.aws Key HealthCheckUrl Description Health check endpoint @@ -175,10 +175,9 @@ bun run deploy:show-config ``` This displays: -- Complete Claude Desktop JSON configuration (ready to copy-paste) -- Claude Web/Mobile connection details -- Your AUTH_TOKEN (for authentication) -- Test command with your credentials +- Base URL for Claude OAuth connector +- OAuth metadata endpoint +- MCP endpoint for desktop clients {: .tip } > Use this command whenever you need to reconnect Claude or check your deployment details! @@ -196,12 +195,20 @@ Expected response: ## Connecting to Claude -### All Claude Platforms (Web, Desktop, Mobile) +### Claude Web/Mobile (OAuth) + +1. Open Claude Settings +2. Navigate to **Connectors** +3. Click **Add custom connector** +4. Enter the **base URL** from deployment (no `/mcp` or `/sse`) +5. Claude will complete OAuth automatically + +### Claude Desktop (Bearer Token) 1. Open Claude Settings 2. Navigate to **Custom Connectors** (or **MCP Servers**) 3. Click **Add Connector** -4. Enter the configuration from deployment: +4. Enter: ```json { "name": "strava", @@ -213,9 +220,6 @@ Expected response: ``` 5. Save -{: .tip } -> The URL must end with `/mcp` - this is the MCP endpoint that handles all requests. - {: .tip } > You can now use Strava MCP tools in any Claude conversation! diff --git a/docs/development.md b/docs/development.md index 0bc7de3..04065d1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,7 @@ graph TB end subgraph "AWS Lambda" - D[Lambda Handler
lambda.ts] + D[Lambda Handler
lambda-web.ts] E[Bearer Token
Middleware] F[MCP Server
@modelcontextprotocol/sdk] end @@ -170,7 +170,7 @@ graph LR ``` StravaMCP/ ├── src/ -│ ├── lambda.ts # Lambda entry point (Streamable HTTP) +│ ├── lambda-web.ts # Lambda entry point (Streamable HTTP) │ ├── index.ts # Local dev server (JSON-RPC over HTTP) │ ├── lib/ │ │ └── strava-client.ts # OAuth client with auto-refresh @@ -209,7 +209,7 @@ StravaMCP/ ## Key Components -### 1. Lambda Handler (`src/lambda.ts`) +### 1. Lambda Handler (`src/lambda-web.ts`) The Lambda entry point implements: - **Streamable HTTP Transport** for remote MCP @@ -359,7 +359,7 @@ export const myNewTools = [ ### Step 2: Register Tool -Add to `src/lambda.ts` (Lambda) and `src/index.ts` (local dev): +Add to `src/lambda-web.ts` (Lambda) and `src/index.ts` (local dev): ```typescript import { myNewTools } from './tools/my-new-tool.js'; diff --git a/docs/index.md b/docs/index.md index c2b75f0..211bf9f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ The **Strava MCP Server** is a production-ready Model Context Protocol server th - 🔐 **Automatic OAuth Token Refresh** - Set it and forget it - ☁️ **Serverless AWS Lambda** - Runs in the free tier -- 📱 **Works with Claude Web & Mobile** - Use MCP anywhere +- 📱 **Claude Web & Mobile OAuth** - Secure connector support - 🏃 **11 Strava API Tools** - Activities, athlete stats, streams, clubs, uploads - 🎯 **Activity Enrichment** - Transform generic workout titles into detailed training logs - ⚡ **Built with Bun** - Fast builds and deployments @@ -64,7 +64,7 @@ Follow the prompts to enter your Strava credentials and AWS region. After deployment, copy the `ClaudeConnectionUrl` and add it to Claude: -**Claude Web**: Settings → MCP → Add Remote Server +**Claude Web**: Settings → Connectors → Add custom connector (OAuth, base URL) **Claude Mobile**: Settings → MCP Servers → Add Server ## Architecture diff --git a/package.json b/package.json index f64bf26..e71f4ef 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "build": "bun run typecheck && bun run build:tsc", "build:tsc": "tsc", "build:lambda": "bun run build && bun run copy-files", - "copy-files": "cp package.json dist/ && cp .env.example dist/", + "copy-files": "cp package.json dist/ && cp .env.example dist/ && cp run.sh dist/", "dev": "bun --watch src/index.ts", - "dev:lambda": "bun src/lambda.ts", + "dev:lambda": "bun src/lambda-web.ts", "start": "bun dist/index.js", "typecheck": "tsc --noEmit", "deploy": "bun run build:lambda && bun scripts/deploy.ts", @@ -31,7 +31,9 @@ "author": "", "license": "ISC", "dependencies": { - "@codegenie/serverless-express": "^4.15.0", + "@aws-sdk/client-dynamodb": "^3.872.0", + "@aws-sdk/client-secrets-manager": "^3.872.0", + "@aws-sdk/lib-dynamodb": "^3.872.0", "@modelcontextprotocol/sdk": "^1.25.2", "axios": "^1.13.2", "dotenv": "^17.2.3", diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..6c9d665 --- /dev/null +++ b/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +node lambda-web.js diff --git a/scripts/show-config.ts b/scripts/show-config.ts index 691425c..7fb966e 100755 --- a/scripts/show-config.ts +++ b/scripts/show-config.ts @@ -22,7 +22,7 @@ const colors = { }; function log(message: string, color: string = colors.reset) { - console.log(`${color}${message}${colors.reset}`); + console.error(`${color}${message}${colors.reset}`); } function getAuthToken(): string | null { @@ -46,7 +46,7 @@ function getFunctionUrl(): string | null { const match = output.match(/ClaudeConnectionUrl\s+(.+?)(?:\s|$)/); if (match) { - return match[1].replace(/mcp$/, ''); + return match[1].replace(/\/?mcp$/, '').replace(/\/$/, ''); } const urlMatch = output.match(/(https:\/\/[a-z0-9-]+\.lambda-url\.[a-z0-9-]+\.on\.aws\/)/); @@ -64,33 +64,31 @@ function main() { const authToken = getAuthToken(); const functionUrl = getFunctionUrl(); - if (!authToken) { - log('❌ No AUTH_TOKEN found in samconfig.toml', colors.red); - log(' Run: bun run deploy\n', colors.yellow); - process.exit(1); - } - if (!functionUrl) { log('❌ Stack not deployed or unable to retrieve Function URL', colors.red); log(' Run: bun run deploy\n', colors.yellow); process.exit(1); } - - const sseUrl = functionUrl.endsWith('/') ? `${functionUrl}sse?token=${authToken}` : `${functionUrl}/sse?token=${authToken}`; - - log('Claude Connector Configuration:', colors.bright); - log('Copy and paste this URL into Claude Settings → Connectors:\n', colors.yellow); - - log(sseUrl, colors.green + colors.bright); - + + const baseUrl = functionUrl.endsWith('/') ? functionUrl.slice(0, -1) : functionUrl; + const metadataUrl = `${baseUrl}/.well-known/oauth-authorization-server`; + + log('Claude Connector Configuration (OAuth):', colors.bright); + log('Use the BASE URL in Claude Settings → Connectors:\n', colors.yellow); + + log(baseUrl, colors.green + colors.bright); + log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', colors.cyan); log('\nConnection Details:', colors.bright); - log(` SSE URL: ${sseUrl}`, colors.blue); - log(` Token embedded in URL for Claude compatibility`, colors.blue); - - log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', colors.cyan); - log('\nTest:', colors.bright); - log(` curl -H "Authorization: Bearer ${authToken}" ${functionUrl}health\n`, colors.blue); + log(` OAuth metadata: ${metadataUrl}`, colors.blue); + log(` SSE endpoint: ${baseUrl}/sse`, colors.blue); + log(` MCP endpoint: ${baseUrl}/mcp`, colors.blue); + + if (authToken) { + log('\nLegacy Token Test:', colors.bright); + log(` curl -H "Authorization: Bearer ${authToken}" ${baseUrl}/health\n`, colors.blue); + } + log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n', colors.cyan); } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..9af0322 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,389 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { getConfig } from './config/env.js'; +import { loadSecretsFromManager } from './config/secrets.js'; +import { StravaClient } from './lib/strava-client.js'; +import { formatError } from './utils/errors.js'; +import { registerOAuthRoutes, validateAccessToken } from './oauth/server.js'; + +import { + activitiesTools, + getActivities, + GetActivitiesSchema, + getActivityById, + GetActivityByIdSchema, + createActivity, + CreateActivitySchema, + updateActivity, + UpdateActivitySchema, + getActivityZones, + GetActivityZonesSchema, +} from './tools/activities.js'; + +import { + athleteTools, + getAthlete, + getAthleteStats, + GetAthleteStatsSchema, +} from './tools/athlete.js'; + +import { + streamsTools, + getActivityStreams, + GetActivityStreamsSchema, +} from './tools/streams.js'; + +import { + clubsTools, + getClubActivities, + GetClubActivitiesSchema, +} from './tools/clubs.js'; + +import { + uploadsTools, + createUpload, + CreateUploadSchema, + getUpload, + GetUploadSchema, +} from './tools/uploads.js'; + +import { + openaiTools, + searchActivities, + SearchSchema, + fetchActivity, + FetchSchema, +} from './tools/openai.js'; + +const DEFAULT_ALLOWED_REDIRECT_URIS = [ + 'https://claude.ai/api/mcp/auth_callback', + 'https://claude.com/api/mcp/auth_callback', +]; + +export async function createApp(runtime: 'local' | 'lambda') { + await loadSecretsFromManager(process.env.SECRETS_MANAGER_ARN); + + const config = getConfig(); + const stravaClient = new StravaClient({ + clientId: config.STRAVA_CLIENT_ID, + clientSecret: config.STRAVA_CLIENT_SECRET, + refreshToken: config.STRAVA_REFRESH_TOKEN, + }); + + const app = express(); + app.set('trust proxy', true); + app.use(express.json()); + app.use(express.urlencoded({ extended: false })); + + const mcpServer = new Server( + { + name: 'strava-mcp-server', + version: '3.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + const allTools = [ + ...activitiesTools, + ...athleteTools, + ...streamsTools, + ...clubsTools, + ...uploadsTools, + ...openaiTools, + ]; + + mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: allTools }; + }); + + async function runTool(name: string, args: any) { + switch (name) { + case 'get_activities': { + const params = GetActivitiesSchema.parse(args); + const result = await getActivities(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_activity_by_id': { + const params = GetActivityByIdSchema.parse(args); + const result = await getActivityById(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'create_activity': { + const params = CreateActivitySchema.parse(args); + const result = await createActivity(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'update_activity': { + const params = UpdateActivitySchema.parse(args); + const result = await updateActivity(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_activity_zones': { + const params = GetActivityZonesSchema.parse(args); + const result = await getActivityZones(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_athlete': { + const result = await getAthlete(stravaClient); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_athlete_stats': { + const params = GetAthleteStatsSchema.parse(args || {}); + const result = await getAthleteStats(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_activity_streams': { + const params = GetActivityStreamsSchema.parse(args); + const result = await getActivityStreams(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_club_activities': { + const params = GetClubActivitiesSchema.parse(args); + const result = await getClubActivities(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'create_upload': { + const params = CreateUploadSchema.parse(args); + const result = await createUpload(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'get_upload': { + const params = GetUploadSchema.parse(args); + const result = await getUpload(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + case 'search': { + const params = SearchSchema.parse(args); + const result = await searchActivities(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } + case 'fetch': { + const params = FetchSchema.parse(args); + const result = await fetchActivity(stravaClient, params); + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } + default: + throw new Error(`Unknown tool: ${name}`); + } + } + + mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + return await runToolSafe(name, args); + }); + + async function runToolSafe(name: string, args: any) { + try { + return await runTool(name, args); + } catch (error) { + const errorMessage = formatError(error); + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true, + }; + } + } + + const transports: Record = {}; + + const allowedRedirectUris = + config.OAUTH_ALLOWED_REDIRECT_URIS && config.OAUTH_ALLOWED_REDIRECT_URIS.length > 0 + ? config.OAUTH_ALLOWED_REDIRECT_URIS + : DEFAULT_ALLOWED_REDIRECT_URIS; + + const oauthConfig = { + enabled: config.OAUTH_ENABLED, + clientsTable: config.OAUTH_CLIENTS_TABLE || '', + codesTable: config.OAUTH_CODES_TABLE || '', + tokensTable: config.OAUTH_TOKENS_TABLE || '', + accessTokenTtlSeconds: config.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + refreshTokenTtlSeconds: config.OAUTH_REFRESH_TOKEN_TTL_SECONDS, + allowedRedirectUris, + registrationToken: config.OAUTH_REGISTRATION_TOKEN, + }; + + registerOAuthRoutes(app, oauthConfig); + + app.use(async (req: Request, res: Response, next) => { + const publicPaths = new Set([ + '/health', + '/debug', + '/.well-known/oauth-authorization-server', + '/authorize', + '/token', + '/register', + ]); + + if (publicPaths.has(req.path)) { + return next(); + } + + const sessionId = req.query.sessionId as string; + if (req.path === '/message' && sessionId && transports[sessionId]) { + return next(); + } + + let token: string | undefined; + const authHeader = req.headers['authorization']; + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } else if (req.query.access_token) { + token = req.query.access_token as string; + } + + if (!token && req.query.token) { + token = req.query.token as string; + } + + if (token && config.AUTH_TOKEN && token === config.AUTH_TOKEN) { + return next(); + } + + if (token && config.OAUTH_ENABLED) { + const valid = await validateAccessToken(oauthConfig, token); + if (valid) { + return next(); + } + } + + if (!config.OAUTH_ENABLED && !config.AUTH_TOKEN) { + return next(); + } + + return res.status(401).json({ + error: 'Unauthorized', + message: 'Missing or invalid access token', + }); + }); + + app.get('/health', (_req: Request, res: Response) => { + res.json({ + status: 'healthy', + version: '3.0.0', + runtime, + oauth_enabled: config.OAUTH_ENABLED, + timestamp: new Date().toISOString(), + }); + }); + + app.get('/debug', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + version: '3.0.0', + runtime, + oauth_enabled: config.OAUTH_ENABLED, + endpoints: { + health: '/health', + debug: '/debug', + oauth_metadata: '/.well-known/oauth-authorization-server', + authorize: '/authorize', + token: '/token', + register: '/register', + sse: '/sse (GET, establishes SSE connection)', + message: '/message (POST, requires sessionId query param)', + mcp: '/mcp (POST, requires Bearer token)', + }, + }); + }); + + const sseHandler = async (_req: Request, res: Response) => { + console.error('[StravaServer] New SSE connection established'); + const transport = new SSEServerTransport('/message', res); + const sessionId = randomUUID(); + transports[sessionId] = transport; + + res.on('close', () => { + console.error(`[StravaServer] SSE connection closed for session ${sessionId}`); + delete transports[sessionId]; + }); + + await mcpServer.connect(transport); + console.error(`[StravaServer] Session ${sessionId} initialized`); + }; + + app.get('/sse', sseHandler); + app.get('/sse/', sseHandler); + + app.post('/message', async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + const transport = transports[sessionId]; + + if (!transport) { + res.status(400).json({ error: 'Invalid or expired session ID' }); + return; + } + + await transport.handlePostMessage(req, res, req.body); + }); + + app.post('/mcp', async (req: Request, res: Response) => { + try { + console.error('[StravaServer] MCP request received:', req.body.method); + const { jsonrpc, id, method, params } = req.body; + + if (jsonrpc !== '2.0' || !method) { + return res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32600, message: 'Invalid Request' }, + id: id || null, + }); + } + + let result; + switch (method) { + case 'tools/list': + result = { tools: allTools }; + break; + case 'tools/call': + if (!params || !params.name) { + return res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32602, message: 'Invalid params: missing tool name' }, + id, + }); + } + result = await runToolSafe(params.name, params.arguments || {}); + break; + case 'initialize': + result = { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'strava-mcp-server', version: '3.0.0' }, + }; + break; + default: + return res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32601, message: `Method not found: ${method}` }, + id, + }); + } + + res.json({ jsonrpc: '2.0', result, id }); + } catch (error) { + console.error('[StravaServer] MCP request error:', error); + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + data: error instanceof Error ? error.message : String(error), + }, + id: req.body.id || null, + }); + } + }); + + return { app, config }; +} diff --git a/src/config/env.ts b/src/config/env.ts index a5d227f..625128c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -10,11 +10,37 @@ const envSchema = z.object({ STRAVA_CLIENT_ID: z.string().min(1, 'STRAVA_CLIENT_ID is required'), STRAVA_CLIENT_SECRET: z.string().min(1, 'STRAVA_CLIENT_SECRET is required'), 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'), + AUTH_TOKEN: z + .string() + .min(32, 'AUTH_TOKEN must be at least 32 characters') + .optional() + .or(z.literal('').transform(() => undefined)), 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'), + SECRETS_MANAGER_ARN: z.string().optional(), + OAUTH_ENABLED: z.string().default('false').transform((val) => val.toLowerCase() === 'true'), + OAUTH_CLIENTS_TABLE: z.string().optional(), + OAUTH_CODES_TABLE: z.string().optional(), + OAUTH_TOKENS_TABLE: z.string().optional(), + OAUTH_ALLOWED_REDIRECT_URIS: z + .string() + .optional() + .transform((value) => + value + ? value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + : undefined + ), + OAUTH_REGISTRATION_TOKEN: z.string().optional(), + OAUTH_ACCESS_TOKEN_TTL_SECONDS: z + .string() + .default('3600') + .transform((val) => Number(val)), + OAUTH_REFRESH_TOKEN_TTL_SECONDS: z + .string() + .default('2592000') + .transform((val) => Number(val)), }); /** @@ -28,7 +54,15 @@ 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, + SECRETS_MANAGER_ARN: process.env.SECRETS_MANAGER_ARN, + OAUTH_ENABLED: process.env.OAUTH_ENABLED, + OAUTH_CLIENTS_TABLE: process.env.OAUTH_CLIENTS_TABLE, + OAUTH_CODES_TABLE: process.env.OAUTH_CODES_TABLE, + OAUTH_TOKENS_TABLE: process.env.OAUTH_TOKENS_TABLE, + OAUTH_ALLOWED_REDIRECT_URIS: process.env.OAUTH_ALLOWED_REDIRECT_URIS, + OAUTH_REGISTRATION_TOKEN: process.env.OAUTH_REGISTRATION_TOKEN, + OAUTH_ACCESS_TOKEN_TTL_SECONDS: process.env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + OAUTH_REFRESH_TOKEN_TTL_SECONDS: process.env.OAUTH_REFRESH_TOKEN_TTL_SECONDS, }); if (!result.success) { @@ -39,6 +73,12 @@ export function getConfig() { ); } + if (result.data.OAUTH_ENABLED) { + if (!result.data.OAUTH_CLIENTS_TABLE || !result.data.OAUTH_CODES_TABLE || !result.data.OAUTH_TOKENS_TABLE) { + throw new Error('OAuth is enabled but OAUTH_* table names are missing'); + } + } + return result.data; } diff --git a/src/config/secrets.ts b/src/config/secrets.ts new file mode 100644 index 0000000..651b325 --- /dev/null +++ b/src/config/secrets.ts @@ -0,0 +1,51 @@ +import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; + +export interface SecretPayload { + STRAVA_CLIENT_ID?: string; + STRAVA_CLIENT_SECRET?: string; + STRAVA_REFRESH_TOKEN?: string; + AUTH_TOKEN?: string; +} + +let cachedSecrets: SecretPayload | null = null; + +export async function loadSecretsFromManager(secretArn?: string): Promise { + if (!secretArn || secretArn.trim().length === 0) { + return; + } + + if (cachedSecrets) { + applySecrets(cachedSecrets); + return; + } + + const client = new SecretsManagerClient({}); + const result = await client.send( + new GetSecretValueCommand({ + SecretId: secretArn, + }) + ); + + if (!result.SecretString) { + throw new Error('Secrets Manager returned empty secret string'); + } + + const payload = JSON.parse(result.SecretString) as SecretPayload; + cachedSecrets = payload; + applySecrets(payload); +} + +function applySecrets(payload: SecretPayload): void { + if (payload.STRAVA_CLIENT_ID && !process.env.STRAVA_CLIENT_ID) { + process.env.STRAVA_CLIENT_ID = payload.STRAVA_CLIENT_ID; + } + if (payload.STRAVA_CLIENT_SECRET && !process.env.STRAVA_CLIENT_SECRET) { + process.env.STRAVA_CLIENT_SECRET = payload.STRAVA_CLIENT_SECRET; + } + if (payload.STRAVA_REFRESH_TOKEN && !process.env.STRAVA_REFRESH_TOKEN) { + process.env.STRAVA_REFRESH_TOKEN = payload.STRAVA_REFRESH_TOKEN; + } + if (payload.AUTH_TOKEN && !process.env.AUTH_TOKEN) { + process.env.AUTH_TOKEN = payload.AUTH_TOKEN; + } +} diff --git a/src/index.ts b/src/index.ts index 7d862ae..71cca48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,728 +1,23 @@ #!/usr/bin/env node -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, - Tool, -} from '@modelcontextprotocol/sdk/types.js'; -import express, { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; +import { createApp } from './app.js'; -import { getConfig } from './config/env.js'; -import { StravaClient } from './lib/strava-client.js'; -import { formatError } from './utils/errors.js'; - -// Import all tools -import { - activitiesTools, - getActivities, - GetActivitiesSchema, - getActivityById, - GetActivityByIdSchema, - createActivity, - CreateActivitySchema, - updateActivity, - UpdateActivitySchema, - getActivityZones, - GetActivityZonesSchema, -} from './tools/activities.js'; - -import { - athleteTools, - getAthlete, - getAthleteStats, - GetAthleteStatsSchema, -} from './tools/athlete.js'; - -import { - streamsTools, - getActivityStreams, - GetActivityStreamsSchema, -} from './tools/streams.js'; - -import { - clubsTools, - getClubActivities, - GetClubActivitiesSchema, -} from './tools/clubs.js'; - -import { - uploadsTools, - createUpload, - CreateUploadSchema, - getUpload, - GetUploadSchema, -} from './tools/uploads.js'; - -import { - openaiTools, - searchActivities, - SearchSchema, - fetchActivity, - FetchSchema, -} from './tools/openai.js'; - -/** - * Strava MCP Server - * - * A Model Context Protocol server that provides comprehensive access to the Strava API. - * Enables LLMs to manage Strava accounts with a focus on enriching workout data. - */ - -// Initialize configuration -let config: ReturnType; -let stravaClient: StravaClient; - -try { - config = getConfig(); - stravaClient = new StravaClient({ - clientId: config.STRAVA_CLIENT_ID, - clientSecret: config.STRAVA_CLIENT_SECRET, - refreshToken: config.STRAVA_REFRESH_TOKEN, - }); - console.error('[StravaServer] Initialized successfully'); -} catch (error) { - console.error('[StravaServer] Failed to initialize:', formatError(error)); - process.exit(1); -} - -// Create MCP server -const server = new Server( - { - name: 'strava-mcp-server', - version: '3.0.0', - }, - { - capabilities: { - tools: {}, - }, - } -); - -// Combine all tools -const allTools = [ - ...activitiesTools, - ...athleteTools, - ...streamsTools, - ...clubsTools, - ...uploadsTools, - ...openaiTools, -]; - -// Handle tool listing -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: allTools, - }; -}); - -// Handle tool execution -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - try { - switch (name) { - // Activity tools - case 'get_activities': { - const params = GetActivitiesSchema.parse(args); - const result = await getActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_by_id': { - const params = GetActivityByIdSchema.parse(args); - const result = await getActivityById(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'create_activity': { - const params = CreateActivitySchema.parse(args); - const result = await createActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'update_activity': { - const params = UpdateActivitySchema.parse(args); - const result = await updateActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_zones': { - const params = GetActivityZonesSchema.parse(args); - const result = await getActivityZones(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Athlete tools - case 'get_athlete': { - const result = await getAthlete(stravaClient); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_athlete_stats': { - const params = GetAthleteStatsSchema.parse(args || {}); - const result = await getAthleteStats(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Streams tools - case 'get_activity_streams': { - const params = GetActivityStreamsSchema.parse(args); - const result = await getActivityStreams(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Clubs tools - case 'get_club_activities': { - const params = GetClubActivitiesSchema.parse(args); - const result = await getClubActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Uploads tools - case 'create_upload': { - const params = CreateUploadSchema.parse(args); - const result = await createUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_upload': { - const params = GetUploadSchema.parse(args); - const result = await getUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // OpenAI tools - case 'search': { - const params = SearchSchema.parse(args); - const result = await searchActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - case 'fetch': { - const params = FetchSchema.parse(args); - const result = await fetchActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - default: - throw new Error(`Unknown tool: ${name}`); - } - } catch (error) { - const errorMessage = formatError(error); - return { - content: [ - { - type: 'text', - text: `Error: ${errorMessage}`, - }, - ], - isError: true, - }; - } -}); - -// Start the HTTP server async function main() { - const app = express(); - app.use(express.json()); - - // Store transports by session ID - const transports: Record = {}; - - // 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' || req.path === '/debug') { - return next(); - } - - // 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 with valid session, trust the session - const sessionId = req.query.sessionId as string; - if (isMessageEndpoint && sessionId && transports[sessionId]) { - return next(); - } - - // For local development, authentication is optional - // 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({ - error: 'Unauthorized', - message: 'Invalid or missing token' - }); - } - } - - next(); - }); - - // Health check endpoint - enhanced with diagnostic info - app.get('/health', (_req: Request, res: Response) => { - 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 - // Support both /sse and /sse/ for compatibility - const sseHandler = async (_req: Request, res: Response) => { - console.error('[StravaServer] New SSE connection established'); - const transport = new SSEServerTransport('/message', res); - const sessionId = randomUUID(); - transports[sessionId] = transport; - - // Clean up transport on connection close - res.on('close', () => { - console.error(`[StravaServer] SSE connection closed for session ${sessionId}`); - delete transports[sessionId]; - }); - - await server.connect(transport); - console.error(`[StravaServer] Session ${sessionId} initialized`); - }; - - app.get('/sse', sseHandler); - app.get('/sse/', sseHandler); - - // Message endpoint - handles client-to-server messages - app.post('/message', async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - const transport = transports[sessionId]; - - if (!transport) { - res.status(400).json({ error: 'Invalid or expired session ID' }); - return; - } - - await transport.handlePostMessage(req, res, req.body); - }); - - // MCP endpoint - handles JSON-RPC requests (for testing and direct API access) - app.post('/mcp', async (req: Request, res: Response) => { - try { - console.error('[StravaServer] MCP request received:', req.body.method); - - const { jsonrpc, id, method, params } = req.body; - - // Validate JSON-RPC request - if (jsonrpc !== '2.0' || !method) { - return res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32600, - message: 'Invalid Request', - }, - id: id || null, - }); - } - - // Route to appropriate handler - let result; - switch (method) { - case 'tools/list': - result = { tools: allTools }; - break; - - case 'tools/call': - if (!params || !params.name) { - return res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32602, - message: 'Invalid params: missing tool name', - }, - id, - }); - } - result = await handleToolCall(params.name, params.arguments || {}); - break; - - case 'initialize': - result = { - protocolVersion: '2024-11-05', - capabilities: { - tools: {}, - }, - serverInfo: { - name: 'strava-mcp-server', - version: '3.0.0', - }, - }; - break; - - default: - return res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32601, - message: `Method not found: ${method}`, - }, - id, - }); - } - - res.json({ - jsonrpc: '2.0', - result, - id, - }); - } catch (error) { - console.error('[StravaServer] MCP request error:', error); - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error', - data: error instanceof Error ? error.message : String(error), - }, - id: req.body.id || null, - }); - } - }); - - // Helper function to handle tool calls - async function handleToolCall(name: string, args: any) { - switch (name) { - // Activity tools - case 'get_activities': { - const params = GetActivitiesSchema.parse(args); - const result = await getActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_by_id': { - const params = GetActivityByIdSchema.parse(args); - const result = await getActivityById(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'create_activity': { - const params = CreateActivitySchema.parse(args); - const result = await createActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'update_activity': { - const params = UpdateActivitySchema.parse(args); - const result = await updateActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_zones': { - const params = GetActivityZonesSchema.parse(args); - const result = await getActivityZones(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Athlete tools - case 'get_athlete': { - const result = await getAthlete(stravaClient); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_athlete_stats': { - const params = GetAthleteStatsSchema.parse(args || {}); - const result = await getAthleteStats(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Streams tools - case 'get_activity_streams': { - const params = GetActivityStreamsSchema.parse(args); - const result = await getActivityStreams(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Clubs tools - case 'get_club_activities': { - const params = GetClubActivitiesSchema.parse(args); - const result = await getClubActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Uploads tools - case 'create_upload': { - const params = CreateUploadSchema.parse(args); - const result = await createUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_upload': { - const params = GetUploadSchema.parse(args); - const result = await getUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // OpenAI tools - case 'search': { - const params = SearchSchema.parse(args); - const result = await searchActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - case 'fetch': { - const params = FetchSchema.parse(args); - const result = await fetchActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - default: - throw new Error(`Unknown tool: ${name}`); - } - } - + const { app, config } = await createApp('local'); const port = config.PORT; + app.listen(port, () => { - console.error(`[StravaServer] Remote MCP server running on http://localhost:${port}`); + console.error(`[StravaServer] 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'}`); + console.error( + `[StravaServer] OAuth metadata: http://localhost:${port}/.well-known/oauth-authorization-server` + ); }); } main().catch((error) => { - console.error('[StravaServer] Fatal error:', formatError(error)); + console.error('[StravaServer] Failed to start:', error); process.exit(1); }); diff --git a/src/lambda-web.ts b/src/lambda-web.ts new file mode 100644 index 0000000..396745a --- /dev/null +++ b/src/lambda-web.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { createApp } from './app.js'; + +const DEFAULT_PORT = 8080; + +async function main() { + const { app } = await createApp('lambda'); + const port = + Number(process.env.AWS_LWA_PORT) || + Number(process.env.PORT) || + DEFAULT_PORT; + + app.listen(port, () => { + console.error(`[StravaLambda] Web adapter server listening on ${port}`); + }); +} + +main().catch((error) => { + console.error('[StravaLambda] Failed to start:', error); + process.exit(1); +}); diff --git a/src/lambda.ts b/src/lambda.ts deleted file mode 100644 index 0f6f4b0..0000000 --- a/src/lambda.ts +++ /dev/null @@ -1,753 +0,0 @@ -#!/usr/bin/env node - -import { configure as serverlessExpress } from '@codegenie/serverless-express'; -import type { - APIGatewayProxyEventV2, - Context as LambdaContext, -} from 'aws-lambda'; -import express, { Request, Response } from 'express'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import { randomUUID } from 'node:crypto'; - -import { getConfig } from './config/env.js'; -import { StravaClient } from './lib/strava-client.js'; -import { formatError } from './utils/errors.js'; - -// Import all tools -import { - activitiesTools, - getActivities, - GetActivitiesSchema, - getActivityById, - GetActivityByIdSchema, - createActivity, - CreateActivitySchema, - updateActivity, - UpdateActivitySchema, - getActivityZones, - GetActivityZonesSchema, -} from './tools/activities.js'; - -import { - athleteTools, - getAthlete, - getAthleteStats, - GetAthleteStatsSchema, -} from './tools/athlete.js'; - -import { - streamsTools, - getActivityStreams, - GetActivityStreamsSchema, -} from './tools/streams.js'; - -import { - clubsTools, - getClubActivities, - GetClubActivitiesSchema, -} from './tools/clubs.js'; - -import { - uploadsTools, - createUpload, - CreateUploadSchema, - getUpload, - GetUploadSchema, -} from './tools/uploads.js'; - -import { - openaiTools, - searchActivities, - SearchSchema, - fetchActivity, - FetchSchema, -} from './tools/openai.js'; - -/** - * Strava MCP Lambda Handler - * - * 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 -let config: ReturnType; -let stravaClient: StravaClient; -let mcpServer: Server; - -function initializeServer(): express.Application { - // Create Express app - const app = express(); - app.use(express.json()); - - // Store transports by session ID - const transports: Record = {}; - - // Authentication middleware - verify Bearer token - // 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' || req.path === '/debug') { - return next(); - } - - 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 with valid session, trust the session - const sessionId = req.query.sessionId as string; - 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 ')) { - token = authHeader.substring(7); - } - // Fallback to query parameter (required for Claude connectors) - else if (req.query.token) { - token = req.query.token as string; - } - - if (!token || token !== config.AUTH_TOKEN) { - console.error('[StravaLambda] Invalid or missing token'); - return res.status(401).json({ - error: 'Unauthorized', - message: 'Invalid or missing token. Use: Authorization: Bearer OR ?token=' - }); - } - - // Token is valid, proceed - next(); - }); - - try { - config = getConfig(); - stravaClient = new StravaClient({ - clientId: config.STRAVA_CLIENT_ID, - clientSecret: config.STRAVA_CLIENT_SECRET, - refreshToken: config.STRAVA_REFRESH_TOKEN, - }); - console.error('[StravaLambda] Initialized successfully'); - } catch (error) { - console.error('[StravaLambda] Failed to initialize:', formatError(error)); - throw error; - } - - // Create MCP server - mcpServer = new Server( - { - name: 'strava-mcp-server', - version: '3.0.0', - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // Combine all tools - const allTools = [ - ...activitiesTools, - ...athleteTools, - ...streamsTools, - ...clubsTools, - ...uploadsTools, - ...openaiTools, - ]; - - // Handle tool listing - mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: allTools, - }; - }); - - // Handle tool execution - mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - try { - switch (name) { - // Activity tools - case 'get_activities': { - const params = GetActivitiesSchema.parse(args); - const result = await getActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_by_id': { - const params = GetActivityByIdSchema.parse(args); - const result = await getActivityById(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'create_activity': { - const params = CreateActivitySchema.parse(args); - const result = await createActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'update_activity': { - const params = UpdateActivitySchema.parse(args); - const result = await updateActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_zones': { - const params = GetActivityZonesSchema.parse(args); - const result = await getActivityZones(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Athlete tools - case 'get_athlete': { - const result = await getAthlete(stravaClient); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_athlete_stats': { - const params = GetAthleteStatsSchema.parse(args || {}); - const result = await getAthleteStats(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Streams tools - case 'get_activity_streams': { - const params = GetActivityStreamsSchema.parse(args); - const result = await getActivityStreams(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Clubs tools - case 'get_club_activities': { - const params = GetClubActivitiesSchema.parse(args); - const result = await getClubActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Uploads tools - case 'create_upload': { - const params = CreateUploadSchema.parse(args); - const result = await createUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_upload': { - const params = GetUploadSchema.parse(args); - const result = await getUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // OpenAI tools - case 'search': { - const params = SearchSchema.parse(args); - const result = await searchActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - case 'fetch': { - const params = FetchSchema.parse(args); - const result = await fetchActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - default: - throw new Error(`Unknown tool: ${name}`); - } - } catch (error) { - const errorMessage = formatError(error); - return { - content: [ - { - type: 'text', - text: `Error: ${errorMessage}`, - }, - ], - isError: true, - }; - } - }); - - // Health check endpoint - enhanced with diagnostic info - app.get('/health', (_req: Request, res: Response) => { - 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 - // Support both /sse and /sse/ for compatibility - const sseHandler = async (_req: Request, res: Response) => { - console.error('[StravaLambda] New SSE connection established'); - const transport = new SSEServerTransport('/message', res); - const sessionId = randomUUID(); - transports[sessionId] = transport; - - // Clean up transport on connection close - res.on('close', () => { - console.error(`[StravaLambda] SSE connection closed for session ${sessionId}`); - delete transports[sessionId]; - }); - - await mcpServer.connect(transport); - console.error(`[StravaLambda] Session ${sessionId} initialized`); - }; - - app.get('/sse', sseHandler); - app.get('/sse/', sseHandler); - - // Message endpoint - handles client-to-server messages - app.post('/message', async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - const transport = transports[sessionId]; - - if (!transport) { - res.status(400).json({ error: 'Invalid or expired session ID' }); - return; - } - - await transport.handlePostMessage(req, res, req.body); - }); - - // MCP endpoint - handles JSON-RPC requests (for testing and direct API access) - app.post('/mcp', async (req: Request, res: Response) => { - try { - console.error('[StravaLambda] MCP request received:', req.body.method); - - const { jsonrpc, id, method, params } = req.body; - - // Validate JSON-RPC request - if (jsonrpc !== '2.0' || !method) { - return res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32600, - message: 'Invalid Request', - }, - id: id || null, - }); - } - - // Route to appropriate handler - let result; - switch (method) { - case 'tools/list': - result = { tools: allTools }; - break; - - case 'tools/call': - if (!params || !params.name) { - return res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32602, - message: 'Invalid params: missing tool name', - }, - id, - }); - } - result = await handleToolCall(params.name, params.arguments || {}); - break; - - case 'initialize': - result = { - protocolVersion: '2024-11-05', - capabilities: { - tools: {}, - }, - serverInfo: { - name: 'strava-mcp-server', - version: '3.0.0', - }, - }; - break; - - default: - return res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32601, - message: `Method not found: ${method}`, - }, - id, - }); - } - - res.json({ - jsonrpc: '2.0', - result, - id, - }); - } catch (error) { - console.error('[StravaLambda] MCP request error:', error); - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error', - data: error instanceof Error ? error.message : String(error), - }, - id: req.body.id || null, - }); - } - }); - - // Helper function to handle tool calls - async function handleToolCall(name: string, args: any) { - switch (name) { - // Activity tools - case 'get_activities': { - const params = GetActivitiesSchema.parse(args); - const result = await getActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_by_id': { - const params = GetActivityByIdSchema.parse(args); - const result = await getActivityById(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'create_activity': { - const params = CreateActivitySchema.parse(args); - const result = await createActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'update_activity': { - const params = UpdateActivitySchema.parse(args); - const result = await updateActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_activity_zones': { - const params = GetActivityZonesSchema.parse(args); - const result = await getActivityZones(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Athlete tools - case 'get_athlete': { - const result = await getAthlete(stravaClient); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_athlete_stats': { - const params = GetAthleteStatsSchema.parse(args || {}); - const result = await getAthleteStats(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Streams tools - case 'get_activity_streams': { - const params = GetActivityStreamsSchema.parse(args); - const result = await getActivityStreams(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Clubs tools - case 'get_club_activities': { - const params = GetClubActivitiesSchema.parse(args); - const result = await getClubActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // Uploads tools - case 'create_upload': { - const params = CreateUploadSchema.parse(args); - const result = await createUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - case 'get_upload': { - const params = GetUploadSchema.parse(args); - const result = await getUpload(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - - // OpenAI tools - case 'search': { - const params = SearchSchema.parse(args); - const result = await searchActivities(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - case 'fetch': { - const params = FetchSchema.parse(args); - const result = await fetchActivity(stravaClient, params); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - - default: - throw new Error(`Unknown tool: ${name}`); - } - } - - console.error('[StravaLambda] Express app initialized'); - return app; -} - -// Initialize the server once (outside handler for Lambda container reuse) -// This improves performance by reusing initialized resources across invocations -const expressApp = initializeServer(); - -// Lambda handler using serverless-express (codegenie) -// This creates the handler function that Lambda will call -export const handler = serverlessExpress({ app: expressApp }); diff --git a/src/oauth/server.ts b/src/oauth/server.ts new file mode 100644 index 0000000..14f1c76 --- /dev/null +++ b/src/oauth/server.ts @@ -0,0 +1,445 @@ +import type { Express, Request, Response } from 'express'; +import { URL } from 'node:url'; +import { generateToken, isValidPkceS256 } from './utils.js'; +import { + consumeAuthCode, + getClient, + getTokenByAccess, + getTokenByRefresh, + putAuthCode, + putClient, + putToken, + touchClient, +} from './store.js'; + +export interface OAuthConfig { + enabled: boolean; + clientsTable: string; + codesTable: string; + tokensTable: string; + accessTokenTtlSeconds: number; + refreshTokenTtlSeconds: number; + allowedRedirectUris: string[]; + registrationToken?: string; +} + +const DEFAULT_SCOPES = ['strava']; + +function getBaseUrl(req: Request): string { + const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol; + const host = + (req.headers['x-forwarded-host'] as string) || + (req.headers['host'] as string); + return `${proto}://${host}`.replace(/\/+$/, ''); +} + +function normalizeScopes(scope?: string): string[] { + if (!scope) { + return DEFAULT_SCOPES; + } + return scope + .split(' ') + .map((value) => value.trim()) + .filter(Boolean); +} + +function ensureRedirectAllowed(redirectUri: string, allowed: string[]): boolean { + return allowed.includes(redirectUri); +} + +function writeOAuthError(res: Response, status: number, error: string, description?: string) { + res.status(status).json({ + error, + error_description: description, + }); +} + +function getBearerToken(value?: string): string | null { + if (!value) { + return null; + } + if (value.startsWith('Bearer ')) { + return value.substring(7); + } + return null; +} + +function renderConsentPage(params: Record, clientName?: string): string { + const safe = (value: string) => + value.replace(/&/g, '&').replace(//g, '>'); + + const fields = Object.entries(params) + .map(([key, value]) => ``) + .join('\n'); + + const title = clientName ? `Authorize ${safe(clientName)}` : 'Authorize Strava MCP'; + + return ` + + + + + ${title} + + + +
+

${title}

+

Approve this request to allow Claude to access your Strava data.

+
+ ${fields} + +
+
+ +`; +} + +function parseAuthorizeInput(source: Record) { + const response_type = source.response_type; + const client_id = source.client_id; + const redirect_uri = source.redirect_uri; + const state = source.state; + const code_challenge = source.code_challenge; + const code_challenge_method = source.code_challenge_method; + const scope = source.scope; + + return { + response_type, + client_id, + redirect_uri, + state, + code_challenge, + code_challenge_method, + scope, + }; +} + +export function registerOAuthRoutes(app: Express, config: OAuthConfig): void { + if (!config.enabled) { + return; + } + + app.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => { + const baseUrl = getBaseUrl(req); + res.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + registration_endpoint: `${baseUrl}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: DEFAULT_SCOPES, + }); + }); + + app.post('/register', async (req: Request, res: Response) => { + try { + if (config.registrationToken) { + const authHeader = req.headers['authorization'] as string | undefined; + const headerToken = getBearerToken(authHeader) || (req.headers['x-registration-token'] as string | undefined); + if (headerToken !== config.registrationToken) { + return writeOAuthError(res, 401, 'invalid_client', 'Registration token required'); + } + } + + const { redirect_uris, client_name } = req.body || {}; + + if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) { + return writeOAuthError(res, 400, 'invalid_client_metadata', 'redirect_uris is required'); + } + + const invalid = redirect_uris.some( + (uri: string) => !ensureRedirectAllowed(uri, config.allowedRedirectUris) + ); + + if (invalid) { + return writeOAuthError(res, 400, 'invalid_redirect_uri', 'redirect_uri not allowed'); + } + + const clientId = generateToken(24); + const now = Math.floor(Date.now() / 1000); + + await putClient(config.clientsTable, { + client_id: clientId, + redirect_uris, + client_name, + created_at: now, + }); + + res.status(201).json({ + client_id: clientId, + client_id_issued_at: now, + token_endpoint_auth_method: 'none', + redirect_uris, + response_types: ['code'], + grant_types: ['authorization_code', 'refresh_token'], + }); + } catch (error) { + console.error('[OAuth] Registration error:', error); + writeOAuthError(res, 500, 'server_error', 'Failed to register client'); + } + }); + + app.get('/authorize', async (req: Request, res: Response) => { + try { + const { + response_type, + client_id, + redirect_uri, + state, + code_challenge, + code_challenge_method, + scope, + } = parseAuthorizeInput(req.query as Record); + + if (response_type !== 'code') { + return writeOAuthError(res, 400, 'unsupported_response_type'); + } + + if (!client_id || !redirect_uri || !code_challenge || !code_challenge_method) { + return writeOAuthError(res, 400, 'invalid_request', 'Missing required parameters'); + } + + if (code_challenge_method !== 'S256') { + return writeOAuthError(res, 400, 'invalid_request', 'Only S256 is supported'); + } + + const client = await getClient(config.clientsTable, client_id); + if (!client) { + return writeOAuthError(res, 400, 'invalid_client', 'Unknown client'); + } + + if (!client.redirect_uris.includes(redirect_uri)) { + return writeOAuthError(res, 400, 'invalid_redirect_uri', 'Redirect URI mismatch'); + } + + if (!ensureRedirectAllowed(redirect_uri, config.allowedRedirectUris)) { + return writeOAuthError(res, 400, 'invalid_redirect_uri', 'Redirect URI not allowed'); + } + + const consentParams: Record = { + response_type, + client_id, + redirect_uri, + code_challenge, + code_challenge_method, + }; + + if (state) { + consentParams.state = state; + } + + if (scope) { + consentParams.scope = scope; + } + + res.status(200).send(renderConsentPage(consentParams, client.client_name)); + } catch (error) { + console.error('[OAuth] Authorize error:', error); + writeOAuthError(res, 500, 'server_error', 'Authorization failed'); + } + }); + + app.post('/authorize', async (req: Request, res: Response) => { + try { + const { + response_type, + client_id, + redirect_uri, + state, + code_challenge, + code_challenge_method, + scope, + } = parseAuthorizeInput(req.body as Record); + + if (response_type !== 'code') { + return writeOAuthError(res, 400, 'unsupported_response_type'); + } + + if (!client_id || !redirect_uri || !code_challenge || !code_challenge_method) { + return writeOAuthError(res, 400, 'invalid_request', 'Missing required parameters'); + } + + if (code_challenge_method !== 'S256') { + return writeOAuthError(res, 400, 'invalid_request', 'Only S256 is supported'); + } + + const client = await getClient(config.clientsTable, client_id); + if (!client) { + return writeOAuthError(res, 400, 'invalid_client', 'Unknown client'); + } + + if (!client.redirect_uris.includes(redirect_uri)) { + return writeOAuthError(res, 400, 'invalid_redirect_uri', 'Redirect URI mismatch'); + } + + if (!ensureRedirectAllowed(redirect_uri, config.allowedRedirectUris)) { + return writeOAuthError(res, 400, 'invalid_redirect_uri', 'Redirect URI not allowed'); + } + + const now = Math.floor(Date.now() / 1000); + const code = generateToken(24); + const scopes = normalizeScopes(scope); + + await putAuthCode(config.codesTable, { + code, + client_id, + redirect_uri, + code_challenge, + code_challenge_method: 'S256', + scopes, + expires_at: now + 300, + }); + + await touchClient(config.clientsTable, client_id); + + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('code', code); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + res.redirect(302, redirectUrl.toString()); + } catch (error) { + console.error('[OAuth] Authorize error:', error); + writeOAuthError(res, 500, 'server_error', 'Authorization failed'); + } + }); + + app.post('/token', async (req: Request, res: Response) => { + try { + const grantType = req.body?.grant_type as string | undefined; + if (!grantType) { + return writeOAuthError(res, 400, 'invalid_request', 'grant_type is required'); + } + + if (grantType === 'authorization_code') { + const code = req.body?.code as string | undefined; + const redirectUri = req.body?.redirect_uri as string | undefined; + const clientId = req.body?.client_id as string | undefined; + const verifier = req.body?.code_verifier as string | undefined; + + if (!code || !redirectUri || !clientId || !verifier) { + return writeOAuthError(res, 400, 'invalid_request', 'Missing required parameters'); + } + + const authCode = await consumeAuthCode(config.codesTable, code); + if (!authCode) { + return writeOAuthError(res, 400, 'invalid_grant', 'Invalid or expired code'); + } + + if (authCode.client_id !== clientId || authCode.redirect_uri !== redirectUri) { + return writeOAuthError(res, 400, 'invalid_grant', 'Client or redirect mismatch'); + } + + const now = Math.floor(Date.now() / 1000); + if (authCode.expires_at < now) { + return writeOAuthError(res, 400, 'invalid_grant', 'Authorization code expired'); + } + + if (!isValidPkceS256(verifier, authCode.code_challenge)) { + return writeOAuthError(res, 400, 'invalid_grant', 'PKCE verification failed'); + } + + const accessToken = generateToken(32); + const refreshToken = generateToken(32); + const accessExpiresAt = now + config.accessTokenTtlSeconds; + const refreshExpiresAt = now + config.refreshTokenTtlSeconds; + + await putToken(config.tokensTable, { + access_token: accessToken, + refresh_token: refreshToken, + client_id: clientId, + scopes: authCode.scopes, + refresh_expires_at: refreshExpiresAt, + access_expires_at: accessExpiresAt, + expires_at: refreshExpiresAt, + }); + + await touchClient(config.clientsTable, clientId); + + return res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: config.accessTokenTtlSeconds, + refresh_token: refreshToken, + scope: authCode.scopes.join(' '), + }); + } + + if (grantType === 'refresh_token') { + const refreshToken = req.body?.refresh_token as string | undefined; + const clientId = req.body?.client_id as string | undefined; + + if (!refreshToken || !clientId) { + return writeOAuthError(res, 400, 'invalid_request', 'Missing refresh_token or client_id'); + } + + const storedToken = await getTokenByRefresh(config.tokensTable, refreshToken); + if (!storedToken) { + return writeOAuthError(res, 400, 'invalid_grant', 'Unknown refresh token'); + } + + if (storedToken.client_id !== clientId) { + return writeOAuthError(res, 400, 'invalid_grant', 'Client mismatch'); + } + + const now = Math.floor(Date.now() / 1000); + if (storedToken.refresh_expires_at < now) { + return writeOAuthError(res, 400, 'invalid_grant', 'Refresh token expired'); + } + + const accessToken = generateToken(32); + const accessExpiresAt = now + config.accessTokenTtlSeconds; + + await putToken(config.tokensTable, { + ...storedToken, + access_token: accessToken, + access_expires_at: accessExpiresAt, + expires_at: storedToken.refresh_expires_at, + }); + + await touchClient(config.clientsTable, clientId); + + return res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: config.accessTokenTtlSeconds, + refresh_token: storedToken.refresh_token, + scope: storedToken.scopes.join(' '), + }); + } + + return writeOAuthError(res, 400, 'unsupported_grant_type'); + } catch (error) { + console.error('[OAuth] Token error:', error); + writeOAuthError(res, 500, 'server_error', 'Token exchange failed'); + } + }); +} + +export async function validateAccessToken( + config: OAuthConfig, + accessToken: string +): Promise { + if (!config.enabled) { + return false; + } + + const record = await getTokenByAccess(config.tokensTable, accessToken); + if (!record) { + return false; + } + + const now = Math.floor(Date.now() / 1000); + return record.access_expires_at > now; +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts new file mode 100644 index 0000000..5c14e84 --- /dev/null +++ b/src/oauth/store.ts @@ -0,0 +1,150 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + DeleteCommand, + GetCommand, + PutCommand, + QueryCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; + +export interface OAuthClient { + client_id: string; + redirect_uris: string[]; + created_at: number; + last_used_at?: number; + client_name?: string; +} + +export interface OAuthAuthCode { + code: string; + client_id: string; + redirect_uri: string; + code_challenge: string; + code_challenge_method: 'S256'; + scopes: string[]; + expires_at: number; +} + +export interface OAuthTokenRecord { + access_token: string; + refresh_token: string; + client_id: string; + scopes: string[]; + access_expires_at: number; + refresh_expires_at: number; + expires_at: number; +} + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +export const REFRESH_TOKEN_INDEX = 'refresh_token_index'; + +export async function putClient(tableName: string, client: OAuthClient): Promise { + await ddb.send( + new PutCommand({ + TableName: tableName, + Item: client, + }) + ); +} + +export async function getClient( + tableName: string, + clientId: string +): Promise { + const result = await ddb.send( + new GetCommand({ + TableName: tableName, + Key: { client_id: clientId }, + }) + ); + return (result.Item as OAuthClient) || null; +} + +export async function touchClient(tableName: string, clientId: string): Promise { + await ddb.send( + new UpdateCommand({ + TableName: tableName, + Key: { client_id: clientId }, + UpdateExpression: 'SET last_used_at = :now', + ExpressionAttributeValues: { + ':now': Math.floor(Date.now() / 1000), + }, + }) + ); +} + +export async function putAuthCode(tableName: string, code: OAuthAuthCode): Promise { + await ddb.send( + new PutCommand({ + TableName: tableName, + Item: code, + }) + ); +} + +export async function consumeAuthCode( + tableName: string, + code: string +): Promise { + const result = await ddb.send( + new GetCommand({ + TableName: tableName, + Key: { code }, + }) + ); + if (!result.Item) { + return null; + } + + await ddb.send( + new DeleteCommand({ + TableName: tableName, + Key: { code }, + }) + ); + + return result.Item as OAuthAuthCode; +} + +export async function putToken(tableName: string, token: OAuthTokenRecord): Promise { + await ddb.send( + new PutCommand({ + TableName: tableName, + Item: token, + }) + ); +} + +export async function getTokenByAccess( + tableName: string, + accessToken: string +): Promise { + const result = await ddb.send( + new GetCommand({ + TableName: tableName, + Key: { access_token: accessToken }, + }) + ); + return (result.Item as OAuthTokenRecord) || null; +} + +export async function getTokenByRefresh( + tableName: string, + refreshToken: string +): Promise { + const result = await ddb.send( + new QueryCommand({ + TableName: tableName, + IndexName: REFRESH_TOKEN_INDEX, + KeyConditionExpression: 'refresh_token = :refresh', + ExpressionAttributeValues: { + ':refresh': refreshToken, + }, + Limit: 1, + }) + ); + + return (result.Items?.[0] as OAuthTokenRecord) || null; +} diff --git a/src/oauth/utils.ts b/src/oauth/utils.ts new file mode 100644 index 0000000..2608620 --- /dev/null +++ b/src/oauth/utils.ts @@ -0,0 +1,31 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export function base64UrlEncode(input: Buffer): string { + return input + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +export function sha256Base64Url(value: string): string { + const hash = createHash('sha256').update(value).digest(); + return base64UrlEncode(hash); +} + +export function generateToken(bytes = 32): string { + return base64UrlEncode(randomBytes(bytes)); +} + +export function isValidPkceS256(verifier: string, challenge: string): boolean { + const length = verifier.length; + if (length < 43 || length > 128) { + return false; + } + + if (!/^[A-Za-z0-9\-._~]+$/.test(verifier)) { + return false; + } + + return sha256Base64Url(verifier) === challenge; +} diff --git a/template.yaml b/template.yaml index fc3a0b2..15217aa 100644 --- a/template.yaml +++ b/template.yaml @@ -37,16 +37,35 @@ Parameters: Type: String Description: Bearer token for authenticating MCP client requests (use a long random string) NoEcho: true - MinLength: 32 + Default: "" + AllowedPattern: '^$|.{32,}$' - AllowAuthless: + SecretsManagerArn: Type: String - Description: When "true", allows SSE connections without authentication (for Claude.ai custom connectors) + Description: Optional Secrets Manager ARN containing Strava credentials (JSON payload) + Default: "" + + OAuthEnabled: + Type: String + Description: Enable OAuth 2.1 endpoints for Claude Web connectors Default: "true" AllowedValues: - "true" - "false" + OAuthAllowedRedirectUris: + Type: String + Description: Comma-separated list of allowed OAuth redirect URIs + Default: "https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback" + + OAuthRegistrationToken: + Type: String + Description: Optional bearer token to protect dynamic client registration + Default: "" + +Conditions: + HasSecretsManagerArn: !Not [!Equals [!Ref SecretsManagerArn, ""]] + Resources: StravaMCPFunction: Type: AWS::Serverless::Function @@ -54,19 +73,42 @@ Resources: FunctionName: strava-mcp-server Description: Strava MCP Server with Streamable HTTP transport CodeUri: dist/ - Handler: lambda.handler + Handler: run.sh Environment: Variables: STRAVA_CLIENT_ID: !Ref StravaClientId STRAVA_CLIENT_SECRET: !Ref StravaClientSecret STRAVA_REFRESH_TOKEN: !Ref StravaRefreshToken AUTH_TOKEN: !Ref AuthToken - ALLOW_AUTHLESS: !Ref AllowAuthless + SECRETS_MANAGER_ARN: !Ref SecretsManagerArn + OAUTH_ENABLED: !Ref OAuthEnabled + OAUTH_CLIENTS_TABLE: !Ref OAuthClientsTable + OAUTH_CODES_TABLE: !Ref OAuthAuthCodesTable + OAUTH_TOKENS_TABLE: !Ref OAuthTokensTable + OAUTH_ALLOWED_REDIRECT_URIS: !Ref OAuthAllowedRedirectUris + OAUTH_REGISTRATION_TOKEN: !Ref OAuthRegistrationToken + AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap + AWS_LWA_PORT: 8080 + AWS_LWA_READINESS_CHECK_PATH: /health + Layers: + - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerArm64:25 + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref OAuthClientsTable + - DynamoDBCrudPolicy: + TableName: !Ref OAuthAuthCodesTable + - DynamoDBCrudPolicy: + TableName: !Ref OAuthTokensTable + - Statement: + Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !If [HasSecretsManagerArn, !Ref SecretsManagerArn, !Ref AWS::NoValue] 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 @@ -77,13 +119,64 @@ Resources: - Accept - X-Requested-With AllowCredentials: false - MaxAge: 86400 # Cache preflight for 24 hours + MaxAge: 86400 InvokeMode: RESPONSE_STREAM # Enable streaming for SSE support Tags: Project: StravaMCP Environment: Production CostCenter: FreeTier + OAuthClientsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: strava-mcp-oauth-clients + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: client_id + AttributeType: S + KeySchema: + - AttributeName: client_id + KeyType: HASH + + OAuthAuthCodesTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: strava-mcp-oauth-codes + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: code + AttributeType: S + KeySchema: + - AttributeName: code + KeyType: HASH + TimeToLiveSpecification: + AttributeName: expires_at + Enabled: true + + OAuthTokensTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: strava-mcp-oauth-tokens + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: access_token + AttributeType: S + - AttributeName: refresh_token + AttributeType: S + KeySchema: + - AttributeName: access_token + KeyType: HASH + GlobalSecondaryIndexes: + - IndexName: refresh_token_index + KeySchema: + - AttributeName: refresh_token + KeyType: HASH + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: expires_at + Enabled: true + Outputs: StravaMCPUrl: Description: "Function URL for Strava MCP Server" @@ -94,8 +187,8 @@ Outputs: Value: !GetAtt StravaMCPFunction.Arn ClaudeConnectionUrl: - Description: "URL to use for Claude MCP connector" - Value: !Sub "${StravaMCPFunctionUrl.FunctionUrl}mcp" + Description: "Base URL to use for Claude MCP connector" + Value: !GetAtt StravaMCPFunctionUrl.FunctionUrl HealthCheckUrl: Description: "Health check endpoint"