diff --git a/README.md b/README.md index 9441fb0..612af86 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,17 @@ claude mcp add --transport sse --scope user absmartly \ -H "Authorization:my-subdomain YOUR_API_KEY" ``` +#### Remote with API Key (Streamable HTTP — recommended for new installs) + +```bash +claude mcp add --transport http --scope user absmartly \ + https://mcp.absmartly.com/mcp \ + -H "Authorization:YOUR_API_KEY" \ + -H "x-absmartly-endpoint:https://your-instance.absmartly.com" +``` + +> Streamable HTTP is the modern MCP transport (the MCP spec deprecated SSE in March 2025). The `/sse` URL still works for older clients. + #### Remote with OAuth ```bash @@ -181,6 +192,23 @@ Click the badge above to install with one click (OAuth). After Cursor prompts yo } ``` +**Or with Streamable HTTP transport (modern, recommended):** + +```json +{ + "mcpServers": { + "absmartly": { + "type": "http", + "url": "https://mcp.absmartly.com/mcp", + "headers": { + "Authorization": "YOUR_API_KEY", + "x-absmartly-endpoint": "https://your-instance.absmartly.com" + } + } + } +} +``` + #### With OAuth ```json @@ -230,6 +258,22 @@ Cursor will detect the OAuth requirement and open your browser for login. The `a > Note: Windsurf uses `"serverUrl"` instead of `"url"`. +**Or with Streamable HTTP transport:** + +```json +{ + "mcpServers": { + "absmartly": { + "serverUrl": "https://mcp.absmartly.com/mcp", + "headers": { + "Authorization": "YOUR_API_KEY", + "x-absmartly-endpoint": "https://your-instance.absmartly.com" + } + } + } +} +``` + #### With OAuth ```json @@ -291,6 +335,31 @@ Click a badge above for one-click install (OAuth). You'll be prompted for your A } ``` +**Or with Streamable HTTP transport (recommended):** + +```json +{ + "servers": { + "absmartly": { + "type": "http", + "url": "https://mcp.absmartly.com/mcp", + "headers": { + "Authorization": "${input:absmartly-api-key}", + "x-absmartly-endpoint": "https://your-instance.absmartly.com" + } + } + }, + "inputs": [ + { + "type": "promptString", + "id": "absmartly-api-key", + "description": "ABsmartly API Key", + "password": true + } + ] +} +``` + #### With OAuth ```json @@ -364,6 +433,18 @@ gemini mcp add --transport sse --scope user absmartly \ -H "x-absmartly-endpoint: https://your-instance.absmartly.com" ``` +#### Gemini Enterprise (Google Cloud Console) + +Gemini Enterprise's **Custom MCP Server** connector (Preview) requires Streamable HTTP transport — use the `/mcp` endpoint: + +1. Google Cloud Console → **Gemini Enterprise** → **Data stores** → **Create data store**. +2. Search "Custom MCP Server" → **Add MCP server**. +3. **Server URL:** `https://mcp.absmartly.com/mcp` +4. **Authentication:** OAuth — register Gemini Enterprise as an OAuth client against your identity provider, then provide the `client_id` / `client_secret`. Grant the `mcp:access` scope. +5. Save and wait for the connector status to become **Active**. + +> SSE transport (`/sse`) is **not** supported by this connector. The legacy Gemini CLI / Code Assist sections above continue to use `/sse` until those clients add Streamable HTTP support. + > Reload the IDE window after editing settings (VS Code: Command Palette → **Developer: Reload Window**). MCP support in Code Assist requires **agent preview mode** — set `"geminicodeassist.updateChannel": "Insiders"` in VS Code settings if not already enabled. ### Option 7: ChatGPT (Developer Mode) diff --git a/src/index.ts b/src/index.ts index f5aec6a..c3852e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,12 @@ import { safeKvGet, } from "./shared"; +const MCP_CORS_OPTIONS = { + origin: "*", + methods: "GET, POST, OPTIONS", + headers: "Content-Type, Authorization, Accept", +} as const; + export class ABsmartlyMCP extends McpAgent, ABsmartlyProps> { server = new McpServer( { @@ -535,13 +541,20 @@ async function verifyApiKey(apiKey: string, endpoint: string): Promise<{ ok: boo } } -const baseMcpHandler = ABsmartlyMCP.mount("/sse"); +const sseMcpHandler = ABsmartlyMCP.serveSSE("/sse", { + corsOptions: MCP_CORS_OPTIONS +}); + +const streamableMcpHandler = ABsmartlyMCP.serve("/mcp", { + corsOptions: MCP_CORS_OPTIONS +}); const oauthHandler = new ABsmartlyOAuthHandler(); const oauthProvider = new OAuthProvider({ apiHandlers: { - "/sse": baseMcpHandler + "/sse": sseMcpHandler, + "/mcp": streamableMcpHandler }, authorizeEndpoint: "/authorize", tokenEndpoint: "/token", @@ -591,6 +604,106 @@ const oauthProvider = new OAuthProvider({ }, } as any); +type McpTransportRoute = { + pathPrefix: string; + handler: { fetch: (request: Request, env: any, ctx: any) => Promise }; +}; + +async function handleMcpTransportRequest( + request: Request, + env: any, + ctx: any, + route: McpTransportRoute, + detected: { apiKey: string | null; endpoint: string | null }, + clientFingerprint: string +): Promise { + const url = new URL(request.url); + const { apiKey, endpoint } = detected; + + if (apiKey) { + debug(`API key detected on ${route.pathPrefix}, bypassing OAuth flow`); + + try { + const verifyResult = await verifyApiKey(apiKey, endpoint || DEFAULT_ABSMARTLY_ENDPOINT); + + if (!verifyResult.ok) { + const isTransient = verifyResult.error === 'server_error' || verifyResult.error === 'network_error'; + console.error(`Failed to verify API key: ${verifyResult.error}`); + return new Response(isTransient ? "ABsmartly service temporarily unavailable" : "Unauthorized", { + status: isTransient ? 503 : 401, + headers: CORS_HEADERS, + }); + } + + const userData = verifyResult.user; + const userId = userData.id?.toString() || userData.email; + + if (!userData.email) { + debug('No email found in API response for API key authentication, user data:', userData); + } + + const props: ABsmartlyProps = { + email: userData.email || DEFAULT_API_KEY_USER_EMAIL, + name: `${userData.first_name || ''} ${userData.last_name || ''}`.trim() || userData.email || DEFAULT_API_KEY_USER_NAME, + absmartly_endpoint: endpoint || DEFAULT_ABSMARTLY_ENDPOINT, + absmartly_api_key: apiKey, + user_id: userId + }; + + debug(`API key authenticated for user: ${props.email}`); + + const session = { + userId, + email: props.email, + name: props.name, + absmartly_endpoint: props.absmartly_endpoint, + absmartly_api_key: apiKey, + createdAt: Date.now(), + expiresAt: Date.now() + (SESSION_TTL_SECONDS * 1000) + }; + + await safeKvPut(env.OAUTH_KV, `session:${userId}`, JSON.stringify(session), { + expirationTtl: SESSION_TTL_SECONDS, + }); + + ctx.props = props; + return await route.handler.fetch(request, env, ctx); + } catch (error) { + console.error("Error during API key authentication:", error); + return new Response("Internal Server Error", { + status: 500, + headers: CORS_HEADERS, + }); + } + } + + const authHeader = request.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + debug(`No valid Authorization header on ${route.pathPrefix}, returning 401 to trigger OAuth flow`); + + const requestedEndpoint = url.searchParams.get('absmartly-endpoint') || + request.headers.get('x-absmartly-endpoint') || + extractEndpointFromPath(url.pathname, route.pathPrefix) || + endpoint; + if (requestedEndpoint) { + await safeKvPut(env.OAUTH_KV, `oauth_endpoint_pending:${clientFingerprint}`, requestedEndpoint, { + expirationTtl: OAUTH_STATE_TTL_SECONDS, + }); + } + + return new Response("Unauthorized", { + status: 401, + headers: { + ...CORS_HEADERS, + "WWW-Authenticate": 'Bearer realm="OAuth"', + }, + }); + } + + return await oauthProvider.fetch(request, env, ctx); +} + export default { async fetch(request: Request, env: any, ctx: any): Promise { const url = new URL(request.url); @@ -660,89 +773,21 @@ export default { } if (url.pathname.startsWith("/sse")) { - if (apiKey) { - debug("API key detected, bypassing OAuth flow"); - - try { - const verifyResult = await verifyApiKey(apiKey, endpoint || DEFAULT_ABSMARTLY_ENDPOINT); - - if (!verifyResult.ok) { - const isTransient = verifyResult.error === 'server_error' || verifyResult.error === 'network_error'; - console.error(`Failed to verify API key: ${verifyResult.error}`); - return new Response(isTransient ? "ABsmartly service temporarily unavailable" : "Unauthorized", { - status: isTransient ? 503 : 401, - headers: CORS_HEADERS, - }); - } - - const userData = verifyResult.user; - const userId = userData.id?.toString() || userData.email; - - if (!userData.email) { - debug('No email found in API response for API key authentication, user data:', userData); - } - - const props: ABsmartlyProps = { - email: userData.email || DEFAULT_API_KEY_USER_EMAIL, - name: `${userData.first_name || ''} ${userData.last_name || ''}`.trim() || userData.email || DEFAULT_API_KEY_USER_NAME, - absmartly_endpoint: endpoint || DEFAULT_ABSMARTLY_ENDPOINT, - absmartly_api_key: apiKey, - user_id: userId - }; - - debug(`API key authenticated for user: ${props.email}`); - - const session = { - userId: userId, - email: props.email, - name: props.name, - absmartly_endpoint: props.absmartly_endpoint, - absmartly_api_key: apiKey, - createdAt: Date.now(), - expiresAt: Date.now() + (SESSION_TTL_SECONDS * 1000) - }; - - await safeKvPut(env.OAUTH_KV, `session:${userId}`, JSON.stringify(session), { - expirationTtl: SESSION_TTL_SECONDS, - }); - - ctx.props = props; - return await baseMcpHandler.fetch(request, env, ctx); - - } catch (error) { - console.error("Error during API key authentication:", error); - return new Response("Internal Server Error", { - status: 500, - headers: CORS_HEADERS, - }); - } - } - - const authHeader = request.headers.get("Authorization"); - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - debug("No valid Authorization header, returning 401 to trigger OAuth flow"); - - const requestedEndpoint = url.searchParams.get('absmartly-endpoint') || - request.headers.get('x-absmartly-endpoint') || - extractEndpointFromPath(url.pathname, '/sse') || - endpoint; - if (requestedEndpoint) { - await safeKvPut(env.OAUTH_KV, `oauth_endpoint_pending:${clientFingerprint}`, requestedEndpoint, { - expirationTtl: OAUTH_STATE_TTL_SECONDS, - }); - } - - return new Response("Unauthorized", { - status: 401, - headers: { - ...CORS_HEADERS, - "WWW-Authenticate": 'Bearer realm="OAuth"', - }, - }); - } + return await handleMcpTransportRequest( + request, env, ctx, + { pathPrefix: "/sse", handler: sseMcpHandler }, + { apiKey, endpoint }, + clientFingerprint + ); + } - return await oauthProvider.fetch(request, env, ctx); + if (url.pathname.startsWith("/mcp")) { + return await handleMcpTransportRequest( + request, env, ctx, + { pathPrefix: "/mcp", handler: streamableMcpHandler }, + { apiKey, endpoint }, + clientFingerprint + ); } if (url.pathname === '/register' && request.method === 'POST') { diff --git a/src/shared.ts b/src/shared.ts index fb3efb2..a012548 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -38,12 +38,16 @@ export function buildAuthHeader(authToken: string, isApiKey: boolean): Record, keys: string[]): Record { @@ -99,7 +103,7 @@ export function detectApiKey( const url = new URL(request.url); const authHeader = request.headers.get("Authorization"); - const endpointFromPath = extractEndpointFromPath(url.pathname, '/sse'); + const endpointFromPath = extractEndpointFromPath(url.pathname, ['/sse', '/mcp']); const apiKeyFromQuery = url.searchParams.get("api_key") || url.searchParams.get("apikey"); if (apiKeyFromQuery) { diff --git a/tests/unit/shared.test.ts b/tests/unit/shared.test.ts index 885ada3..f7282dc 100644 --- a/tests/unit/shared.test.ts +++ b/tests/unit/shared.test.ts @@ -90,6 +90,30 @@ export default async function run() { test('extractEndpointFromPath keeps dotted hostname as-is', () => { assert.strictEqual(extractEndpointFromPath('/sse/custom.example.com', '/sse'), 'https://custom.example.com'); }); + test('extractEndpointFromPath returns null for /mcp without trailing content', () => { + assert.strictEqual(extractEndpointFromPath('/mcp/', '/mcp'), null); + }); + test('extractEndpointFromPath appends domain for /mcp shortname', () => { + assert.strictEqual(extractEndpointFromPath('/mcp/dev1', '/mcp'), `https://dev1.${DEFAULT_ABSMARTLY_DOMAIN}`); + }); + test('extractEndpointFromPath keeps dotted /mcp hostname as-is', () => { + assert.strictEqual(extractEndpointFromPath('/mcp/custom.example.com', '/mcp'), 'https://custom.example.com'); + }); + test('extractEndpointFromPath accepts an array of prefixes and matches /sse', () => { + assert.strictEqual( + extractEndpointFromPath('/sse/dev1', ['/sse', '/mcp']), + `https://dev1.${DEFAULT_ABSMARTLY_DOMAIN}` + ); + }); + test('extractEndpointFromPath accepts an array of prefixes and matches /mcp', () => { + assert.strictEqual( + extractEndpointFromPath('/mcp/dev1', ['/sse', '/mcp']), + `https://dev1.${DEFAULT_ABSMARTLY_DOMAIN}` + ); + }); + test('extractEndpointFromPath returns null when none of the prefixes match', () => { + assert.strictEqual(extractEndpointFromPath('/other/dev1', ['/sse', '/mcp']), null); + }); test('escapeHtml escapes all special chars', () => { assert.strictEqual(escapeHtml('&<>"\''), '&<>"''); @@ -170,6 +194,30 @@ export default async function run() { assert.strictEqual(result.endpoint, DEFAULT_ABSMARTLY_ENDPOINT); }); + test('detectApiKey extracts endpoint from /mcp path with Authorization header', () => { + const request = new Request('https://mcp.absmartly.com/mcp/demo-1', { + headers: { 'Authorization': 'BxYKd1U2_abc123' } + }); + const result = detectApiKey(request); + assert.strictEqual(result.apiKey, 'BxYKd1U2_abc123'); + assert.strictEqual(result.endpoint, `https://demo-1.${DEFAULT_ABSMARTLY_DOMAIN}`); + }); + + test('detectApiKey extracts endpoint from /mcp path with api_key query param', () => { + const request = new Request('https://mcp.absmartly.com/mcp/demo-1?api_key=BxYKd1U2_abc123'); + const result = detectApiKey(request); + assert.strictEqual(result.apiKey, 'BxYKd1U2_abc123'); + assert.strictEqual(result.endpoint, `https://demo-1.${DEFAULT_ABSMARTLY_DOMAIN}`); + }); + + test('detectApiKey still extracts endpoint from /sse path (regression guard)', () => { + const request = new Request('https://mcp.absmartly.com/sse/demo-1', { + headers: { 'Authorization': 'BxYKd1U2_abc123' } + }); + const result = detectApiKey(request); + assert.strictEqual(result.endpoint, `https://demo-1.${DEFAULT_ABSMARTLY_DOMAIN}`); + }); + await asyncTest('safeKvPut does nothing when kv is undefined', async () => { await safeKvPut(undefined, 'key', 'value'); }); diff --git a/tests/unit/streamable-routing.test.ts b/tests/unit/streamable-routing.test.ts new file mode 100644 index 0000000..e301f7f --- /dev/null +++ b/tests/unit/streamable-routing.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import { extractEndpointFromPath, detectApiKey, DEFAULT_ABSMARTLY_DOMAIN } from '../../src/shared'; + +export default async function run() { + let passed = 0; + let failed = 0; + const details: Array<{ name: string; status: string; error?: string }> = []; + + function test(name: string, fn: () => void) { + try { fn(); passed++; details.push({ name, status: 'PASS' }); } + catch (e: any) { failed++; details.push({ name, status: 'FAIL', error: e.message }); } + } + + // describe: /mcp transport request shape + test('detectApiKey falls back to default endpoint when /mcp path has no subdomain', () => { + const request = new Request('https://mcp.absmartly.com/mcp', { + headers: { 'Authorization': 'BxYKd1U2_abc123' } + }); + const result = detectApiKey(request); + assert.strictEqual(result.apiKey, 'BxYKd1U2_abc123'); + assert.strictEqual(result.endpoint, 'https://sandbox.absmartly.com'); + }); + + test('detectApiKey prefers explicit x-absmartly-endpoint over /mcp path', () => { + const request = new Request('https://mcp.absmartly.com/mcp/demo-1', { + headers: { + 'Authorization': 'BxYKd1U2_abc123', + 'x-absmartly-endpoint': 'https://override.absmartly.com' + } + }); + const result = detectApiKey(request); + assert.strictEqual(result.endpoint, 'https://override.absmartly.com'); + }); + + test('extractEndpointFromPath under /mcp matches the same shape as /sse', () => { + assert.strictEqual( + extractEndpointFromPath('/mcp/demo-1', ['/sse', '/mcp']), + `https://demo-1.${DEFAULT_ABSMARTLY_DOMAIN}` + ); + }); + + return { + success: failed === 0, + message: `${passed}/${passed + failed} tests passed`, + testCount: passed + failed, + details, + }; +}