Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/refresh-mcp-tools-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Refresh MCP tools when servers report tool list changes.
9 changes: 8 additions & 1 deletion packages/agent-core/src/mcp/client-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

import {
buildListChangedClientOptions,
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
toMcpToolDefinition,
toMcpToolResult,
type ToolsChangedListener,
type UnexpectedCloseListener,
type UnexpectedCloseReason,
} from './client-shared';
Expand Down Expand Up @@ -54,6 +56,7 @@ export class HttpMcpClient implements MCPClient {
private ready = false;
private hooksInstalled = false;
private unexpectedCloseListener: UnexpectedCloseListener | undefined;
private toolsChangedListener: ToolsChangedListener | undefined;
private lastTransportError: Error | undefined;
// See StdioMcpClient — buffered when the listener has not been installed
// yet so an early close is replayed instead of dropped.
Expand All @@ -75,7 +78,7 @@ export class HttpMcpClient implements MCPClient {
this.client = new Client({
name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
});
}, buildListChangedClientOptions(() => this.toolsChangedListener));
this.toolCallTimeoutMs = options.toolCallTimeoutMs;
}

Expand Down Expand Up @@ -121,6 +124,10 @@ export class HttpMcpClient implements MCPClient {
}
}

onToolsChanged(listener: ToolsChangedListener): void {
this.toolsChangedListener = listener;
}

async listTools(): Promise<MCPToolDefinition[]> {
const result = await this.client.listTools();
return result.tools.map(toMcpToolDefinition);
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-core/src/mcp/client-shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCoreVersion } from '#/version';
import type { ClientOptions } from '@modelcontextprotocol/sdk/client/index.js';

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

Expand Down Expand Up @@ -26,6 +27,11 @@ export interface UnexpectedCloseReason {

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

export type ToolsChangedListener = (
error: Error | null,
tools: MCPToolDefinition[] | null,
) => void;

export interface McpRequestOptions {
readonly timeout?: number;
readonly signal?: AbortSignal;
Expand All @@ -45,6 +51,22 @@ export function buildRequestOptions(
return { timeout: toolCallTimeoutMs, signal };
}

export function buildListChangedClientOptions(
getToolsChangedListener: () => ToolsChangedListener | undefined,
): Pick<ClientOptions, 'listChanged'> {
return {
listChanged: {
tools: {
onChanged: (error, tools) => {
const listener = getToolsChangedListener();
if (listener === undefined) return;
listener(error, tools?.map(toMcpToolDefinition) ?? null);
},
},
},
};
}

interface SdkListedTool {
readonly name: string;
readonly description?: string;
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-core/src/mcp/client-sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.
import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';

import {
buildListChangedClientOptions,
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
toMcpToolDefinition,
toMcpToolResult,
type ToolsChangedListener,
type UnexpectedCloseListener,
type UnexpectedCloseReason,
} from './client-shared';
Expand Down Expand Up @@ -52,6 +54,7 @@ export class SseMcpClient implements MCPClient {
private ready = false;
private hooksInstalled = false;
private unexpectedCloseListener: UnexpectedCloseListener | undefined;
private toolsChangedListener: ToolsChangedListener | undefined;
private lastTransportError: Error | undefined;
private pendingUnexpectedClose: UnexpectedCloseReason | undefined;
private unexpectedCloseFired = false;
Expand All @@ -68,7 +71,7 @@ export class SseMcpClient implements MCPClient {
this.client = new Client({
name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
});
}, buildListChangedClientOptions(() => this.toolsChangedListener));
this.toolCallTimeoutMs = options.toolCallTimeoutMs;
}

Expand Down Expand Up @@ -112,6 +115,10 @@ export class SseMcpClient implements MCPClient {
}
}

onToolsChanged(listener: ToolsChangedListener): void {
this.toolsChangedListener = listener;
}

async listTools(): Promise<MCPToolDefinition[]> {
const result = await this.client.listTools();
return result.tools.map(toMcpToolDefinition);
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-core/src/mcp/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { isAbsolute, resolve } from 'pathe';

import {
buildListChangedClientOptions,
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
toMcpToolDefinition,
toMcpToolResult,
type ToolsChangedListener,
type UnexpectedCloseListener,
type UnexpectedCloseReason,
} from './client-shared';
Expand Down Expand Up @@ -46,6 +48,7 @@ export class StdioMcpClient implements MCPClient {
private ready = false;
private hooksInstalled = false;
private unexpectedCloseListener: UnexpectedCloseListener | undefined;
private toolsChangedListener: ToolsChangedListener | undefined;
private lastTransportError: Error | undefined;
// Buffered when the transport closes before a listener is installed (e.g.
// a server that exits seconds after answering `tools/list`). Replayed when
Expand Down Expand Up @@ -76,7 +79,7 @@ export class StdioMcpClient implements MCPClient {
this.client = new Client({
name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
});
}, buildListChangedClientOptions(() => this.toolsChangedListener));
this.toolCallTimeoutMs = options.toolCallTimeoutMs;
}

Expand Down Expand Up @@ -128,6 +131,10 @@ export class StdioMcpClient implements MCPClient {
}
}

onToolsChanged(listener: ToolsChangedListener): void {
this.toolsChangedListener = listener;
}

/**
* Returns the tail of bytes captured from the child's stderr since spawn.
* Bounded by {@link StdioMcpClient.stderrBufferCapacity} so a noisy server
Expand Down
60 changes: 58 additions & 2 deletions packages/agent-core/src/mcp/connection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import { abortable } from '../utils/abort';
import { HttpMcpClient } from './client-http';
import { isRemoteMcpConfig } from './client-remote';
import { SseMcpClient } from './client-sse';
import type { UnexpectedCloseReason } from './client-shared';
import type { ToolsChangedListener, UnexpectedCloseReason } from './client-shared';
import { StdioMcpClient } from './client-stdio';
import type { McpOAuthService } from './oauth';
import { assertMcpInputSchema, type MCPClient } from './types';
import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types';

export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';

Expand Down Expand Up @@ -263,6 +263,7 @@ export class McpConnectionManager {
const startupClient = this.createClient(entry.config, entry.name);
client = startupClient;
entry.client = startupClient;
this.watchForToolsChanged(entry, startupClient, attemptId);
const tools = await withTimeout(
this.connectAndDiscoverTools(startupClient),
timeoutMs,
Expand Down Expand Up @@ -324,6 +325,57 @@ export class McpConnectionManager {
});
}

private watchForToolsChanged(
entry: InternalEntry,
client: RuntimeMcpClient,
attemptId: number,
): void {
const listener: ToolsChangedListener = (error, mcpTools) => {
this.handleToolsChanged(entry, client, attemptId, error, mcpTools);
};
client.onToolsChanged(listener);
}

private handleToolsChanged(
entry: InternalEntry,
client: RuntimeMcpClient,
attemptId: number,
error: Error | null,
mcpTools: MCPToolDefinition[] | null,
): void {
if (!this.isCurrent(entry, attemptId)) return;
if (entry.client !== client) return;
if (entry.status !== 'connected') return;
if (error !== null) {
this.log.warn('mcp tools refresh failed', {
server: entry.name,
transport: entry.config.transport,
error,
});
return;
}
if (mcpTools === null) return;

let tools: Tool[];
try {
tools = this.mcpToolsToTools(mcpTools);
} catch (refreshError) {
this.log.warn('mcp tools refresh failed', {
server: entry.name,
transport: entry.config.transport,
error: refreshError,
});
return;
}
if (!this.isCurrent(entry, attemptId)) return;
if (entry.client !== client) return;
if (entry.status !== 'connected') return;
entry.tools = tools;
entry.enabledNames = computeEnabledNames(entry.config, tools);
entry.error = undefined;
this.emit(entry);
}

private beginConnectAttempt(entry: InternalEntry): number {
entry.attemptId += 1;
return entry.attemptId;
Expand Down Expand Up @@ -379,6 +431,10 @@ export class McpConnectionManager {
private async connectAndDiscoverTools(client: RuntimeMcpClient): Promise<Tool[]> {
await client.connect();
const mcpTools = await client.listTools();
return this.mcpToolsToTools(mcpTools);
}

private mcpToolsToTools(mcpTools: readonly MCPToolDefinition[]): Tool[] {
return mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
Expand Down
Loading