diff --git a/apps/server/src/api/services/chat-service.ts b/apps/server/src/api/services/chat-service.ts index aefe92d73..9cd2ddcb5 100644 --- a/apps/server/src/api/services/chat-service.ts +++ b/apps/server/src/api/services/chat-service.ts @@ -18,24 +18,27 @@ import { } from '../../lib/clients/gateway' import type { KlavisClient } from '../../lib/clients/klavis/klavis-client' import { logger } from '../../lib/logger' +import { + detectMcpTransport, + type McpTransportType, +} from '../../lib/mcp-transport-detect' import type { BrowserContext, ChatRequest } from '../types' -interface McpHttpServerOptions { - httpUrl: string +interface McpServerOptions { + url: string + transport: McpTransportType headers?: Record trust?: boolean } -function createHttpMcpServerConfig( - options: McpHttpServerOptions, -): MCPServerConfig { +function createMcpServerConfig(options: McpServerOptions): MCPServerConfig { return new MCPServerConfig( undefined, undefined, undefined, undefined, - undefined, - options.httpUrl, + options.transport === 'sse' ? options.url : undefined, + options.transport === 'streamable-http' ? options.url : undefined, options.headers, undefined, undefined, @@ -163,8 +166,9 @@ export class ChatService { const servers: Record = {} if (mcpServerUrl) { - servers['browseros-mcp'] = createHttpMcpServerConfig({ - httpUrl: mcpServerUrl, + servers['browseros-mcp'] = createMcpServerConfig({ + url: mcpServerUrl, + transport: 'streamable-http', headers: { Accept: 'application/json, text/event-stream', 'X-BrowserOS-Source': 'gemini-agent', @@ -182,8 +186,9 @@ export class ChatService { browserosId, browserContext.enabledMcpServers, ) - servers['klavis-strata'] = createHttpMcpServerConfig({ - httpUrl: result.strataServerUrl, + servers['klavis-strata'] = createMcpServerConfig({ + url: result.strataServerUrl, + transport: 'streamable-http', trust: true, }) logger.info('Added Klavis Strata MCP server', { @@ -200,14 +205,22 @@ export class ChatService { } if (browserContext?.customMcpServers?.length) { - for (const server of browserContext.customMcpServers) { - servers[`custom-${server.name}`] = createHttpMcpServerConfig({ - httpUrl: server.url, + const customServers = browserContext.customMcpServers + const transports = await Promise.all( + customServers.map((server) => detectMcpTransport(server.url)), + ) + for (let i = 0; i < customServers.length; i++) { + const server = customServers[i] + const transport = transports[i] + servers[`custom-${server.name}`] = createMcpServerConfig({ + url: server.url, + transport, trust: true, }) logger.info('Added custom MCP server', { name: server.name, url: server.url, + transport, }) } } diff --git a/apps/server/src/lib/mcp-transport-detect.ts b/apps/server/src/lib/mcp-transport-detect.ts new file mode 100644 index 000000000..ecef805a2 --- /dev/null +++ b/apps/server/src/lib/mcp-transport-detect.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Detects MCP transport type by probing the server endpoint. + * + * Following MCP spec (2025-03-26): try Streamable HTTP first, fall back to SSE. + * SSE transport is deprecated but still supported for older servers. + * + * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports + */ + +import { TIMEOUTS } from '@browseros/shared/constants/timeouts' +import { logger } from './logger' + +export type McpTransportType = 'streamable-http' | 'sse' + +const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour + +interface CachedTransport { + transport: McpTransportType + expiresAt: number +} + +const transportCache = new Map() + +export async function detectMcpTransport( + url: string, +): Promise { + const cached = transportCache.get(url) + if (cached && Date.now() < cached.expiresAt) { + return cached.transport + } + + let transport: McpTransportType + let shouldCache = true + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'browseros-probe', version: '1.0.0' }, + }, + }), + signal: AbortSignal.timeout(TIMEOUTS.MCP_TRANSPORT_PROBE), + }) + + const contentType = response.headers.get('content-type') ?? '' + const isJsonResponse = contentType.includes('application/json') + const isStreamResponse = contentType.includes('text/event-stream') + + // Release the connection — avoid leaking open streams (especially SSE) + await response.body?.cancel() + + logger.debug('MCP transport probe response', { + url, + status: response.status, + contentType, + }) + + if (response.status === 200 && (isJsonResponse || isStreamResponse)) { + transport = 'streamable-http' + } else if (response.status === 401) { + transport = 'streamable-http' + } else if (response.status >= 500) { + transport = 'sse' + shouldCache = false + } else { + transport = 'sse' + } + } catch (error) { + logger.debug('MCP transport probe failed', { + url, + error: error instanceof Error ? error.message : String(error), + }) + transport = 'sse' + shouldCache = false + } + + if (shouldCache) { + transportCache.set(url, { + transport, + expiresAt: Date.now() + CACHE_TTL_MS, + }) + } + logger.debug('Detected MCP transport', { + url, + transport, + cached: shouldCache, + }) + return transport +} + +export function clearTransportCache(): void { + transportCache.clear() +} diff --git a/packages/shared/src/constants/timeouts.ts b/packages/shared/src/constants/timeouts.ts index 405e6ff42..69fa07f76 100644 --- a/packages/shared/src/constants/timeouts.ts +++ b/packages/shared/src/constants/timeouts.ts @@ -17,6 +17,7 @@ export const TIMEOUTS = { // MCP operations MCP_DEFAULT: 5_000, + MCP_TRANSPORT_PROBE: 5_000, // External API calls KLAVIS_FETCH: 30_000,