Skip to content
Merged
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
38 changes: 38 additions & 0 deletions src/__tests__/logging.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
37 changes: 37 additions & 0 deletions src/logging.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 4 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
})
Expand Down
Loading