diff --git a/src/__tests__/logging.test.ts b/src/__tests__/logging.test.ts new file mode 100644 index 0000000..ecdb7ad --- /dev/null +++ b/src/__tests__/logging.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { classifyBundlerLog } from '../logging.js'; + +describe('classifyBundlerLog', () => { + it('downgrades expected variant-connection failures to debug', () => { + // These fire when a variant port is closed (the Kunobi app isn't running), + // which is normal for this hub — they must not reach the client as errors. + expect(classifyBundlerLog('error', '[legacy] Transport error')).toBe( + 'debug', + ); + expect(classifyBundlerLog('error', '[dev] Connection failed')).toBe( + 'debug', + ); + }); + + it('keeps genuine errors from a connected variant at error', () => { + expect(classifyBundlerLog('error', '[stable] Failed to list tools')).toBe( + 'error', + ); + expect( + classifyBundlerLog('error', '[stable] Tool call failed: get_pod_logs'), + ).toBe('error'); + expect( + classifyBundlerLog('error', '[stable] Resource read failed: kunobi://x'), + ).toBe('error'); + }); + + it('maps warn to warning', () => { + expect( + classifyBundlerLog('warn', '[stable] Session expired. Reconnecting.'), + ).toBe('warning'); + }); + + it('drops info/debug and any other level', () => { + expect(classifyBundlerLog('info', '[stable] Connected')).toBeNull(); + expect(classifyBundlerLog('debug', 'anything')).toBeNull(); + }); +}); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..aa2ed1c --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,37 @@ +import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; + +// A failed attempt to (re)connect to a variant's port is expected operation for +// this hub, not a client-actionable error: the hub is designed for Kunobi +// variants that come and go, it retries forever, and it already reports an +// absent variant as `not_running` (see VariantManager.getStates). The bundler, +// however, logs these connection-establishment failures at `error`, so they +// reach the MCP client looking like something broke when nothing did — the app +// simply isn't running. Match those messages so we can route them to `debug`. +// +// Note this is deliberately limited to connection failures. Errors raised while +// a variant IS connected (e.g. "Failed to list tools", "Tool call failed") are +// genuine and must still surface at `error`. +function isExpectedConnectFailure(message: string): boolean { + return ( + message.includes('Transport error') || message.includes('Connection failed') + ); +} + +// Map a bundler log (level, message) to the MCP logging level the hub should +// emit, or `null` to drop it. Mirrors the previous inline behaviour — only +// `error`/`warn` are surfaced to the client — with one change: expected +// variant-connection failures are downgraded from `error` to `debug` so they +// stop masquerading as failures. The connection state itself stays observable +// via the `kunobi://status` resource and the `kunobi_status` tool. +export function classifyBundlerLog( + level: string, + message: string, +): LoggingLevel | null { + if (level === 'error') { + return isExpectedConnectFailure(message) ? 'debug' : 'error'; + } + if (level === 'warn') { + return 'warning'; + } + return null; +} diff --git a/src/server.ts b/src/server.ts index 07daaa5..961109c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import { getConnectionConfig, launchHint, } from './discovery.js'; +import { classifyBundlerLog } from './logging.js'; import { VariantManager } from './manager.js'; import { registerCallTool } from './tools/call.js'; import { registerLaunchTool } from './tools/launch.js'; @@ -229,10 +230,11 @@ const manager = new VariantManager(server, { reconnectIntervalMs: connectionConfig.reconnectIntervalMs, autoReconnect: connectionConfig.autoConnect, logger: (level, message) => { - if (level === 'error' || level === 'warn') { + const mcpLevel = classifyBundlerLog(level, message); + if (mcpLevel) { server.server .sendLoggingMessage({ - level: level === 'error' ? 'error' : 'warning', + level: mcpLevel, logger: 'kunobi-mcp', data: message, })