diff --git a/MCP_SETUP.md b/MCP_SETUP.md index cc600dd..70d80d4 100644 --- a/MCP_SETUP.md +++ b/MCP_SETUP.md @@ -25,7 +25,7 @@ AES-256-GCM under `AKADA_MCP_TOKEN_SECRET` (`lib/mcp-auth.ts`). | --- | --- | | Protected resource metadata (RFC 9728) | `/.well-known/oauth-protected-resource/mcp`, also `/.well-known/oauth-protected-resource/api/mcp` and `/.well-known/oauth-protected-resource` for clients that probe | | Authorization server metadata (RFC 8414) | `/.well-known/oauth-authorization-server`, issuer = `NEXT_PUBLIC_SITE_URL` | -| Dynamic client registration (RFC 7591) | `POST /api/mcp/register`. Only known callbacks: `https://claude.ai/api/mcp/auth_callback`, ChatGPT's, Gemini's, and `http://localhost` / `127.0.0.1` `/callback` or `/oauth/callback` on any port (Claude Code, Gemini CLI) | +| Dynamic client registration (RFC 7591) | `POST /api/mcp/register`. Only known callbacks: `https://claude.ai/api/mcp/auth_callback`, ChatGPT's (`https://chatgpt.com/connector/oauth/`, `…/connector_platform_oauth_redirect`, `platform.openai.com/apps-manage/oauth`), Gemini's, and `http://localhost` / `127.0.0.1` `/callback` or `/oauth/callback` on any port (Claude Code, Gemini CLI) | | Authorization + consent | `GET/POST /api/mcp/authorize`. PKCE S256 required. The user must be signed in to Akada, then confirms their password, which starts a Supabase session that only the connector refreshes | | Token | `POST /api/mcp/token`, form-encoded, public client (`none`). `authorization_code` and `refresh_token` grants | diff --git a/app/api/mcp/_shared.ts b/app/api/mcp/_shared.ts index 673022e..753b389 100644 --- a/app/api/mcp/_shared.ts +++ b/app/api/mcp/_shared.ts @@ -24,7 +24,15 @@ function isLoopbackCallback(uri: string) { // every run, so it's matched by shape instead of an exact string). const KNOWN_CALLBACKS: KnownCallback[] = [ { name: 'Claude', test: (uri) => uri === 'https://claude.ai/api/mcp/auth_callback' }, - { name: 'ChatGPT', test: (uri) => uri === 'https://chatgpt.com/connector_platform_oauth_redirect' }, + { + name: 'ChatGPT', + // The plugin dialog hands each connector its own /connector/oauth/ + // callback; the older apps flow and OpenAI's review tool use fixed ones. + test: (uri) => + uri === 'https://chatgpt.com/connector_platform_oauth_redirect' || + uri === 'https://platform.openai.com/apps-manage/oauth' || + /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/.test(uri), + }, { name: 'Gemini', test: (uri) => uri === 'https://vertexaisearch.cloud.google.com/oauth-redirect' || isLoopbackCallback(uri), diff --git a/app/api/mcp/oauth.test.ts b/app/api/mcp/oauth.test.ts index 5091ceb..73419a1 100644 --- a/app/api/mcp/oauth.test.ts +++ b/app/api/mcp/oauth.test.ts @@ -72,6 +72,10 @@ test('register: Claude and any-port loopback callbacks are accepted, others are assert.equal((await register([CLAUDE_CALLBACK])).status, 201); assert.equal((await register(['http://localhost:3118/callback'])).status, 201); assert.equal((await register(['http://127.0.0.1:49152/callback'])).status, 201); + assert.equal((await register(['https://chatgpt.com/connector/oauth/abc_123-XYZ'])).status, 201); + assert.equal((await register(['https://chatgpt.com/connector_platform_oauth_redirect', 'https://platform.openai.com/apps-manage/oauth'])).status, 201); + assert.equal((await register(['https://chatgpt.com/connector/oauth/abc/../evil'])).status, 400); + assert.equal((await register([CLAUDE_CALLBACK, 'https://evil.example/callback'])).status, 400); assert.equal((await register(['https://evil.example/callback'])).status, 400); }); diff --git a/app/api/mcp/register/route.ts b/app/api/mcp/register/route.ts index b02bbd3..33b55fb 100644 --- a/app/api/mcp/register/route.ts +++ b/app/api/mcp/register/route.ts @@ -16,7 +16,8 @@ export async function POST(request: NextRequest) { const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((uri): uri is string => typeof uri === 'string') : []; - const clientName = redirectUris.length === 1 ? matchKnownCallback(redirectUris[0]) : null; + const names = redirectUris.map(matchKnownCallback); + const clientName = redirectUris.length > 0 && names.every((name) => name && name === names[0]) ? names[0] : null; if (!clientName) { return oauthError( 'invalid_redirect_uri',