-
Notifications
You must be signed in to change notification settings - Fork 0
feat(transport): add Streamable HTTP /mcp endpoint (FT-1935) #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
59c99fe
1d9fc7a
6f0041f
85ead87
b20e107
de091ca
3c3c8bf
fcbc971
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ | |
| import { Env } from "./types"; | ||
| import { debug } from "./config"; | ||
| import { MCP_VERSION } from "./version"; | ||
| import { DXT_BUNDLE_BASE64, DXT_BUNDLE_SHA } from "./dxt-bundle"; | ||
|
Check failure on line 11 in src/index.ts
|
||
| import { APIClient } from "@absmartly/cli/api-client"; | ||
| import type { CustomSectionField } from "@absmartly/cli/api-client"; | ||
| import { FetchHttpClient } from "./fetch-adapter"; | ||
|
|
@@ -32,6 +32,12 @@ | |
| safeKvGet, | ||
| } from "./shared"; | ||
|
|
||
| const MCP_CORS_OPTIONS = { | ||
| origin: "*", | ||
| methods: "GET, POST, OPTIONS", | ||
| headers: "Content-Type, Authorization, Accept", | ||
| } as const; | ||
|
|
||
| export class ABsmartlyMCP extends McpAgent<Env, Record<string, never>, ABsmartlyProps> { | ||
| server = new McpServer( | ||
| { | ||
|
|
@@ -535,13 +541,20 @@ | |
| } | ||
| } | ||
|
|
||
| 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 @@ | |
| }, | ||
| } as any); | ||
|
|
||
| type McpTransportRoute = { | ||
| pathPrefix: string; | ||
| handler: { fetch: (request: Request, env: any, ctx: any) => Promise<Response> }; | ||
| }; | ||
|
|
||
| async function handleMcpTransportRequest( | ||
| request: Request, | ||
| env: any, | ||
| ctx: any, | ||
| route: McpTransportRoute, | ||
| detected: { apiKey: string | null; endpoint: string | null }, | ||
| clientFingerprint: string | ||
| ): Promise<Response> { | ||
| 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<Response> { | ||
| const url = new URL(request.url); | ||
|
|
@@ -660,89 +773,21 @@ | |
| } | ||
|
|
||
| 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 }, | ||
|
Comment on lines
+778
to
+788
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Replace inline transport path literals with top-level constants (Line 778 and Line 787).
As per coding guidelines, "Never use magic strings or hardcoded values inline in code - all default values must be declared as constants at the top of the file". 🤖 Prompt for AI Agents |
||
| clientFingerprint | ||
| ); | ||
| } | ||
|
|
||
| if (url.pathname === '/register' && request.method === 'POST') { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,12 +38,16 @@ export function buildAuthHeader(authToken: string, isApiKey: boolean): Record<st | |
| }; | ||
| } | ||
|
|
||
| export function extractEndpointFromPath(pathname: string, prefix: string): string | null { | ||
| if (!pathname.startsWith(prefix + '/')) return null; | ||
| const hostPart = pathname.slice(prefix.length + 1).replace(/\/+$/, ''); | ||
| if (!hostPart) return null; | ||
| const host = hostPart.includes('.') ? hostPart : `${hostPart}.${DEFAULT_ABSMARTLY_DOMAIN}`; | ||
| return `https://${host}`; | ||
| export function extractEndpointFromPath(pathname: string, prefix: string | readonly string[]): string | null { | ||
| const prefixes = Array.isArray(prefix) ? prefix : [prefix]; | ||
| for (const p of prefixes) { | ||
| if (!pathname.startsWith(p + '/')) continue; | ||
| const hostPart = pathname.slice(p.length + 1).replace(/\/+$/, ''); | ||
| if (!hostPart) continue; | ||
| const host = hostPart.includes('.') ? hostPart : `${hostPart}.${DEFAULT_ABSMARTLY_DOMAIN}`; | ||
| return `https://${host}`; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| export function pickDefined(source: Record<string, unknown>, keys: string[]): Record<string, unknown> { | ||
|
|
@@ -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']); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Extract transport prefixes into a top-level constant (Line 106). Using As per coding guidelines, "Never use magic strings or hardcoded values inline in code - all default values must be declared as constants at the top of the file". 🤖 Prompt for AI Agents |
||
|
|
||
| const apiKeyFromQuery = url.searchParams.get("api_key") || url.searchParams.get("apikey"); | ||
| if (apiKeyFromQuery) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix markdownlint MD028: remove the blank line inside the blockquote.
There is an empty line between two quoted lines, which triggers
no-blanks-blockquote. Keep both quoted lines contiguous (or make the second one a normal paragraph).Suggested fix
> 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.📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 447-447: Blank line inside blockquote
(MD028, no-blanks-blockquote)
🤖 Prompt for AI Agents