feat: add MCP transport auto-detection for custom servers (#307)

Automatically detect whether custom MCP servers use Streamable HTTP or
SSE transport by probing with a POST request before creating the config.

- Add detectMcpTransport() utility that probes the server endpoint
- If POST returns 200 with JSON/event-stream, use Streamable HTTP
- If POST returns 404/405 or fails, fall back to SSE transport
- Cache detection results per URL with 1-hour TTL
- Skip caching for transient errors (5xx, network failures)

Known servers (browseros-mcp, klavis-strata) skip detection and use
Streamable HTTP directly.
This commit is contained in:
shivammittal274
2026-02-06 23:24:27 +05:30
committed by GitHub
parent e35cb6cd16
commit 23abfdf6f4
3 changed files with 134 additions and 14 deletions

View File

@@ -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<string, string>
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<string, MCPServerConfig> = {}
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,
})
}
}

View File

@@ -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<string, CachedTransport>()
export async function detectMcpTransport(
url: string,
): Promise<McpTransportType> {
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()
}

View File

@@ -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,