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:
shivammittal274
2026-03-06 00:04:57 +05:30
committed by GitHub
parent a91bef1cd1
commit fb58a6ee1c
6 changed files with 196 additions and 3 deletions

View File

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

View File

@@ -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) {

View File

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

View File

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

View 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,
})
}

View File

@@ -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=="],