mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-15 00:49:53 +00:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
106
apps/server/src/lib/mcp-transport-detect.ts
Normal file
106
apps/server/src/lib/mcp-transport-detect.ts
Normal 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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user