From 5e911bf81509cae3ecfb4f8b97079571838a296f Mon Sep 17 00:00:00 2001 From: BrowserOS Coding Agent Date: Thu, 12 Feb 2026 12:43:35 +0000 Subject: [PATCH 1/2] chore: baseline setup --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 13dcca1b..111787bd 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,6 @@ log.txt # Testing iteration temp files tmp/ + +# Coding agent artifacts +.agent/ From 7c8d8adf25bf7493bacff6ccb035abe25d28b1a7 Mon Sep 17 00:00:00 2001 From: BrowserOS Coding Agent Date: Thu, 12 Feb 2026 13:16:25 +0000 Subject: [PATCH 2/2] feat(agent): if lets say user has connected MCP servers with klavis, we want it to accessible Task ID: XUfT6NJd --- apps/server/src/api/routes/chat.ts | 3 - apps/server/src/api/routes/klavis.ts | 25 +- apps/server/src/api/routes/mcp.ts | 39 ++- apps/server/src/api/server.ts | 7 +- apps/server/src/api/services/chat-service.ts | 28 +- apps/server/src/api/types.ts | 4 + apps/server/src/lib/klavis-mcp-proxy.ts | 176 +++++++++++++ apps/server/src/main.ts | 16 +- .../server/tests/lib/klavis-mcp-proxy.test.ts | 242 ++++++++++++++++++ packages/shared/src/constants/timeouts.ts | 6 + 10 files changed, 510 insertions(+), 36 deletions(-) create mode 100644 apps/server/src/lib/klavis-mcp-proxy.ts create mode 100644 apps/server/tests/lib/klavis-mcp-proxy.test.ts diff --git a/apps/server/src/api/routes/chat.ts b/apps/server/src/api/routes/chat.ts index 20393f48..2406f19f 100644 --- a/apps/server/src/api/routes/chat.ts +++ b/apps/server/src/api/routes/chat.ts @@ -9,7 +9,6 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { stream } from 'hono/streaming' import { SessionManager } from '../../agent/session' -import { KlavisClient } from '../../lib/clients/klavis/klavis-client' import { logger } from '../../lib/logger' import { metrics } from '../../lib/metrics' import type { RateLimiter } from '../../lib/rate-limiter/rate-limiter' @@ -33,11 +32,9 @@ export function createChatRoutes(deps: ChatRouteDeps) { const executionDir = deps.executionDir || PATHS.DEFAULT_EXECUTION_DIR const sessionManager = new SessionManager() - const klavisClient = new KlavisClient() const chatService = new ChatService({ sessionManager, - klavisClient, executionDir, mcpServerUrl, browserosId, diff --git a/apps/server/src/api/routes/klavis.ts b/apps/server/src/api/routes/klavis.ts index 89a74d8c..7caa5d6f 100644 --- a/apps/server/src/api/routes/klavis.ts +++ b/apps/server/src/api/routes/klavis.ts @@ -9,6 +9,7 @@ import { Hono } from 'hono' import { z } from 'zod' import { KlavisClient } from '../../lib/clients/klavis/klavis-client' import { OAUTH_MCP_SERVERS } from '../../lib/clients/klavis/oauth-mcp-servers' +import type { KlavisMcpProxy } from '../../lib/klavis-mcp-proxy' import { logger } from '../../lib/logger' const ServerNameSchema = z.object({ @@ -17,10 +18,11 @@ const ServerNameSchema = z.object({ interface KlavisRouteDeps { browserosId: string + klavisMcpProxy?: KlavisMcpProxy } export function createKlavisRoutes(deps: KlavisRouteDeps) { - const { browserosId } = deps + const { browserosId, klavisMcpProxy } = deps const klavisClient = new KlavisClient() // Chain route definitions for proper Hono RPC type inference @@ -96,6 +98,12 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) { const result = await klavisClient.createStrata(browserosId, [serverName]) + klavisMcpProxy?.refresh().catch((e) => { + logger.warn('Failed to refresh Klavis MCP proxy after add', { + error: e instanceof Error ? e.message : String(e), + }) + }) + return c.json({ success: true, serverName, @@ -127,6 +135,15 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) { logger.info('Submitted API key for server', { serverName }) + klavisMcpProxy?.refresh().catch((e) => { + logger.warn( + 'Failed to refresh Klavis MCP proxy after api-key submit', + { + error: e instanceof Error ? e.message : String(e), + }, + ) + }) + return c.json({ success: true, serverName }) } catch (error) { logger.error('Error submitting API key', { @@ -156,6 +173,12 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) { await klavisClient.removeServer(browserosId, serverName) + klavisMcpProxy?.refresh().catch((e) => { + logger.warn('Failed to refresh Klavis MCP proxy after remove', { + error: e instanceof Error ? e.message : String(e), + }) + }) + return c.json({ success: true, serverName, diff --git a/apps/server/src/api/routes/mcp.ts b/apps/server/src/api/routes/mcp.ts index 43ad6980..cb3d388b 100644 --- a/apps/server/src/api/routes/mcp.ts +++ b/apps/server/src/api/routes/mcp.ts @@ -14,12 +14,13 @@ import type { } from '@modelcontextprotocol/sdk/types.js' import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { Hono } from 'hono' -import type { z } from 'zod' +import { z } from 'zod' import type { McpContext } from '../../browser/cdp/context' import { type ControllerContext, ScopedControllerContext, } from '../../browser/extension/context' +import type { KlavisMcpProxy } from '../../lib/klavis-mcp-proxy' import { logger } from '../../lib/logger' import { metrics } from '../../lib/metrics' import type { MutexPool } from '../../lib/mutex' @@ -37,6 +38,7 @@ interface McpRouteDeps { controllerContext: ControllerContext mutexPool: MutexPool allowRemote: boolean + klavisMcpProxy?: KlavisMcpProxy } const MCP_SOURCE_HEADER = 'X-BrowserOS-Source' @@ -60,7 +62,14 @@ function getMcpRequestSource( * Reuses the same logic from the old mcp/server.ts */ function createMcpServerWithTools(deps: McpRouteDeps): McpServer { - const { version, tools, cdpContext, controllerContext, mutexPool } = deps + const { + version, + tools, + cdpContext, + controllerContext, + mutexPool, + klavisMcpProxy, + } = deps const server = new McpServer( { @@ -164,6 +173,23 @@ function createMcpServerWithTools(deps: McpRouteDeps): McpServer { ) } + // Register upstream Klavis tools (proxied through Strata) + if (klavisMcpProxy?.isConnected()) { + for (const tool of klavisMcpProxy.getTools()) { + server.registerTool( + tool.name, + { + description: tool.description ?? '', + inputSchema: z.object({}).passthrough() as unknown as z.ZodRawShape, + annotations: tool.annotations, + }, + async (params: Record): Promise => { + return klavisMcpProxy.callTool(tool.name, params) + }, + ) + } + } + return server } @@ -171,7 +197,14 @@ export function createMcpRoutes(deps: McpRouteDeps) { const { allowRemote } = deps // Create MCP server once with all tools registered - const mcpServer = createMcpServerWithTools(deps) + let mcpServer = createMcpServerWithTools(deps) + + if (deps.klavisMcpProxy) { + deps.klavisMcpProxy.onToolsChanged = () => { + mcpServer = createMcpServerWithTools(deps) + logger.info('MCP server rebuilt with updated Klavis tools') + } + } return new Hono().all('/', async (c) => { // Security check: localhost only (unless allowRemote is enabled) diff --git a/apps/server/src/api/server.ts b/apps/server/src/api/server.ts index 382e5a1e..1a59035b 100644 --- a/apps/server/src/api/server.ts +++ b/apps/server/src/api/server.ts @@ -64,6 +64,7 @@ export async function createHttpServer(config: HttpServerConfig) { controllerContext, mutexPool, allowRemote, + klavisMcpProxy, } = config const { onShutdown } = config @@ -78,7 +79,10 @@ export async function createHttpServer(config: HttpServerConfig) { ) .route('/status', createStatusRoute({ controllerContext })) .route('/test-provider', createProviderRoutes()) - .route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' })) + .route( + '/klavis', + createKlavisRoutes({ browserosId: browserosId || '', klavisMcpProxy }), + ) .route( '/mcp', createMcpRoutes({ @@ -88,6 +92,7 @@ export async function createHttpServer(config: HttpServerConfig) { controllerContext, mutexPool, allowRemote, + klavisMcpProxy, }), ) .route( diff --git a/apps/server/src/api/services/chat-service.ts b/apps/server/src/api/services/chat-service.ts index 711bfd5b..e16f563f 100644 --- a/apps/server/src/api/services/chat-service.ts +++ b/apps/server/src/api/services/chat-service.ts @@ -16,7 +16,6 @@ import { fetchBrowserOSConfig, getLLMConfigFromProvider, } from '../../lib/clients/gateway' -import type { KlavisClient } from '../../lib/clients/klavis/klavis-client' import { logger } from '../../lib/logger' import { detectMcpTransport, @@ -48,7 +47,6 @@ function createMcpServerConfig(options: McpServerOptions): MCPServerConfig { export interface ChatServiceDeps { sessionManager: SessionManager - klavisClient: KlavisClient executionDir: string mcpServerUrl: string browserosId?: string @@ -163,7 +161,7 @@ export class ChatService { private async buildMcpServers( browserContext?: BrowserContext, ): Promise> { - const { klavisClient, mcpServerUrl, browserosId } = this.deps + const { mcpServerUrl } = this.deps const servers: Record = {} if (mcpServerUrl) { @@ -181,30 +179,6 @@ export class ChatService { }) } - if (browserosId && browserContext?.enabledMcpServers?.length) { - try { - const result = await klavisClient.createStrata( - browserosId, - browserContext.enabledMcpServers, - ) - servers['klavis-strata'] = createMcpServerConfig({ - url: result.strataServerUrl, - transport: 'streamable-http', - trust: true, - }) - logger.info('Added Klavis Strata MCP server', { - browserosId: browserosId.slice(0, 12), - servers: browserContext.enabledMcpServers, - }) - } catch (error) { - logger.error('Failed to create Klavis Strata MCP server', { - browserosId: browserosId?.slice(0, 12), - servers: browserContext.enabledMcpServers, - error: error instanceof Error ? error.message : String(error), - }) - } - } - if (browserContext?.customMcpServers?.length) { const customServers = browserContext.customMcpServers const transports = await Promise.all( diff --git a/apps/server/src/api/types.ts b/apps/server/src/api/types.ts index c5f034a6..fc2f8911 100644 --- a/apps/server/src/api/types.ts +++ b/apps/server/src/api/types.ts @@ -18,6 +18,7 @@ import { VercelAIConfigSchema } from '../agent/provider-adapter/types' import type { McpContext } from '../browser/cdp/context' import type { ControllerContext } from '../browser/extension/context' +import type { KlavisMcpProxy } from '../lib/klavis-mcp-proxy' import type { MutexPool } from '../lib/mutex' import type { RateLimiter } from '../lib/rate-limiter/rate-limiter' import type { ToolDefinition } from '../tools/types/tool-definition' @@ -74,6 +75,9 @@ export interface HttpServerConfig { mutexPool: MutexPool allowRemote: boolean + // For Klavis MCP proxy + klavisMcpProxy?: KlavisMcpProxy + // For Chat/Klavis routes browserosId?: string executionDir?: string diff --git a/apps/server/src/lib/klavis-mcp-proxy.ts b/apps/server/src/lib/klavis-mcp-proxy.ts new file mode 100644 index 00000000..984c5b58 --- /dev/null +++ b/apps/server/src/lib/klavis-mcp-proxy.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Upstream Klavis MCP proxy: connects to Strata, discovers tools, + * proxies tool calls, and handles periodic refresh. + */ + +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 { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js' +import type { KlavisClient } from './clients/klavis/klavis-client' +import { logger } from './logger' + +export class KlavisMcpProxy { + private client: Client | null = null + private transport: StreamableHTTPClientTransport | null = null + private tools: Tool[] = [] + private authenticatedServerNames: string[] = [] + private refreshInterval: ReturnType | null = null + + onToolsChanged: (() => void) | null = null + + constructor( + private klavisClient: KlavisClient, + private browserosId: string, + ) {} + + async connect(): Promise { + try { + const integrations = await this.klavisClient.getUserIntegrations( + this.browserosId, + ) + const authenticated = integrations.filter((i) => i.isAuthenticated) + const names = authenticated.map((i) => i.name) + + if (names.length === 0) { + this.tools = [] + this.authenticatedServerNames = [] + logger.info('No authenticated Klavis integrations found') + return + } + + const result = await this.klavisClient.createStrata( + this.browserosId, + names, + ) + + const client = new Client({ + name: 'browseros-klavis-proxy', + version: '1.0.0', + }) + + const transport = new StreamableHTTPClientTransport( + new URL(result.strataServerUrl), + ) + + await client.connect(transport) + + const listResult = await client.listTools(undefined, { + signal: AbortSignal.timeout(TIMEOUTS.MCP_UPSTREAM_LIST_TOOLS), + }) + + this.client = client + this.transport = transport + this.tools = listResult.tools as Tool[] + this.authenticatedServerNames = names + + logger.info('Connected to Klavis Strata', { + toolCount: this.tools.length, + servers: names, + }) + + this.onToolsChanged?.() + + this.refreshInterval = setInterval(() => { + this.refresh().catch((e) => { + logger.warn('Periodic Klavis MCP proxy refresh failed', { + error: e instanceof Error ? e.message : String(e), + }) + }) + }, TIMEOUTS.MCP_UPSTREAM_REFRESH_INTERVAL) + } catch (error) { + logger.warn('Failed to connect to Klavis Strata', { + error: error instanceof Error ? error.message : String(error), + }) + this.tools = [] + } + } + + getTools(): Tool[] { + return this.tools + } + + async callTool( + name: string, + args: Record, + ): Promise { + if (!this.client) { + return { + content: [{ type: 'text', text: 'Klavis MCP proxy is not connected' }], + isError: true, + } + } + + const result = await this.client.callTool( + { name, arguments: args }, + undefined, + { signal: AbortSignal.timeout(TIMEOUTS.MCP_UPSTREAM_TOOL_CALL) }, + ) + + // The SDK may return { toolResult } for compatibility — normalize to CallToolResult + if ('toolResult' in result) { + return { + content: [{ type: 'text', text: JSON.stringify(result.toolResult) }], + } + } + + return result as CallToolResult + } + + async refresh(): Promise { + try { + const integrations = await this.klavisClient.getUserIntegrations( + this.browserosId, + ) + const authenticated = integrations.filter((i) => i.isAuthenticated) + const names = authenticated.map((i) => i.name).sort() + const currentNames = [...this.authenticatedServerNames].sort() + + if ( + names.length === currentNames.length && + names.every((n, i) => n === currentNames[i]) + ) { + return + } + + logger.info('Klavis integration set changed, reconnecting', { + previous: currentNames, + current: names, + }) + + await this.disconnectClient() + await this.connect() + } catch (error) { + logger.warn('Failed to refresh Klavis MCP proxy', { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + async disconnect(): Promise { + if (this.refreshInterval) { + clearInterval(this.refreshInterval) + this.refreshInterval = null + } + await this.disconnectClient() + } + + isConnected(): boolean { + return this.client !== null && this.tools.length > 0 + } + + private async disconnectClient(): Promise { + try { + await this.transport?.close() + } catch { + // Ignore close errors + } + this.client = null + this.transport = null + this.tools = [] + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index c0d2c6a3..937f97d4 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -20,9 +20,10 @@ import { ControllerBridge } from './browser/extension/bridge' import { ControllerContext } from './browser/extension/context' import type { ServerConfig } from './config' import { INLINED_ENV } from './env' +import { KlavisClient } from './lib/clients/klavis/klavis-client' import { initializeDb } from './lib/db' - import { identity } from './lib/identity' +import { KlavisMcpProxy } from './lib/klavis-mcp-proxy' import { logger } from './lib/logger' import { metrics } from './lib/metrics' import { MutexPool } from './lib/mutex' @@ -72,6 +73,18 @@ export class Application { const tools = createToolRegistry(cdpContext) const mutexPool = new MutexPool() + const klavisClient = new KlavisClient() + const klavisMcpProxy = new KlavisMcpProxy( + klavisClient, + identity.getBrowserOSId(), + ) + + klavisMcpProxy.connect().catch((error) => { + logger.warn('Failed initial Klavis MCP proxy connection', { + error: error instanceof Error ? error.message : String(error), + }) + }) + try { await createHttpServer({ port: this.config.serverPort, @@ -86,6 +99,7 @@ export class Application { executionDir: this.config.executionDir, rateLimiter: new RateLimiter(this.getDb(), dailyRateLimit), codegenServiceUrl: this.config.codegenServiceUrl, + klavisMcpProxy, onShutdown: () => this.stop(), }) diff --git a/apps/server/tests/lib/klavis-mcp-proxy.test.ts b/apps/server/tests/lib/klavis-mcp-proxy.test.ts new file mode 100644 index 00000000..4b6d328f --- /dev/null +++ b/apps/server/tests/lib/klavis-mcp-proxy.test.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, beforeEach, describe, it, mock } from 'bun:test' +import assert from 'node:assert' + +// Mock MCP SDK modules before importing KlavisMcpProxy +const mockConnect = mock(() => Promise.resolve()) +const mockListTools = mock(() => + Promise.resolve({ + tools: [ + { + name: 'discover_server_categories_or_actions', + description: 'Discover available server categories', + inputSchema: { type: 'object' as const }, + }, + { + name: 'execute_action', + description: 'Execute an action', + inputSchema: { type: 'object' as const }, + }, + ], + }), +) +const mockCallTool = mock(() => + Promise.resolve({ + content: [{ type: 'text', text: 'tool result' }], + }), +) +const mockTransportClose = mock(() => Promise.resolve()) + +mock.module('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: class MockClient { + connect = mockConnect + listTools = mockListTools + callTool = mockCallTool + }, +})) + +mock.module('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: class MockTransport { + close = mockTransportClose + }, +})) + +import { KlavisMcpProxy } from '../../src/lib/klavis-mcp-proxy' + +function createMockKlavisClient( + overrides: { + getUserIntegrations?: () => Promise< + Array<{ name: string; isAuthenticated: boolean }> + > + createStrata?: () => Promise<{ + strataServerUrl: string + strataId: string + addedServers: string[] + }> + } = {}, +) { + return { + getUserIntegrations: + overrides.getUserIntegrations ?? + mock(() => + Promise.resolve([ + { name: 'Gmail', isAuthenticated: true }, + { name: 'Slack', isAuthenticated: false }, + ]), + ), + createStrata: + overrides.createStrata ?? + mock(() => + Promise.resolve({ + strataServerUrl: 'http://strata.test/mcp', + strataId: 'test-strata', + addedServers: ['Gmail'], + }), + ), + submitApiKey: mock(() => Promise.resolve()), + removeServer: mock(() => Promise.resolve()), + } as unknown as ConstructorParameters[0] +} + +describe('KlavisMcpProxy', () => { + let proxy: KlavisMcpProxy + + beforeEach(() => { + mockConnect.mockClear() + mockListTools.mockClear() + mockCallTool.mockClear() + mockTransportClose.mockClear() + }) + + afterEach(async () => { + if (proxy) { + await proxy.disconnect() + } + }) + + it('connect() fetches authenticated integrations and connects to Strata', async () => { + const klavisClient = createMockKlavisClient() + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + + await proxy.connect() + + assert.strictEqual(proxy.isConnected(), true) + assert.strictEqual(proxy.getTools().length, 2) + assert.strictEqual( + proxy.getTools()[0].name, + 'discover_server_categories_or_actions', + ) + assert.strictEqual(proxy.getTools()[1].name, 'execute_action') + assert.strictEqual(klavisClient.getUserIntegrations.mock.calls.length, 1) + assert.strictEqual(klavisClient.createStrata.mock.calls.length, 1) + assert.strictEqual(mockConnect.mock.calls.length, 1) + assert.strictEqual(mockListTools.mock.calls.length, 1) + }) + + it('connect() with no authenticated integrations — no connection, empty tools', async () => { + const klavisClient = createMockKlavisClient({ + getUserIntegrations: mock(() => + Promise.resolve([ + { name: 'Gmail', isAuthenticated: false }, + { name: 'Slack', isAuthenticated: false }, + ]), + ), + }) + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + + await proxy.connect() + + assert.strictEqual(proxy.isConnected(), false) + assert.deepStrictEqual(proxy.getTools(), []) + assert.strictEqual(klavisClient.createStrata.mock.calls.length, 0) + assert.strictEqual(mockConnect.mock.calls.length, 0) + }) + + it('callTool() delegates to upstream client', async () => { + const klavisClient = createMockKlavisClient() + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + await proxy.connect() + + const result = await proxy.callTool( + 'discover_server_categories_or_actions', + { query: 'email' }, + ) + + assert.strictEqual(mockCallTool.mock.calls.length, 1) + const callArgs = mockCallTool.mock.calls[0] + assert.deepStrictEqual(callArgs[0], { + name: 'discover_server_categories_or_actions', + arguments: { query: 'email' }, + }) + assert.ok(result.content) + assert.strictEqual( + (result.content as Array<{ type: string; text: string }>)[0].text, + 'tool result', + ) + }) + + it('callTool() when disconnected returns error result', async () => { + const klavisClient = createMockKlavisClient() + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + + const result = await proxy.callTool('some_tool', {}) + + assert.strictEqual(result.isError, true) + assert.strictEqual( + (result.content as Array<{ type: string; text: string }>)[0].text, + 'Klavis MCP proxy is not connected', + ) + }) + + it('refresh() triggers onToolsChanged when server set changes', async () => { + let callCount = 0 + const klavisClient = createMockKlavisClient({ + getUserIntegrations: mock(() => { + callCount++ + if (callCount <= 1) { + return Promise.resolve([{ name: 'Gmail', isAuthenticated: true }]) + } + return Promise.resolve([ + { name: 'Gmail', isAuthenticated: true }, + { name: 'Slack', isAuthenticated: true }, + ]) + }), + }) + + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + await proxy.connect() + + const onToolsChanged = mock(() => {}) + proxy.onToolsChanged = onToolsChanged + + await proxy.refresh() + + // onToolsChanged is called both during reconnect (via connect()) and should be triggered + assert.ok( + onToolsChanged.mock.calls.length >= 1, + 'onToolsChanged should have been called at least once', + ) + }) + + it('refresh() is a no-op when server set is unchanged', async () => { + const klavisClient = createMockKlavisClient({ + getUserIntegrations: mock(() => + Promise.resolve([{ name: 'Gmail', isAuthenticated: true }]), + ), + }) + + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + await proxy.connect() + + const connectCallsBefore = mockConnect.mock.calls.length + + const onToolsChanged = mock(() => {}) + proxy.onToolsChanged = onToolsChanged + + await proxy.refresh() + + // Should not have reconnected + assert.strictEqual(mockConnect.mock.calls.length, connectCallsBefore) + assert.strictEqual(onToolsChanged.mock.calls.length, 0) + }) + + it('connect() failure is graceful', async () => { + const klavisClient = createMockKlavisClient({ + getUserIntegrations: mock(() => { + throw new Error('Network error') + }), + }) + + proxy = new KlavisMcpProxy(klavisClient, 'test-browseros-id') + + // Should not throw + await proxy.connect() + + assert.strictEqual(proxy.isConnected(), false) + assert.deepStrictEqual(proxy.getTools(), []) + }) +}) diff --git a/packages/shared/src/constants/timeouts.ts b/packages/shared/src/constants/timeouts.ts index 0da1f7ed..d28e105f 100644 --- a/packages/shared/src/constants/timeouts.ts +++ b/packages/shared/src/constants/timeouts.ts @@ -25,6 +25,12 @@ export const TIMEOUTS = { // External API calls KLAVIS_FETCH: 30_000, + // Upstream MCP proxy (Klavis Strata) + MCP_UPSTREAM_CONNECT: 10_000, + MCP_UPSTREAM_TOOL_CALL: 60_000, + MCP_UPSTREAM_LIST_TOOLS: 10_000, + MCP_UPSTREAM_REFRESH_INTERVAL: 300_000, // 5 minutes + // Navigation/DOM NAVIGATION: 10_000, PAGE_LOAD_WAIT: 30_000,