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
5 changes: 5 additions & 0 deletions .changeset/mcp-tool-call-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Reconnect a dropped MCP server connection automatically when one of its tools is called, and retry the call once.
5 changes: 5 additions & 0 deletions packages/agent-core-v2/src/agent/mcp/client-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
MCP_LIVENESS_PROBE_TIMEOUT_MS,
toMcpToolDefinition,
toMcpToolResult,
type UnexpectedCloseListener,
Expand Down Expand Up @@ -103,6 +104,10 @@ export class HttpMcpClient implements MCPClient {
return toMcpToolResult(result);
}

async ping(signal?: AbortSignal): Promise<void> {
await this.client.ping(buildRequestOptions(MCP_LIVENESS_PROBE_TIMEOUT_MS, signal));
}

private async closeStartedClient(): Promise<void> {
if (!this.started) return;
this.started = false;
Expand Down
60 changes: 59 additions & 1 deletion packages/agent-core-v2/src/agent/mcp/client-shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getCoreVersion } from '#/_base/version';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';

import type { MCPToolDefinition, MCPToolResult } from './types';
import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';

export const KIMI_MCP_CLIENT_NAME = 'kimi-code';
export const KIMI_MCP_CLIENT_VERSION = getCoreVersion();
Expand All @@ -12,6 +13,63 @@ export interface UnexpectedCloseReason {

export type UnexpectedCloseListener = (reason: UnexpectedCloseReason) => void;

export function isMcpConnectionClosedError(error: unknown): boolean {
return (
error instanceof Error &&
(error as Error & { readonly code?: unknown }).code === ErrorCode.ConnectionClosed
);
}

export function isMcpTransportFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (isMcpConnectionClosedError(error)) return true;
return !(error instanceof McpError);
}

/**
* Timeout for the liveness probe sent after an ambiguous tool-call failure.
* Kept short: the probe runs on an already-failed call, so it must not add
* anywhere near a tool-call timeout to the turn.
*/
export const MCP_LIVENESS_PROBE_TIMEOUT_MS = 5_000;

/**
* True when the error is a client-side validation failure of an otherwise
* well-formed JSON-RPC response: the SDK rejects with a `ZodError` when the
* result of `tools/call` does not match `CallToolResultSchema`
* (shared/protocol.js rejects with `parseResult.error`). The server did
* answer, so reconnecting is pointless — but the error is not an `McpError`,
* so `isMcpTransportFailure` alone cannot tell it apart from a dead
* transport. Matched by name because the repo carries more than one zod
* copy, which makes `instanceof` unreliable.
*/
export function isMcpMalformedResultError(error: unknown): boolean {
return error instanceof Error && error.name === 'ZodError';
}

/**
* Probes whether the client's transport is still usable by sending a ping.
* A server that answers in any way — including `MethodNotFound`, a JSON-RPC
* error, or an unparseable result — counts as alive; only errors that prove
* the bytes never made a round trip (closed connection, fetch failures) or
* a probe that itself timed out (alive socket, unresponsive server) count
* as dead. Never rejects; an abort surfaces as a dead verdict and is the
* caller's job to detect via the signal.
*/
export async function probeMcpLiveness(client: MCPClient, signal: AbortSignal): Promise<boolean> {
try {
await client.ping(signal);
return true;
} catch (error) {
if (isMcpConnectionClosedError(error)) return false;
if (isMcpMalformedResultError(error)) return true;
if (error instanceof McpError) {
return (error as Error & { readonly code?: unknown }).code !== ErrorCode.RequestTimeout;
}
return false;
}
}

export interface McpRequestOptions {
readonly timeout?: number;
readonly signal?: AbortSignal;
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core-v2/src/agent/mcp/client-sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
MCP_LIVENESS_PROBE_TIMEOUT_MS,
toMcpToolDefinition,
toMcpToolResult,
type UnexpectedCloseListener,
Expand Down Expand Up @@ -103,6 +104,10 @@ export class SseMcpClient implements MCPClient {
return toMcpToolResult(result);
}

async ping(signal?: AbortSignal): Promise<void> {
await this.client.ping(buildRequestOptions(MCP_LIVENESS_PROBE_TIMEOUT_MS, signal));
}

private async closeStartedClient(): Promise<void> {
if (!this.started) return;
this.started = false;
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core-v2/src/agent/mcp/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
MCP_LIVENESS_PROBE_TIMEOUT_MS,
toMcpToolDefinition,
toMcpToolResult,
type UnexpectedCloseListener,
Expand Down Expand Up @@ -115,6 +116,10 @@ export class StdioMcpClient implements MCPClient {
return toMcpToolResult(result);
}

async ping(signal?: AbortSignal): Promise<void> {
await this.client.ping(buildRequestOptions(MCP_LIVENESS_PROBE_TIMEOUT_MS, signal));
}

private async closeStartedClient(): Promise<void> {
if (!this.started) return;
this.started = false;
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core-v2/src/agent/mcp/connection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface McpConnectionManagerOptions {
export class McpConnectionManager {
private readonly entries = new Map<string, InternalEntry>();
private readonly listeners = new Set<McpStatusListener>();
private readonly inFlightReconnects = new Map<string, Promise<void>>();
private initialLoad: Promise<void> = Promise.resolve();
private initialLoadAttemptId = 0;
private initialLoadStartedAt: number | undefined;
Expand Down Expand Up @@ -232,6 +233,18 @@ export class McpConnectionManager {
await this.connectOne(entry, attemptId);
}

reconnectAndJoin(name: string): Promise<void> {
const existing = this.inFlightReconnects.get(name);
if (existing !== undefined) return existing;
const work = this.reconnect(name).finally(() => {
if (this.inFlightReconnects.get(name) === work) {
this.inFlightReconnects.delete(name);
}
});
this.inFlightReconnects.set(name, work);
return work;
}

async shutdown(): Promise<void> {
const entries = Array.from(this.entries.values());
this.entries.clear();
Expand Down
37 changes: 29 additions & 8 deletions packages/agent-core-v2/src/agent/mcp/mcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Tool as KosongTool } from '#/app/llmProtocol/tool';
import { Disposable, type IDisposable } from "#/_base/di/lifecycle";
import type { KimiErrorPayload } from '#/_base/errors/serialize';
import { ErrorCodes, makeErrorPayload } from "#/errors";
import { abortable } from '#/_base/utils/abort';
import { IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { sessionMediaOriginalsDir } from '#/agent/media/image-originals';
Expand Down Expand Up @@ -130,6 +131,26 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
signal?.throwIfAborted();
}

private reconnectForToolCall(
serverName: string,
staleClient: MCPClient,
signal?: AbortSignal,
): Promise<MCPClient | undefined> {
const work = this.joinHealedOrReconnect(serverName, staleClient);
return signal === undefined ? work : abortable(work, signal);
}

private async joinHealedOrReconnect(
serverName: string,
staleClient: MCPClient,
): Promise<MCPClient | undefined> {
const healed = this.resolved(serverName)?.client;
if (healed !== undefined && healed !== staleClient) return healed;
await this.sessionMcp.connectionManager().reconnectAndJoin(serverName);
const current = this.resolved(serverName)?.client;
return current !== undefined && current !== staleClient ? current : undefined;
}

onStatusChange(listener: Parameters<IAgentMcpService['onStatusChange']>[0]) {
const unsubscribe = this.sessionMcp.connectionManager().onStatusChange(listener);
return {
Expand Down Expand Up @@ -167,16 +188,15 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
this.registerNeedsAuthMcpServer(entry);
return;
}
if (entry.status === 'failed') {
this.unregisterMcpServer(entry.name);
this.eventBus.publish({
type: 'tool.list.updated',
reason: 'mcp.failed',
serverName: entry.name,
});
if (entry.status === 'failed' || entry.status === 'pending') {
// Keep the server's tools registered while it is down or reconnecting.
// The captured client is closed, so the next call fails fast at the
// transport layer and the tool adapter's reconnect-and-retry path heals
// the connection — a dropped server surfaces as a slow call instead of
// "tool not found" for the rest of the session.
return;
}
if (entry.status === 'disabled' || entry.status === 'pending') {
if (entry.status === 'disabled') {
const removed = this.unregisterMcpServer(entry.name);
if (removed) {
this.eventBus.publish({
Expand Down Expand Up @@ -267,6 +287,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
createMcpTool(qualified, tool, client, {
originalsDir: sessionMediaOriginalsDir(this.sessionContext.sessionDir),
telemetry: this.telemetry,
reconnect: (signal) => this.reconnectForToolCall(serverName, client, signal),
}),
{ source: 'mcp' },
),
Expand Down
109 changes: 101 additions & 8 deletions packages/agent-core-v2/src/agent/mcp/tools/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,45 @@
*
* Each tool exposed by a connected MCP server is adapted into an
* `ExecutableTool` whose `resolveExecution` forwards the call to the client
* and normalizes the result.
* and normalizes the result. When a call fails, the adapter picks one of
* three recoveries based on why it failed:
*
* - The server answered (a JSON-RPC error, or a response that failed
* client-side schema validation) → the error is rethrown; reconnecting
* would not change the answer.
* - The failure is ambiguous (a raw fetch/socket error) → the client is
* probed with a ping: alive means a transient blip and the call is
* retried once in place; dead means the transport is gone.
* - The transport is provably dead (the SDK fired `onclose`, or the probe
* failed) → the server is reconnected once through `options.reconnect`
* and the call retried on the fresh client, so a dropped connection
* surfaces as a slow call instead of a failed turn.
*
* Retries are at-least-once: if the transport died after the server
* processed the call but before the response arrived, the retry may
* duplicate side effects. There is no protocol-level dedup across
* reconnects, so this trade-off is accepted deliberately.
*/

import type { Tool as KosongTool } from '#/app/llmProtocol/tool';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import { toErrorMessage } from '#/errors';
import { isAbortError } from '#/_base/utils/abort';

import type { ExecutableTool, ExecutableToolResult } from '#/tool/toolContract';
import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract';
import { mcpResultToExecutableOutput } from '#/agent/mcp/output';
import type { MCPClient } from '#/agent/mcp/types';
import type { MCPClient, MCPToolResult } from '#/agent/mcp/types';
import {
isMcpConnectionClosedError,
isMcpMalformedResultError,
isMcpTransportFailure,
probeMcpLiveness,
} from '#/agent/mcp/client-shared';

interface McpToolOptions {
readonly originalsDir?: string;
readonly telemetry?: ITelemetryService;
readonly reconnect?: (signal?: AbortSignal) => Promise<MCPClient | undefined>;
}

export function createMcpTool(
Expand All @@ -24,18 +50,21 @@ export function createMcpTool(
client: MCPClient,
options: McpToolOptions = {},
): ExecutableTool {
const callTool = (activeClient: MCPClient, args: unknown, signal: AbortSignal) =>
activeClient.callTool(tool.name, (args ?? {}) as Record<string, unknown>, signal);
return {
name: qualifiedName,
description: tool.description,
parameters: tool.parameters,
resolveExecution: (args) => ({
approvalRule: qualifiedName,
execute: async (context) => {
const result = await client.callTool(
tool.name,
(args ?? {}) as Record<string, unknown>,
context.signal,
);
let result;
try {
result = await callTool(client, args, context.signal);
} catch (error) {
result = await retryAfterReconnect(error, client, args, context, options, callTool);
}
return normalizeMcpToolResult(
await mcpResultToExecutableOutput(result, qualifiedName, {
originalsDir: options.originalsDir,
Expand All @@ -47,6 +76,70 @@ export function createMcpTool(
};
}

async function retryAfterReconnect(
error: unknown,
client: MCPClient,
args: unknown,
context: Pick<ExecutableToolContext, 'signal' | 'onUpdate'>,
options: McpToolOptions,
callTool: (client: MCPClient, args: unknown, signal: AbortSignal) => Promise<MCPToolResult>,
): Promise<MCPToolResult> {
const reconnect = options.reconnect;
// Errors that can never be fixed by a retry: user cancellation, and the
// server having answered — a JSON-RPC error (`McpError`, including a tool
// call timeout) or a malformed result that failed schema validation.
const isUnrecoverable = (e: unknown): boolean =>
context.signal.aborted ||
isAbortError(e) ||
!isMcpTransportFailure(e) ||
isMcpMalformedResultError(e);
if (reconnect === undefined || isUnrecoverable(error)) {
throw error;
}

// A ConnectionClosed error is a measured death (the SDK already fired
// `onclose` and rejected every pending request), so it goes straight to
// reconnect. Anything else is ambiguous about whether the transport
// still works — probe it instead of guessing from the error's type.
let failure = error;
if (!isMcpConnectionClosedError(failure)) {
const alive = await probeMcpLiveness(client, context.signal);
context.signal.throwIfAborted();
if (alive) {
// The transport is fine and the failure was transient: retry once in
// place instead of paying a full reconnect for a network blip. If the
// transport dies between probe and retry, fall through to reconnect —
// still capped at one reconnect per call.
try {
return await callTool(client, args, context.signal);
} catch (retryError) {
if (isUnrecoverable(retryError)) {
throw retryError;
}
failure = retryError;
}
}
}

context.onUpdate?.({ kind: 'status', text: 'MCP connection lost — reconnecting…' });
let freshClient: MCPClient | undefined;
try {
freshClient = await reconnect(context.signal);
} catch (reconnectError) {
if (context.signal.aborted || isAbortError(reconnectError)) {
throw reconnectError;
}
throw new Error(
`${toErrorMessage(failure)} (reconnecting the MCP server also failed: ${toErrorMessage(reconnectError)})`,
{ cause: reconnectError },
);
}
if (freshClient === undefined) {
throw failure;
}
return callTool(freshClient, args, context.signal);
}

function normalizeMcpToolResult(result: {
readonly output: ExecutableToolResult['output'];
readonly isError: boolean;
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-core-v2/src/agent/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export interface MCPClient {
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<MCPToolResult>;
/**
* Sends a protocol-level `ping` with a short built-in timeout, so a hung
* server rejects instead of blocking. Used to probe liveness after an
* ambiguous call failure; a server that answers in any way — even with
* `MethodNotFound` — proves the transport is usable.
*/
ping(signal?: AbortSignal): Promise<void>;
}

export function assertMcpInputSchema(
Expand Down
Loading
Loading