mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-17 02:25:57 +00:00
feat: expose Klavis MCP tools to external MCP clients (#411)
* feat: expose Klavis MCP tools to external MCP clients Connect to Klavis Strata at server startup and register discovered tools on each per-request McpServer instance. This lets external MCP clients (Claude Code, Gemini CLI) access Klavis-proxied integrations (Gmail, Slack, GitHub, etc.) alongside browser tools. - Add register-klavis-mcp.ts with connectKlavisProxy() and registerKlavisTools() - Wire KlavisProxyHandle through server.ts -> mcp routes -> mcp-server - Use structured logging and proper type imports * fix: forward Klavis tool schemas and add shutdown cleanup - Use zod-from-json-schema to convert Strata's JSON Schema to Zod, so MCP clients see proper parameter names, types, and required fields - Close Klavis proxy transport on server shutdown - Move per-request Klavis tool registration logging to debug level - Use proper type imports instead of inline import() types - Fix connectKlavisProxy return type (never returns null) * fix: add timeout to Klavis MCP connect/listTools and log shutdown errors * fix: clear timeout timer and pre-compute Klavis tool schemas at startup * fix: use client.close() instead of transport.close() for proper cleanup
This commit is contained in:
@@ -71,7 +71,8 @@
|
||||
"posthog-node": "^4.17.0",
|
||||
"puppeteer-core": "24.23.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.2",
|
||||
"zod-from-json-schema": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.5",
|
||||
|
||||
@@ -12,12 +12,14 @@ import { metrics } from '../../lib/metrics'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import { createMcpServer } from '../services/mcp/mcp-server'
|
||||
import type { KlavisProxyHandle } from '../services/mcp/register-klavis-mcp'
|
||||
import type { Env } from '../types'
|
||||
|
||||
interface McpRouteDeps {
|
||||
version: string
|
||||
registry: ToolRegistry
|
||||
browser: Browser
|
||||
klavisProxy?: KlavisProxyHandle | null
|
||||
}
|
||||
|
||||
export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
import { createChatV2Routes } from './routes/chat-v2'
|
||||
import { createGraphRoutes } from './routes/graph'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
@@ -26,6 +26,10 @@ import { createSdkRoutes } from './routes/sdk'
|
||||
import { createShutdownRoute } from './routes/shutdown'
|
||||
import { createSoulRoutes } from './routes/soul'
|
||||
import { createStatusRoute } from './routes/status'
|
||||
import {
|
||||
connectKlavisProxy,
|
||||
type KlavisProxyHandle,
|
||||
} from './services/mcp/register-klavis-mcp'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
|
||||
@@ -67,12 +71,39 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
const { onShutdown } = config
|
||||
|
||||
// Connect Klavis proxy (non-blocking: browser tools still work if this fails)
|
||||
let klavisProxy: KlavisProxyHandle | null = null
|
||||
if (browserosId) {
|
||||
try {
|
||||
klavisProxy = await connectKlavisProxy({
|
||||
klavisClient: new KlavisClient(),
|
||||
browserosId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'Failed to connect Klavis proxy, MCP will serve browser tools only',
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
.route(
|
||||
'/shutdown',
|
||||
createShutdownRoute({ onShutdown: onShutdown ?? (() => {}) }),
|
||||
createShutdownRoute({
|
||||
onShutdown: () => {
|
||||
klavisProxy?.close().catch((err) =>
|
||||
logger.warn('Failed to close Klavis proxy transport', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
onShutdown?.()
|
||||
},
|
||||
}),
|
||||
)
|
||||
.route('/status', createStatusRoute({ controller }))
|
||||
.route('/soul', createSoulRoutes())
|
||||
@@ -84,6 +115,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
version,
|
||||
registry,
|
||||
browser,
|
||||
klavisProxy,
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
|
||||
@@ -8,12 +8,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Browser } from '../../../browser/browser'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
import {
|
||||
type KlavisProxyHandle,
|
||||
registerKlavisTools,
|
||||
} from './register-klavis-mcp'
|
||||
import { registerTools } from './register-mcp'
|
||||
|
||||
export interface McpServiceDeps {
|
||||
version: string
|
||||
registry: ToolRegistry
|
||||
browser: Browser
|
||||
klavisProxy?: KlavisProxyHandle | null
|
||||
}
|
||||
|
||||
export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
@@ -30,7 +35,13 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
return {}
|
||||
})
|
||||
|
||||
// Register browser tools
|
||||
registerTools(server, deps.registry, { browser: deps.browser })
|
||||
|
||||
// Register Klavis proxy tools (if connected)
|
||||
if (deps.klavisProxy) {
|
||||
registerKlavisTools(server, deps.klavisProxy)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
144
apps/server/src/api/services/mcp/register-klavis-mcp.ts
Normal file
144
apps/server/src/api/services/mcp/register-klavis-mcp.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { jsonSchemaObjectToZodRawShape } from 'zod-from-json-schema'
|
||||
import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timerId = setTimeout(
|
||||
() => reject(new Error(`Klavis ${label} timed out`)),
|
||||
TIMEOUTS.KLAVIS_FETCH,
|
||||
)
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timerId))
|
||||
}
|
||||
|
||||
export interface KlavisProxyHandle {
|
||||
tools: Tool[]
|
||||
inputSchemas: Map<string, Record<string, never>>
|
||||
callTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<CallToolResult>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
interface ConnectDeps {
|
||||
klavisClient: KlavisClient
|
||||
browserosId: string
|
||||
}
|
||||
|
||||
// One-time async setup: connect to Klavis Strata and discover tools
|
||||
export async function connectKlavisProxy(
|
||||
deps: ConnectDeps,
|
||||
): Promise<KlavisProxyHandle> {
|
||||
// Use the full curated OAuth server list so all tools are exposed,
|
||||
// even unauthenticated ones (Klavis handles auth prompts on call)
|
||||
const allServers = OAUTH_MCP_SERVERS.map((s) => s.name)
|
||||
|
||||
const strata = await deps.klavisClient.createStrata(
|
||||
deps.browserosId,
|
||||
allServers,
|
||||
)
|
||||
|
||||
// Connect MCP client to Strata endpoint
|
||||
const client = new Client({
|
||||
name: 'browseros-klavis-proxy',
|
||||
version: '1.0.0',
|
||||
})
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(strata.strataServerUrl),
|
||||
)
|
||||
await withTimeout(client.connect(transport), 'connect')
|
||||
|
||||
const { tools } = await withTimeout(client.listTools(), 'listTools')
|
||||
|
||||
// Pre-compute Zod schemas once so registerKlavisTools avoids per-request conversion.
|
||||
// Double cast works around TS2589 in registerTool's recursive generics.
|
||||
const inputSchemas = new Map(
|
||||
tools.map((t) => [
|
||||
t.name,
|
||||
jsonSchemaObjectToZodRawShape(
|
||||
t.inputSchema as never,
|
||||
) as unknown as Record<string, never>,
|
||||
]),
|
||||
)
|
||||
|
||||
logger.info('Klavis proxy connected', {
|
||||
toolCount: tools.length,
|
||||
serverCount: allServers.length,
|
||||
})
|
||||
|
||||
return {
|
||||
tools,
|
||||
inputSchemas,
|
||||
callTool: (name, args) =>
|
||||
client.callTool({ name, arguments: args }) as Promise<CallToolResult>,
|
||||
close: () => client.close(),
|
||||
}
|
||||
}
|
||||
|
||||
export function registerKlavisTools(
|
||||
mcpServer: McpServer,
|
||||
handle: KlavisProxyHandle,
|
||||
): void {
|
||||
for (const tool of handle.tools) {
|
||||
const inputSchema = handle.inputSchemas.get(tool.name)
|
||||
|
||||
mcpServer.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema,
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
const result = await handle.callTool(tool.name, args)
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: tool.name,
|
||||
source: 'klavis',
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: !result.isError,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorText =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: tool.name,
|
||||
source: 'klavis',
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: false,
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug('Registered Klavis tools on MCP server', {
|
||||
count: handle.tools.length,
|
||||
})
|
||||
}
|
||||
3
bun.lock
3
bun.lock
@@ -179,6 +179,7 @@
|
||||
"puppeteer-core": "24.23.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-from-json-schema": "^0.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.5",
|
||||
@@ -3867,6 +3868,8 @@
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-from-json-schema": ["zod-from-json-schema@0.1.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-yPcDmFziXePHO8iK4WXl4WcqlKolohQKEdhbJadCzZ/+/ayr1IELxWrVQ/NSUKiuIweNiDlZoi6dgle4Bd1baw=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
Reference in New Issue
Block a user