Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion apps/server/src/api/routes/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { StreamableHTTPTransport } from '@hono/mcp'
import { Hono } from 'hono'
import type { Browser } from '../../browser/browser'
import { KlavisClient } from '../../lib/clients/klavis/klavis-client'
import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
import { Sentry } from '../../lib/sentry'
Expand All @@ -18,16 +19,34 @@ interface McpRouteDeps {
version: string
registry: ToolRegistry
browser: Browser
browserosId?: string
}

export function createMcpRoutes(deps: McpRouteDeps) {
const mcpServer = createMcpServer(deps)
const enabledMcpServers = process.env.BROWSEROS_DEFAULT_MCP_SERVERS?.split(
',',
)
.map((s) => s.trim())
.filter(Boolean)

const klavisClient =
deps.browserosId && enabledMcpServers?.length
? new KlavisClient()
: undefined

const mcpServerPromise = createMcpServer({
...deps,
klavisClient,
enabledMcpServers,
})

return new Hono<Env>().all('/', async (c) => {
const scopeId = c.req.header('X-BrowserOS-Scope-Id') || 'ephemeral'
metrics.log('mcp.request', { scopeId })

try {
const mcpServer = await mcpServerPromise

const transport = new StreamableHTTPTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export async function createHttpServer(config: HttpServerConfig) {
version,
registry,
browser,
browserosId,
}),
)
.route(
Expand Down
85 changes: 85 additions & 0 deletions apps/server/src/api/services/mcp/klavis-mcp-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
import { logger } from '../../../lib/logger'

const CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes

interface CachedEntry {
client: Client
transport: StreamableHTTPClientTransport
strataServerUrl: string
expiresAt: number
}

export class KlavisMcpClientCache {
private cache = new Map<string, CachedEntry>()

async getOrCreate(
browserosId: string,
servers: string[],
klavisClient: KlavisClient,
): Promise<Client> {
const key = browserosId
const cached = this.cache.get(key)

if (cached && Date.now() < cached.expiresAt) {
return cached.client
}

if (cached) {
await this.closeEntry(cached)
this.cache.delete(key)
}

const result = await klavisClient.createStrata(browserosId, servers)

const client = new Client({
name: 'browseros-klavis-proxy',
version: '1.0.0',
})

const transport = new StreamableHTTPClientTransport(
new URL(result.strataServerUrl),
)

await client.connect(transport)

this.cache.set(key, {
client,
transport,
strataServerUrl: result.strataServerUrl,
expiresAt: Date.now() + CACHE_TTL_MS,
})

logger.info('Created Klavis MCP client connection', {
browserosId: browserosId.slice(0, 12),
strataUrl: result.strataServerUrl,
})

return client
}

async invalidate(browserosId: string): Promise<void> {
const entry = this.cache.get(browserosId)
if (entry) {
await this.closeEntry(entry)
this.cache.delete(browserosId)
}
}

async closeAll(): Promise<void> {
for (const [key, entry] of this.cache) {
await this.closeEntry(entry)
this.cache.delete(key)
}
}

private async closeEntry(entry: CachedEntry): Promise<void> {
try {
await entry.transport.close()
} catch {
// Connection may already be closed
}
}
}
32 changes: 31 additions & 1 deletion apps/server/src/api/services/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,27 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Browser } from '../../../browser/browser'
import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
import { logger } from '../../../lib/logger'
import type { ToolRegistry } from '../../../tools/tool-registry'
import { KlavisMcpClientCache } from './klavis-mcp-cache'
import { registerKlavisTools } from './register-klavis'
import { registerTools } from './register-mcp'

const klavisMcpClientCache = new KlavisMcpClientCache()

export interface McpServiceDeps {
version: string
registry: ToolRegistry
browser: Browser
klavisClient?: KlavisClient
browserosId?: string
enabledMcpServers?: string[]
}

export function createMcpServer(deps: McpServiceDeps): McpServer {
export async function createMcpServer(
deps: McpServiceDeps,
): Promise<McpServer> {
const server = new McpServer(
{
name: 'browseros_mcp',
Expand All @@ -32,5 +43,24 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {

registerTools(server, deps.registry, { browser: deps.browser })

if (deps.klavisClient && deps.browserosId && deps.enabledMcpServers?.length) {
try {
await registerKlavisTools(server, {
klavisClient: deps.klavisClient,
browserosId: deps.browserosId,
enabledServers: deps.enabledMcpServers,
registry: deps.registry,
cache: klavisMcpClientCache,
})
} catch (error) {
logger.error(
'Klavis tool registration failed, browser tools unaffected',
{
error: error instanceof Error ? error.message : String(error),
},
)
}
}

return server
}
145 changes: 145 additions & 0 deletions apps/server/src/api/services/mcp/register-klavis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import type { ToolRegistry } from '../../../tools/tool-registry'
import type { KlavisMcpClientCache } from './klavis-mcp-cache'

export interface KlavisProxyConfig {
klavisClient: KlavisClient
browserosId: string
enabledServers: string[]
registry: ToolRegistry
cache: KlavisMcpClientCache
}

export async function registerKlavisTools(
mcpServer: McpServer,
config: KlavisProxyConfig,
): Promise<void> {
const { klavisClient, browserosId, enabledServers, registry, cache } = config

try {
const client = await cache.getOrCreate(
browserosId,
enabledServers,
klavisClient,
)

const result = await client.listTools()
const browserToolNames = new Set(registry.names())
let registeredCount = 0

for (const tool of result.tools) {
if (browserToolNames.has(tool.name)) {
logger.warn('Klavis tool name collides with browser tool, skipping', {
toolName: tool.name,
})
continue
}

const toolName = tool.name

const handler = async (
args: Record<string, unknown>,
): Promise<CallToolResult> => {
const startTime = performance.now()

try {
logger.info(`Klavis proxy ${toolName} request`, {
args: JSON.stringify(args).slice(0, 200),
})

let activeClient: typeof client
try {
activeClient = await cache.getOrCreate(
browserosId,
enabledServers,
klavisClient,
)
} catch (reconnectError) {
const errorText =
reconnectError instanceof Error
? reconnectError.message
: String(reconnectError)
logger.error('Failed to reconnect Klavis MCP client', {
toolName,
error: errorText,
})
return {
content: [
{
type: 'text' as const,
text: `Klavis connection error: ${errorText}`,
},
],
isError: true,
}
}

const callResult = await activeClient.callTool({
name: toolName,
arguments: args,
})

const isError = callResult.isError === true

metrics.log('tool_executed', {
tool_name: toolName,
duration_ms: Math.round(performance.now() - startTime),
success: !isError,
source: 'klavis',
})

return {
content: callResult.content as CallToolResult['content'],
isError,
}
} catch (error) {
const errorText =
error instanceof Error ? error.message : String(error)

metrics.log('tool_executed', {
tool_name: toolName,
duration_ms: Math.round(performance.now() - startTime),
success: false,
error_message: errorText,
source: 'klavis',
})

await cache.invalidate(browserosId).catch(() => {})

return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,
}
}
}

mcpServer.registerTool(
tool.name,
{
description: tool.description,
inputSchema: tool.inputSchema as unknown as Record<string, never>,
},
handler,
)
registeredCount++
}

logger.info(
`Registered ${registeredCount} Klavis proxy tools from Strata`,
{
browserosId: browserosId.slice(0, 12),
totalAvailable: result.tools.length,
skipped: result.tools.length - registeredCount,
},
)
} catch (error) {
logger.error('Failed to register Klavis tools, browser tools unaffected', {
error: error instanceof Error ? error.message : String(error),
browserosId: browserosId.slice(0, 12),
})
}
}
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading