diff --git a/package-lock.json b/package-lock.json index 21fb6b9..4d2e125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,27 @@ { "name": "@supadata/mcp", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@supadata/mcp", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.1.0", "@modelcontextprotocol/sdk": "^1.25.3", "@supadata/js": "^1.4.0", "dotenv": "^16.4.7", + "hono": "^4.7.0", + "jose": "^6.0.0", "zod": "^3.25.76" }, "bin": { "mcp": "dist/index.js" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", "@jest/globals": "^29.7.0", "@types/express": "^5.0.1", "@types/jest": "^29.5.14", @@ -568,6 +572,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@cloudflare/workers-oauth-provider": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-0.1.0.tgz", + "integrity": "sha512-wok0RqyZlxkaKJCmGGXKyWkN0znRs9SFfTOJ1/TJxnv6N/lfXbCYr5hCbsgmOdvVi8JsKKYWj/qkqOLZybgAyQ==", + "license": "MIT" + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260317.1.tgz", + "integrity": "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", diff --git a/package.json b/package.json index 39f3c68..b29ba6b 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,16 @@ }, "license": "MIT", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.1.0", "@modelcontextprotocol/sdk": "^1.25.3", "@supadata/js": "^1.4.0", "dotenv": "^16.4.7", + "hono": "^4.7.0", + "jose": "^6.0.0", "zod": "^3.25.76" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", "@jest/globals": "^29.7.0", "@types/express": "^5.0.1", "@types/jest": "^29.5.14", @@ -58,4 +62,4 @@ "url": "https://github.com/supadata-ai/mcp/issues" }, "homepage": "https://github.com/supadata-ai/mcp#readme" -} \ No newline at end of file +} diff --git a/src/auth-handler.ts b/src/auth-handler.ts new file mode 100644 index 0000000..7227923 --- /dev/null +++ b/src/auth-handler.ts @@ -0,0 +1,133 @@ +import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider'; +import { Hono } from 'hono'; +import { jwtVerify } from 'jose'; + +type Env = { + OAUTH_PROVIDER: OAuthHelpers; + OAUTH_KV: KVNamespace; + DASHBOARD_URL: string; + MCP_JWT_SECRET: string; + COOKIE_ENCRYPTION_KEY: string; +}; + +const app = new Hono<{ Bindings: Env }>(); + +// Store OAuth request info in KV, keyed by a random state token +async function createState( + oauthReqInfo: AuthRequest, + kv: KVNamespace +): Promise { + const stateToken = crypto.randomUUID(); + await kv.put( + `oauth_state:${stateToken}`, + JSON.stringify(oauthReqInfo), + { expirationTtl: 600 } // 10 minutes + ); + return stateToken; +} + +// GET /oauth/authorize — redirect to dashboard for login +app.get('/oauth/authorize', async (c) => { + console.log('[Auth] /oauth/authorize hit'); + const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); + console.log('[Auth] Parsed auth request:', JSON.stringify({ clientId: oauthReqInfo.clientId, redirectUri: oauthReqInfo.redirectUri, scope: oauthReqInfo.scope, resource: oauthReqInfo.resource })); + if (!oauthReqInfo.clientId) { + return c.text('Invalid request: missing client_id', 400); + } + + const stateToken = await createState(oauthReqInfo, c.env.OAUTH_KV); + + const callbackUrl = new URL('/oauth/callback', c.req.url).href; + const dashboardUrl = new URL('/oauth/mcp', c.env.DASHBOARD_URL); + dashboardUrl.searchParams.set('state', stateToken); + dashboardUrl.searchParams.set('callback', callbackUrl); + + console.log('[Auth] Redirecting to dashboard:', dashboardUrl.toString()); + return c.redirect(dashboardUrl.toString()); +}); + +// GET /oauth/callback — validate JWT from dashboard and complete authorization +app.get('/oauth/callback', async (c) => { + console.log('[Auth] /oauth/callback hit'); + const token = c.req.query('token'); + const stateToken = c.req.query('state'); + const error = c.req.query('error'); + + // Handle deny + if (error) { + return c.text(`Authorization denied: ${error}`, 403); + } + + if (!token || !stateToken) { + return c.text('Missing token or state parameter', 400); + } + + // Retrieve and validate the OAuth request info from KV + const storedState = await c.env.OAUTH_KV.get(`oauth_state:${stateToken}`); + if (!storedState) { + return c.text('Invalid or expired state', 400); + } + + // Delete state to prevent replay + await c.env.OAUTH_KV.delete(`oauth_state:${stateToken}`); + + let oauthReqInfo: AuthRequest; + try { + oauthReqInfo = JSON.parse(storedState); + } catch { + return c.text('Corrupted state data', 400); + } + + // Validate the JWT from the dashboard + let payload; + try { + const secret = new TextEncoder().encode(c.env.MCP_JWT_SECRET); + const result = await jwtVerify(token, secret); + payload = result.payload; + } catch (err: any) { + console.error('JWT verification failed:', err.message); + return c.text('Invalid or expired token', 401); + } + + const userId = payload.sub; + const apiKey = payload.api_key as string; + const name = payload.name as string; + const email = payload.email as string; + + if (!userId || !apiKey) { + return c.text('Invalid token claims', 400); + } + + // Remove resource parameter to avoid audience validation issues. + // Claude sends resource=https://api.supadata.ai/ (trailing slash) but the library + // validates tokens against protocol://host (no slash) using strict equality. + // This mismatch causes "Token audience does not match resource server" on every + // MCP request. Removing resource skips audience validation entirely. + delete (oauthReqInfo as any).resource; + + console.log('[Auth] JWT valid, userId:', userId, 'resource:', oauthReqInfo.resource); + + // Complete the OAuth authorization — the library issues its own tokens + const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ + request: oauthReqInfo, + userId, + scope: oauthReqInfo.scope || ['mcp'], + metadata: { + label: name || email || userId + }, + props: { + apiKey, + userId, + name, + email + } + }); + + console.log('[Auth] completeAuthorization redirectTo:', redirectTo); + return c.redirect(redirectTo); +}); + +// Wrap Hono app as an ExportedHandler for OAuthProvider compatibility +export const authHandler = { + fetch: (request: Request, env: any, ctx: any) => app.fetch(request, env, ctx), +}; diff --git a/src/worker.ts b/src/worker.ts index 1f8c3d6..3ecd319 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,91 +1,171 @@ -import { createMcpServer } from './mcp.js'; +import OAuthProvider from '@cloudflare/workers-oauth-provider'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -export default { - async fetch(request: Request, env: { SUPADATA_API_KEY: string }, _ctx: any): Promise { - // Health check — no server/transport needed - const url = new URL(request.url); - if (url.pathname === '/' && request.method === 'GET') { - return new Response('Supadata MCP Worker is running.', { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - } +import { createMcpServer } from './mcp.js'; +import { authHandler } from './auth-handler.js'; - // In stateless mode, only POST is supported for JSON-RPC requests. - // GET (SSE streams) and DELETE (session termination) don't work - // because server.close() in the finally block would terminate them. - if (request.method !== 'POST') { - return new Response( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Method not allowed. Use POST for JSON-RPC requests.', - }, - id: null, - }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, +// Handle MCP requests with a direct API key (legacy / backward-compatible) +async function handleMcpWithApiKey(request: Request, apiKey: string): Promise { + if (request.method !== 'POST') { + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed. Use POST for JSON-RPC requests.', }, - ); - } + id: null, + }), + { + status: 405, + headers: { 'Content-Type': 'application/json', Allow: 'POST' }, + }, + ); + } - let server: Server | null = null; + let server: Server | null = null; - try { - let apiKey = request.headers.get('x-api-key'); + try { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); - if (!apiKey) { - apiKey = request.headers.get('x-api-token'); - } + const result = createMcpServer({ supadataApiKey: apiKey }); + server = result.server; - if (!apiKey) { - apiKey = request.headers.get('supadata-api-key'); - } + await server.connect(transport); - if (!apiKey) { - apiKey = env.SUPADATA_API_KEY; - } + const response = await transport.handleRequest(request); + return response ?? new Response('Not Found', { status: 404 }); + } catch (err: any) { + console.error('Worker Error:', err); + return new Response( + JSON.stringify({ error: `Server Internal Error: ${err?.message}` }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + } finally { + if (server) { + await server.close(); + } + } +} - if (!apiKey) { - console.warn('No API key provided via headers (x-api-key) or environment (SUPADATA_API_KEY).'); - } else { - console.log(`Received API Key (length: ${apiKey.length})`); - } +// OAuth-authenticated MCP handler +// The OAuthProvider injects authenticated `props` (including apiKey) via the execution context +const mcpHandler = { + async fetch(request: Request, env: any, ctx: any): Promise { + const props = ctx?.props || {}; + const apiKey = props.apiKey as string; - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }); + if (!apiKey) { + return new Response( + JSON.stringify({ error: 'No API key in OAuth context' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return handleMcpWithApiKey(request, apiKey); + }, +}; + +export default { + async fetch(request: Request, env: any, ctx: any): Promise { + const url = new URL(request.url); - const result = createMcpServer({ - supadataApiKey: apiKey || '', + // Health check + if (url.pathname === '/' && request.method === 'GET') { + return new Response('Supadata MCP Worker is running.', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, }); - server = result.server; + } - await server.connect(transport); + // Legacy API key support: if x-api-key header is present, bypass OAuth entirely + const apiKey = request.headers.get('x-api-key') + || request.headers.get('x-api-token') + || request.headers.get('supadata-api-key'); - const response = await transport.handleRequest(request); - return response ?? new Response('Not Found', { status: 404 }); + if (apiKey && url.pathname === '/mcp') { + console.log(`Received API Key via header (length: ${apiKey.length})`); + return handleMcpWithApiKey(request, apiKey); + } - } catch (err: any) { - console.error('Worker Error:', err); + // Also support SUPADATA_API_KEY env var for direct POST to /mcp (backward compat) + if (!apiKey && env.SUPADATA_API_KEY && url.pathname === '/mcp' && request.method === 'POST') { + console.log(`Using env SUPADATA_API_KEY (length: ${env.SUPADATA_API_KEY.length})`); + return handleMcpWithApiKey(request, env.SUPADATA_API_KEY); + } + // Serve OAuth Protected Resource Metadata (RFC 9728) + if (url.pathname === '/.well-known/oauth-protected-resource') { return new Response( JSON.stringify({ - error: `Server Internal Error: ${err?.message}`, + resource: 'https://api.supadata.ai', + authorization_servers: ['https://api.supadata.ai'], + scopes_supported: ['mcp'], + bearer_methods_supported: ['header'], }), - { status: 500, headers: { 'Content-Type': 'application/json' } }, + { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, ); - } finally { - if (server) { - await server.close(); + } + + // Log all requests for debugging + console.log(`[OAuth] ${request.method} ${url.pathname} (auth: ${request.headers.has('authorization') ? 'Bearer' : 'none'})`); + + // Strip `resource` parameter from token exchange requests. + // Claude sends resource=https://api.supadata.ai/ (trailing slash) which gets stored + // as the token audience. The library then validates tokens against protocol://host + // (no trailing slash) using strict equality, causing every MCP request to fail with + // "Token audience does not match resource server". Removing `resource` from both + // the grant (auth-handler) and the token exchange body prevents audience from being set. + let processedRequest = request; + if (url.pathname === '/oauth/token' && request.method === 'POST') { + const body = await request.text(); + const params = new URLSearchParams(body); + if (params.has('resource')) { + console.log(`[OAuth] Stripping resource param from token exchange: ${params.get('resource')}`); + params.delete('resource'); + processedRequest = new Request(request, { body: params.toString() }); } } + + // OAuth flow for everything else + const provider = new OAuthProvider({ + apiRoute: '/mcp', + apiHandler: mcpHandler, + defaultHandler: authHandler, + authorizeEndpoint: '/oauth/authorize', + tokenEndpoint: '/oauth/token', + clientRegistrationEndpoint: '/oauth/register', + scopesSupported: ['mcp'], + accessTokenTTL: 3600, // 1 hour + refreshTokenTTL: 2592000, // 30 days + onError({ code, description, status, headers }) { + console.log(`[OAuth Error] status=${status} code=${code} desc=${description}`); + // Add resource_metadata to WWW-Authenticate header on 401 (RFC 9728) + if (status === 401) { + const newHeaders = { ...headers }; + newHeaders['WWW-Authenticate'] = + `Bearer resource_metadata="https://api.supadata.ai/.well-known/oauth-protected-resource"`; + return new Response(JSON.stringify({ error: code, error_description: description }), { + status, + headers: { ...newHeaders, 'Content-Type': 'application/json' }, + }); + } + }, + }); + + const response = await provider.fetch(processedRequest, env, ctx); + console.log(`[OAuth Response] ${url.pathname} -> ${response.status}`); + return response; }, }; diff --git a/tsconfig.json b/tsconfig.json index 18ef969..7c97e21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "types": ["@cloudflare/workers-types"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] diff --git a/wrangler.toml b/wrangler.toml index dfdd60a..3abcec7 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,7 +1,17 @@ name = "supadata-mcp" main = "src/worker.ts" compatibility_date = "2024-12-01" -routes = [{ pattern = "api.supadata.ai/mcp", zone_name = "supadata.ai" }] +routes = [ + { pattern = "api.supadata.ai/mcp", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/.well-known/oauth-authorization-server", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/.well-known/oauth-protected-resource", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/oauth/*", zone_name = "supadata.ai" }, +] [observability] enabled = true + +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "7fdecd45b4e74de2aeaf8d53cb8309af" +preview_id = "c37fddf9dc494ab99956edf0f9296cfd"