mcp suport sse (#744)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions

Co-authored-by: yuchengzhen <yuchengzhen@moonshot.cn>
This commit is contained in:
oocz 2026-06-14 18:04:26 +08:00 committed by GitHub
parent 93928066dc
commit 18f299fd0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 587 additions and 86 deletions

View file

@ -0,0 +1,8 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/acp-adapter": minor
"@moonshot-ai/protocol": minor
"@moonshot-ai/kimi-code": minor
---
Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports.

View file

@ -539,8 +539,8 @@ function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] {
function mcpServerDescription(server: PluginMcpServerInfo): string {
const action = server.enabled ? 'Enter/Space disable' : 'Enter/Space enable';
if (server.transport === 'http') {
return `${action} · HTTP · ${server.url ?? server.runtimeName}`;
if (server.transport === 'http' || server.transport === 'sse') {
return `${action} · ${server.transport.toUpperCase()} · ${server.url ?? server.runtimeName}`;
}
const args = server.args !== undefined && server.args.length > 0 ? ` ${server.args.join(' ')}` : '';
const command = `${server.command ?? ''}${args}`.trim();

View file

@ -742,7 +742,7 @@ command = "vim"
let resolveSnapshot: (
servers: Array<{
name: string;
transport: 'stdio' | 'http';
transport: 'stdio' | 'http' | 'sse';
status: 'pending' | 'connected' | 'failed' | 'disabled';
toolCount: number;
error?: string;

View file

@ -4,10 +4,11 @@
## Connection Methods
Kimi Code CLI supports two MCP server connection methods:
Kimi Code CLI supports three MCP server connection methods:
- **stdio**: The CLI starts the local MCP server as a child process and communicates via standard input/output. Suitable for local command-line tools.
- **HTTP**: The CLI connects to an already-running HTTP endpoint. Suitable for remote services or processes that need to run persistently.
- **SSE**: The CLI connects to a legacy HTTP+SSE endpoint (Server-Sent Events, a streaming HTTP mechanism). Prefer HTTP for new MCP servers, but use `transport: "sse"` when a service still exposes only the older SSE transport.
## Configuration
@ -31,12 +32,16 @@ Structure of `mcp.json`:
},
"linear": {
"url": "https://mcp.linear.app/mcp"
},
"legacy-events": {
"transport": "sse",
"url": "https://mcp.example.com/sse"
}
}
}
```
Entries with a `command` field are stdio servers; entries with a `url` field are HTTP servers. The `transport` field generally does not need to be written manually.
Entries with a `command` field are stdio servers; entries with a `url` field and no `transport` are HTTP servers. For legacy SSE servers, set `transport` to `"sse"` explicitly.
Optional fields:
@ -44,14 +49,15 @@ Optional fields:
| --- | --- | --- | --- |
| `env` | `Record<string, string>` | stdio | Environment variables injected into the child process |
| `cwd` | `string` | stdio | Working directory for the child process |
| `headers` | `Record<string, string>` | HTTP | Static request headers appended to every request |
| `enabled` | `boolean` | Both | Set to `false` to disable this server |
| `startupTimeoutMs` | `number` | Both | Connection timeout; default `30000` milliseconds |
| `toolTimeoutMs` | `number` | Both | Timeout for a single tool call |
| `enabledTools` | `string[]` | Both | Tool allowlist |
| `disabledTools` | `string[]` | Both | Tool blocklist |
| `headers` | `Record<string, string>` | HTTP, SSE | Static request headers appended to every request |
| `bearerTokenEnvVar` | `string` | HTTP, SSE | Name of an environment variable that contains a bearer token |
| `enabled` | `boolean` | All | Set to `false` to disable this server |
| `startupTimeoutMs` | `number` | All | Connection timeout; default `30000` milliseconds |
| `toolTimeoutMs` | `number` | All | Timeout for a single tool call |
| `enabledTools` | `string[]` | All | Tool allowlist |
| `disabledTools` | `string[]` | All | Tool blocklist |
HTTP servers support providing static credentials via `headers` or `bearerTokenEnvVar`. When OAuth is needed, run `/mcp-config login <server-name>` to complete browser-based authorization.
HTTP and SSE servers support providing static credentials via `headers` or `bearerTokenEnvVar`. When OAuth is needed, run `/mcp-config login <server-name>` to complete browser-based authorization.
Plugins can also declare MCP servers in their manifest. Servers declared by a plugin are enabled by default and can be disabled or re-enabled in `/plugins`, then a new session must be started. See [Plugins](./plugins.md) for details.

View file

@ -88,7 +88,7 @@ Paseo's generic ACP adapter does not drive the login flow, so complete the termi
- **Session disconnects immediately / IDE shows "agent exited"**: usually a wrong `command` path or a missing login. Run `kimi acp` in a terminal first to verify — if it blocks waiting for stdin, the CLI itself is fine and the problem is in the IDE configuration; if it exits immediately with an error, follow the error message (most commonly you need to run `/login`).
- **IDE shows "auth required"**: the CLI has no usable authentication token. Exit the IDE, run `kimi` in a terminal to complete login, then restart the IDE.
- **MCP tools not visible**: check the [`kimi acp` reference](../reference/kimi-acp.md) capability table to confirm that the MCP transport type configured in your IDE is supported. The Kimi Code CLI ACP adapter currently supports `http` and `stdio` transports; `sse` and `acp` types are silently dropped and a warning is written to the log.
- **MCP tools not visible**: check the [`kimi acp` reference](../reference/kimi-acp.md) capability table to confirm that the MCP transport type configured in your IDE is supported. The Kimi Code CLI ACP adapter currently supports `http`, `stdio`, and `sse` transports; `acp` transport MCP servers are silently dropped and a warning is written to the log.
## Next steps

View file

@ -22,7 +22,7 @@ The table below lists the capabilities declared by the current ACP adapter layer
| `promptCapabilities.audio` | `false` | Audio prompts not yet supported |
| `promptCapabilities.embeddedContext` | `true` | Client may send `resource`/`resource_link` embedded resource blocks; text content is injected into the prompt as `<resource uri="...">...</resource>`; blob resources are dropped with a warn |
| `mcpCapabilities.http` | `true` | Forwards HTTP MCP services configured by the IDE |
| `mcpCapabilities.sse` | `false` | SSE MCP services not supported; matching entries are discarded and a warn is logged |
| `mcpCapabilities.sse` | `true` | Forwards legacy SSE MCP services configured by the IDE |
| `loadSession` | `true` | Supports `session/load` to resume an existing session, replaying history on load |
| `sessionCapabilities.list` | `{}` | Supports `session/list` to enumerate the current user's sessions |
@ -74,7 +74,8 @@ When an ACP client provides `mcpServers` in `session/new` or `session/load`, the
- `http` → kimi's `transport: 'http'` configuration
- `stdio` → kimi's `transport: 'stdio'` configuration
- `sse` / `acp` → discarded with a warn log entry
- `sse` → kimi's `transport: 'sse'` configuration
- `acp` → discarded with a warn log entry
## Next steps

View file

@ -4,10 +4,11 @@
## 接入方式
Kimi Code CLI 支持种 MCP server 接入方式:
Kimi Code CLI 支持种 MCP server 接入方式:
- **stdio**CLI 以子进程方式启动本地 MCP server通过标准输入输出通信。适合本地命令行工具。
- **HTTP**CLI 连接一个已在运行的 HTTP 端点。适合远程服务或需要持久运行的进程。
- **SSE**CLI 连接旧式 HTTP+SSE 端点Server-Sent Events一种流式 HTTP 机制)。新 MCP server 优先使用 HTTP只有服务仍仅暴露旧式 SSE 传输时,才设置 `transport: "sse"`
## 配置
@ -31,12 +32,16 @@ MCP server 配置写在 `mcp.json` 中,分两层:
},
"linear": {
"url": "https://mcp.linear.app/mcp"
},
"legacy-events": {
"transport": "sse",
"url": "https://mcp.example.com/sse"
}
}
}
```
`command` 字段的条目为 stdio server,含 `url` 字段的条目为 HTTP server通常不需要手写 `transport` 字段
`command` 字段的条目为 stdio server;含 `url` 字段且未写 `transport` 的条目为 HTTP server。旧式 SSE server 需要显式把 `transport` 设为 `"sse"`
可选字段:
@ -44,14 +49,15 @@ MCP server 配置写在 `mcp.json` 中,分两层:
| --- | --- | --- | --- |
| `env` | `Record<string, string>` | stdio | 注入子进程的环境变量 |
| `cwd` | `string` | stdio | 子进程工作目录 |
| `headers` | `Record<string, string>` | HTTP | 附加到每次请求的静态请求头 |
| `enabled` | `boolean` | 两者 | 设为 `false` 可禁用该 server |
| `startupTimeoutMs` | `number` | 两者 | 连接超时,默认 `30000` 毫秒 |
| `toolTimeoutMs` | `number` | 两者 | 单次工具调用超时 |
| `enabledTools` | `string[]` | 两者 | 工具白名单 |
| `disabledTools` | `string[]` | 两者 | 工具黑名单 |
| `headers` | `Record<string, string>` | HTTP、SSE | 附加到每次请求的静态请求头 |
| `bearerTokenEnvVar` | `string` | HTTP、SSE | 存放 bearer token 的环境变量名 |
| `enabled` | `boolean` | 全部 | 设为 `false` 可禁用该 server |
| `startupTimeoutMs` | `number` | 全部 | 连接超时,默认 `30000` 毫秒 |
| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时 |
| `enabledTools` | `string[]` | 全部 | 工具白名单 |
| `disabledTools` | `string[]` | 全部 | 工具黑名单 |
HTTP server 支持通过 `headers``bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login <server-name>` 完成浏览器授权。
HTTP 与 SSE server 支持通过 `headers``bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login <server-name>` 完成浏览器授权。
Plugins 也可以在 manifest 中声明 MCP servers。Plugin 声明的 servers 默认启用,可以在 `/plugins` 中禁用或重新启用,然后开启新会话。详见 [Plugins](./plugins.md)。

View file

@ -88,7 +88,7 @@ Paseo 的通用 ACP 适配层不会帮你走登录流程,所以请先完成终
- **会话立刻被中断 / IDE 提示 "agent exited"**:通常是 `command` 路径不对或 kimi 没登录。先在终端跑一次 `kimi acp` 验证:如果阻塞等待标准输入则说明 CLI 本身没问题,问题在 IDE 配置;如果立刻报错则按报错提示处理(多数是没 `/login`)。
- **IDE 显示 "auth required"**:表示 CLI 没有可用的鉴权令牌。退出 IDE在终端执行 `kimi` 完成登录后再启动 IDE 即可。
- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http``stdio` 两种传输方式,`sse``acp` 类型会被静默丢弃并在日志中给出 warn。
- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http``stdio` `sse` 三种传输方式;`acp` 传输的 MCP server 会被静默丢弃并在日志中给出 warn。
## 下一步

View file

@ -22,7 +22,7 @@ kimi acp
| `promptCapabilities.audio` | `false` | 暂不支持音频 prompt |
| `promptCapabilities.embeddedContext` | `true` | 客户端可发送 `resource`/`resource_link` 嵌入式资源块,文本内容会以 `<resource uri="...">...</resource>` 形式注入 promptblob 资源被丢弃并写 warn |
| `mcpCapabilities.http` | `true` | 转发 IDE 配置的 HTTP MCP 服务 |
| `mcpCapabilities.sse` | `false` | 不支持 SSE MCP 服务,相关条目会被丢弃并写 warn 日志 |
| `mcpCapabilities.sse` | `true` | 转发 IDE 配置的旧式 SSE MCP 服务 |
| `loadSession` | `true` | 支持 `session/load` 续接已有会话,加载时会同步回放历史 |
| `sessionCapabilities.list` | `{}` | 支持 `session/list` 枚举当前用户的会话 |
@ -74,7 +74,8 @@ ACP 客户端在 `session/new` 或 `session/load` 中提供 `mcpServers` 时,
- `http` → kimi 的 `transport: 'http'` 配置
- `stdio` → kimi 的 `transport: 'stdio'` 配置
- `sse` / `acp` → 丢弃并写一条 warn 日志
- `sse` → kimi 的 `transport: 'sse'` 配置
- `acp` → 丢弃并写一条 warn 日志
## 下一步

View file

@ -10,9 +10,8 @@
*
* - `http` kimi `transport: 'http'` with headers projected from
* `Array<{name, value}>` to `Record<string, string>`.
* - `sse` kimi `transport: 'sse'` with headers projected the same way.
* - `stdio` kimi `transport: 'stdio'` with env projected similarly.
* - `sse` dropped with a `log.warn` (PLAN D3 declares
* `mcp_capabilities: sse=false`).
* - `acp` dropped with a `log.warn` (experimental ACP-transport MCP
* is not yet supported).
*
@ -33,7 +32,7 @@ import { log } from '@moonshot-ai/kimi-code-sdk';
/**
* Convert an ACP `McpServer[]` into the kernel-native
* `Record<string, McpServerConfig>` keyed by server name. Unsupported
* transports (`sse`, `acp`) are warn-dropped the caller never has to
* transports (`acp`) are warn-dropped the caller never has to
* filter them out.
*
* Caveat (ACP schema 0.23): the `McpServer` union types stdio as a
@ -79,7 +78,14 @@ function acpMcpServerToConfig(
};
return { name: server.name, config };
}
case 'sse':
case 'sse': {
const config: McpServerConfig = {
transport: 'sse',
url: server.url,
headers: headersArrayToRecord(server.headers),
};
return { name: server.name, config };
}
case 'acp':
default: {
// Defensive: future ACP transports land here too. The cast is the

View file

@ -224,7 +224,7 @@ export class AcpServer implements Agent {
},
mcpCapabilities: {
http: true,
sse: false,
sse: true,
},
sessionCapabilities: {
list: {},
@ -255,7 +255,7 @@ export class AcpServer implements Agent {
// similar fields are wired in Phase 8 (per PLAN D3) — Phase 3.2 keeps
// the surface minimal. Phase 10.1 adds `mcpServers` forwarding so
// ACP-supplied servers (Zed config, JetBrains config) are passed
// alongside the on-disk config; unsupported transports (sse/acp)
// alongside the on-disk config; unsupported ACP-transport servers
// are warn-dropped inside the conversion. `mcpServers` is NOT a
// declared field on `CreateSessionOptions` — the SDK is a
// transparent passthrough for unknown fields (see

View file

@ -10,7 +10,7 @@
*
* 1. `initialize` returns the documented capability matrix
* (PLAN D4: image=true, audio=false, embeddedContext=true,
* mcp.http=true, mcp.sse=false, loadSession=true,
* mcp.http=true, mcp.sse=true, loadSession=true,
* sessionCapabilities.list={}).
* 2. `session/new` returns a non-empty sessionId.
* 3. `session/prompt` streams at least one `agent_message_chunk`
@ -171,7 +171,7 @@ describe('AcpServer end-to-end happy path', () => {
},
mcpCapabilities: {
http: true,
sse: false,
sse: true,
},
sessionCapabilities: {
list: {},

View file

@ -192,16 +192,18 @@ describe('acpMcpServersToConfigs', () => {
expect(warnSpy).not.toHaveBeenCalled();
});
it('warn-drops sse servers (PLAN D3 — sse capability is false)', () => {
it('converts an SSE server with headers to a Record keyed by name', () => {
const out = acpMcpServersToConfigs([
sseServer('events', 'https://stream.example.com', [{ name: 'X-K', value: 'V' }]),
]);
expect(out).toEqual({});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
'acp: dropping unsupported MCP server transport',
expect.objectContaining({ name: 'events', type: 'sse' }),
);
expect(out).toEqual({
events: {
transport: 'sse',
url: 'https://stream.example.com',
headers: { 'X-K': 'V' },
},
});
expect(warnSpy).not.toHaveBeenCalled();
});
it('warn-drops acp servers (experimental, not supported)', () => {
@ -218,10 +220,12 @@ describe('acpMcpServersToConfigs', () => {
const out = acpMcpServersToConfigs([
httpServer('docs', 'https://h', [{ name: 'X', value: 'v' }]),
sseServer('events', 'https://s', [{ name: 'X', value: 'v' }]),
acpServer('inner', 'opaque-id'),
stdioServer('fs', '/bin/fs', [], []),
]);
expect(Object.keys(out)).toEqual(['docs', 'fs']);
expect(Object.keys(out)).toEqual(['docs', 'events', 'fs']);
expect(out['docs']).toMatchObject({ transport: 'http' });
expect(out['events']).toMatchObject({ transport: 'sse' });
expect(out['fs']).toMatchObject({ transport: 'stdio' });
expect(warnSpy).toHaveBeenCalledTimes(1);
});
@ -261,6 +265,11 @@ describe('AcpServer session/new MCP forwarding', () => {
url: 'https://mcp.example.com',
headers: { Auth: 'tok' },
},
events: {
transport: 'sse',
url: 'https://s',
headers: { X: 'v' },
},
});
void _agentConn;
});

View file

@ -78,7 +78,7 @@ describe('AcpServer + AgentSideConnection', () => {
expect(response.agentCapabilities?.promptCapabilities?.audio).toBe(false);
expect(response.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true);
expect(response.agentCapabilities?.mcpCapabilities?.http).toBe(true);
expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(false);
expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(true);
expect(response.agentCapabilities?.sessionCapabilities?.list).toEqual({});
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
});

View file

@ -238,10 +238,10 @@ export class ToolManager {
// server flipping to needs-auth means previous tokens were invalidated.
this.unregisterMcpServer(entry.name);
const oauthService = mcp.oauthService;
const serverUrl = mcp.getHttpServerUrl(entry.name);
const serverUrl = mcp.getRemoteServerUrl(entry.name);
if (oauthService === undefined || serverUrl === undefined) {
// Misconfiguration: a server reached needs-auth without the manager
// owning an OAuth service or being HTTP. Treat it as a no-op so the
// owning an OAuth service or being remote. Treat it as a no-op so the
// existing failure error message keeps the user informed.
return;
}

View file

@ -169,9 +169,24 @@ export const McpServerHttpConfigSchema = z.object({
export type McpServerHttpConfig = z.infer<typeof McpServerHttpConfigSchema>;
export const McpServerSseConfigSchema = z.object({
transport: z.literal('sse'),
url: z.string().url(),
headers: StringRecordSchema.optional(),
// Indirect secret reference: the bearer token is looked up from
// `process.env[bearerTokenEnvVar]` at connection time, never committed.
bearerTokenEnvVar: z.string().min(1).optional(),
...McpServerCommonFields,
});
export type McpServerSseConfig = z.infer<typeof McpServerSseConfigSchema>;
export type McpRemoteServerConfig = McpServerHttpConfig | McpServerSseConfig;
const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [
McpServerStdioConfigSchema,
McpServerHttpConfigSchema,
McpServerSseConfigSchema,
]);
export const McpServerConfigSchema = z.preprocess((raw) => {

View file

@ -1,7 +1,7 @@
/**
* Synthetic `mcp__<server>__authenticate` tool.
*
* When an MCP HTTP server lands in the `needs-auth` state i.e. its
* When a remote MCP server lands in the `needs-auth` state i.e. its
* initial connection failed with a 401 / `UnauthorizedError` and no static
* bearer token is configured the {@link ToolManager} swaps the real MCP
* tool list for this single tool. Calling it:

View file

@ -1,4 +1,3 @@
import { ErrorCodes, KimiError } from '#/errors';
import type { McpServerHttpConfig } from '#/config/schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
@ -13,6 +12,7 @@ import {
type UnexpectedCloseListener,
type UnexpectedCloseReason,
} from './client-shared';
import { buildMcpRemoteHeaders } from './client-remote';
import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';
export interface HttpMcpClientOptions {
@ -211,21 +211,5 @@ export function buildMcpHttpHeaders(
config: McpServerHttpConfig,
envLookup: (name: string) => string | undefined,
): Record<string, string> | undefined {
const headers: Record<string, string> = { ...config.headers };
if (config.bearerTokenEnvVar !== undefined) {
const token = envLookup(config.bearerTokenEnvVar);
if (token === undefined || token.length === 0) {
throw new KimiError(ErrorCodes.CONFIG_INVALID, `MCP HTTP bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`);
}
// Strip any case-variant 'authorization' static header before injecting the
// bearer; Fetch Headers folds duplicate keys into a comma-joined value,
// which produces an invalid auth header rather than letting the bearer win.
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'authorization') {
delete headers[key];
}
}
headers['Authorization'] = `Bearer ${token}`;
}
return Object.keys(headers).length > 0 ? headers : undefined;
return buildMcpRemoteHeaders(config, envLookup);
}

View file

@ -0,0 +1,32 @@
import type { McpRemoteServerConfig, McpServerConfig } from '#/config/schema';
import { ErrorCodes, KimiError } from '#/errors';
export function buildMcpRemoteHeaders(
config: McpRemoteServerConfig,
envLookup: (name: string) => string | undefined,
): Record<string, string> | undefined {
const headers: Record<string, string> = { ...config.headers };
if (config.bearerTokenEnvVar !== undefined) {
const token = envLookup(config.bearerTokenEnvVar);
if (token === undefined || token.length === 0) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`MCP ${config.transport.toUpperCase()} bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`,
);
}
// Strip any case-variant 'authorization' static header before injecting the
// bearer; Fetch Headers folds duplicate keys into a comma-joined value,
// which produces an invalid auth header rather than letting the bearer win.
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'authorization') {
delete headers[key];
}
}
headers['Authorization'] = `Bearer ${token}`;
}
return Object.keys(headers).length > 0 ? headers : undefined;
}
export function isRemoteMcpConfig(config: McpServerConfig): config is McpRemoteServerConfig {
return config.transport === 'http' || config.transport === 'sse';
}

View file

@ -0,0 +1,169 @@
import type { McpServerSseConfig } from '#/config/schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js';
import {
buildRequestOptions,
KIMI_MCP_CLIENT_NAME,
KIMI_MCP_CLIENT_VERSION,
toMcpToolDefinition,
toMcpToolResult,
type UnexpectedCloseListener,
type UnexpectedCloseReason,
} from './client-shared';
import { buildMcpRemoteHeaders } from './client-remote';
import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types';
export interface SseMcpClientOptions {
readonly clientName?: string;
readonly clientVersion?: string;
readonly toolCallTimeoutMs?: number;
/**
* Reads `process.env[name]` by default. Tests can inject a deterministic
* lookup function so they do not have to mutate global env.
*/
readonly envLookup?: (name: string) => string | undefined;
/**
* Lets tests inject a fake `fetch` for the underlying transport.
*/
readonly fetch?: typeof fetch;
/**
* OAuth client provider attached to the transport. Set only when the server
* has no static token configuration; the connection manager wires this in
* and surfaces `UnauthorizedError` as a `needs-auth` status.
*/
readonly oauthProvider?: OAuthClientProvider;
}
/**
* Wraps the SDK's deprecated HTTP+SSE transport as a kosong
* {@link MCPClient}. This exists for compatibility with older MCP servers;
* new remote servers should prefer streamable HTTP.
*/
export class SseMcpClient implements MCPClient {
private readonly client: Client;
private readonly transport: SSEClientTransport;
private readonly toolCallTimeoutMs?: number;
private started = false;
private closed = false;
// Mirrors HttpMcpClient: handshake failures surface through connect(), while
// post-ready terminal transport errors become unexpected closes.
private ready = false;
private hooksInstalled = false;
private unexpectedCloseListener: UnexpectedCloseListener | undefined;
private lastTransportError: Error | undefined;
private pendingUnexpectedClose: UnexpectedCloseReason | undefined;
private unexpectedCloseFired = false;
constructor(config: McpServerSseConfig, options: SseMcpClientOptions = {}) {
const envLookup = options.envLookup ?? ((name) => process.env[name]);
const headers = buildMcpRemoteHeaders(config, envLookup);
this.transport = new SSEClientTransport(new URL(config.url), {
requestInit: headers !== undefined ? { headers } : undefined,
fetch: options.fetch,
authProvider: options.oauthProvider,
});
this.client = new Client({
name: options.clientName ?? KIMI_MCP_CLIENT_NAME,
version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION,
});
this.toolCallTimeoutMs = options.toolCallTimeoutMs;
}
async connect(): Promise<void> {
if (this.closed) {
throw new Error('MCP SSE client is closed');
}
if (this.started) return;
this.started = true;
this.installTransportHooks();
try {
await this.client.connect(this.transport);
} catch (error) {
await this.closeStartedClient();
throw error;
}
if (this.closed) {
await this.closeStartedClient();
throw new Error('MCP SSE client was closed during startup');
}
this.ready = true;
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
await this.closeStartedClient();
}
/**
* Register a listener for unsolicited terminal transport drops. Brief SSE
* stream flaps are left to EventSource's retry loop; terminal HTTP status
* errors after startup remove the tools from the agent.
*/
onUnexpectedClose(listener: UnexpectedCloseListener): void {
this.unexpectedCloseListener = listener;
const pending = this.pendingUnexpectedClose;
if (pending !== undefined) {
this.pendingUnexpectedClose = undefined;
listener(pending);
}
}
async listTools(): Promise<MCPToolDefinition[]> {
const result = await this.client.listTools();
return result.tools.map(toMcpToolDefinition);
}
async callTool(
name: string,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<MCPToolResult> {
const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal);
const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions);
return toMcpToolResult(result);
}
private async closeStartedClient(): Promise<void> {
if (!this.started) return;
this.started = false;
await this.client.close();
}
private installTransportHooks(): void {
if (this.hooksInstalled) return;
this.hooksInstalled = true;
this.client.onclose = () => {
if (this.closed) return;
if (!this.ready) return;
this.fireUnexpectedClose({ error: this.lastTransportError });
};
this.client.onerror = (error) => {
this.lastTransportError = error;
if (this.closed) return;
if (!this.ready) return;
if (isTerminalSseTransportError(error)) {
this.fireUnexpectedClose({ error });
}
};
}
private fireUnexpectedClose(reason: UnexpectedCloseReason): void {
if (this.unexpectedCloseFired) return;
this.unexpectedCloseFired = true;
const listener = this.unexpectedCloseListener;
if (listener !== undefined) {
listener(reason);
} else {
this.pendingUnexpectedClose = reason;
}
}
}
export function isTerminalSseTransportError(error: Error): boolean {
if (error.name === 'UnauthorizedError') return true;
return error instanceof SseError && error.code !== undefined;
}

View file

@ -6,6 +6,8 @@ import type { Tool } from '@moonshot-ai/kosong';
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 { StdioMcpClient } from './client-stdio';
import type { McpOAuthService } from './oauth';
@ -15,7 +17,7 @@ export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' |
export interface McpServerEntry {
readonly name: string;
readonly transport: 'stdio' | 'http';
readonly transport: McpServerConfig['transport'];
readonly status: McpServerStatus;
readonly toolCount: number;
readonly error?: string;
@ -36,12 +38,12 @@ export type McpStatusListener = (entry: McpServerEntry) => void;
const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
type RuntimeMcpClient = StdioMcpClient | HttpMcpClient;
type RuntimeMcpClient = StdioMcpClient | HttpMcpClient | SseMcpClient;
export interface McpConnectionManagerOptions {
readonly envLookup?: (name: string) => string | undefined;
/**
* Optional OAuth orchestrator. When provided, HTTP servers without a
* Optional OAuth orchestrator. When provided, remote servers without a
* static bearer token participate in the OAuth-via-synthetic-tool flow:
* - If `oauthService.hasTokens(name, url)` is true, the provider is
* attached to the transport so the SDK can refresh tokens on 401.
@ -88,17 +90,25 @@ export class McpConnectionManager {
}
/**
* Returns the URL of an HTTP MCP server by name, or `undefined` for
* unknown / non-HTTP / disabled entries. Used by the synthetic auth tool
* Returns the URL of a remote MCP server by name, or `undefined` for
* unknown / non-remote / disabled entries. Used by the synthetic auth tool
* to drive OAuth discovery against the right base URL.
*/
getHttpServerUrl(name: string): string | undefined {
getRemoteServerUrl(name: string): string | undefined {
const entry = this.entries.get(name);
if (entry === undefined) return undefined;
if (entry.config.transport !== 'http') return undefined;
if (!isRemoteMcpConfig(entry.config)) return undefined;
return entry.config.url;
}
/**
* @deprecated Use {@link getRemoteServerUrl}. Kept for in-repo callers that
* were written before legacy SSE support shared the same OAuth path.
*/
getHttpServerUrl(name: string): string | undefined {
return this.getRemoteServerUrl(name);
}
onStatusChange(listener: McpStatusListener): () => void {
this.listeners.add(listener);
return () => {
@ -323,6 +333,13 @@ export class McpConnectionManager {
if (config.transport === 'stdio') {
return new StdioMcpClient(config, { toolCallTimeoutMs });
}
if (config.transport === 'sse') {
return new SseMcpClient(config, {
toolCallTimeoutMs,
envLookup: this.options.envLookup,
oauthProvider: this.resolveOAuthProvider(config, name),
});
}
return new HttpMcpClient(config, {
toolCallTimeoutMs,
envLookup: this.options.envLookup,
@ -336,7 +353,7 @@ export class McpConnectionManager {
): ReturnType<McpOAuthService['getProvider']> | undefined {
const oauthService = this.oauthService;
if (oauthService === undefined) return undefined;
if (config.transport !== 'http') return undefined;
if (!isRemoteMcpConfig(config)) return undefined;
if (config.bearerTokenEnvVar !== undefined) return undefined;
// Only attach the provider once tokens have been minted; before that,
// the transport should propagate a clean 401 so we can flip the entry
@ -348,7 +365,7 @@ export class McpConnectionManager {
private shouldMarkNeedsAuth(entry: InternalEntry, error: unknown): boolean {
if (this.oauthService === undefined) return false;
if (entry.config.transport !== 'http') return false;
if (!isRemoteMcpConfig(entry.config)) return false;
if (entry.config.bearerTokenEnvVar !== undefined) return false;
// If the user pinned a static `headers` block, treat 401s as a bad header
// rather than hijacking them into the OAuth flow — the real error is more

View file

@ -417,12 +417,12 @@ function pluginMcpServerInfo(
name: string,
config: McpServerConfig,
): PluginMcpServerInfo {
if (config.transport === 'http') {
if (config.transport === 'http' || config.transport === 'sse') {
return {
name,
runtimeName: pluginMcpRuntimeName(record.id, name),
enabled: isMcpServerEnabled(record, name, config),
transport: 'http',
transport: config.transport,
url: config.url,
headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(),
};
@ -454,7 +454,7 @@ function withPluginMcpRuntime(
pluginRoot: string,
kimiHomeDir: string,
): McpServerConfig {
if (config.transport === 'http') return config;
if (config.transport === 'http' || config.transport === 'sse') return config;
const env = {
...config.env,

View file

@ -291,7 +291,7 @@ async function normalizePluginMcpServer(input: {
readonly diagnostics: PluginDiagnostic[];
}): Promise<McpServerConfig | undefined> {
const { config } = input;
if (config.transport === 'http') return config;
if (config.transport === 'http' || config.transport === 'sse') return config;
let command = config.command;
if (command.startsWith('./')) {

View file

@ -51,7 +51,7 @@ export interface PluginMcpServerInfo {
readonly name: string;
readonly runtimeName: string;
readonly enabled: boolean;
readonly transport: 'stdio' | 'http';
readonly transport: 'stdio' | 'http' | 'sse';
readonly command?: string;
readonly args?: readonly string[];
readonly cwd?: string;

View file

@ -223,7 +223,7 @@ export interface ActivateSkillPayload {
export interface McpServerInfo {
readonly name: string;
readonly transport: 'stdio' | 'http';
readonly transport: 'stdio' | 'http' | 'sse';
readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
readonly toolCount: number;
readonly error?: string;

View file

@ -0,0 +1,142 @@
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { SseError } from '@modelcontextprotocol/sdk/client/sse.js';
import { afterEach, describe, expect, it } from 'vitest';
import { z } from 'zod';
import { SseMcpClient, isTerminalSseTransportError } from '../../src/mcp/client-sse';
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) {
await cleanup();
}
});
async function startInProcessSseMcpServer(opts?: {
authToken?: string;
}): Promise<{ url: string; close: () => Promise<void> }> {
const transports = new Map<string, SSEServerTransport>();
const httpServer: Server = createServer((req, res) => {
if (opts?.authToken !== undefined) {
const auth = req.headers['authorization'];
if (auth !== `Bearer ${opts.authToken}`) {
res.writeHead(401, { 'content-type': 'text/plain' });
res.end('unauthorized');
return;
}
}
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
if (req.method === 'GET' && url.pathname === '/mcp') {
const mcpServer = new McpServer({ name: 'mock-sse', version: '0.0.1' });
mcpServer.registerTool(
'echo',
{ description: 'Echoes text', inputSchema: { text: z.string() } },
({ text }) => ({ content: [{ type: 'text', text }] }),
);
const transport = new SSEServerTransport('/messages', res);
transports.set(transport.sessionId, transport);
transport.onclose = () => {
transports.delete(transport.sessionId);
};
void mcpServer.connect(transport);
return;
}
if (req.method === 'POST' && url.pathname === '/messages') {
const sessionId = url.searchParams.get('sessionId');
const transport = sessionId === null ? undefined : transports.get(sessionId);
if (transport === undefined) {
res.writeHead(404).end('Session not found');
return;
}
void transport.handlePostMessage(req, res);
return;
}
res.writeHead(404).end('not found');
});
await new Promise<void>((resolve) => {
httpServer.listen(0, '127.0.0.1', resolve);
});
const port = (httpServer.address() as AddressInfo).port;
return {
url: `http://127.0.0.1:${port}/mcp`,
async close() {
await Promise.all([...transports.values()].map((transport) => transport.close()));
await new Promise<void>((resolve, reject) => {
httpServer.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
},
};
}
describe('SseMcpClient', () => {
it('connects, lists tools, and round-trips a call over real SSE', async () => {
const server = await startInProcessSseMcpServer();
cleanups.push(server.close);
const client = new SseMcpClient({ transport: 'sse', url: server.url });
try {
await client.connect();
const tools = await client.listTools();
expect(tools.map((t) => t.name)).toEqual(['echo']);
const result = await client.callTool('echo', { text: 'hello sse' });
expect(result.isError).toBe(false);
expect(result.content).toEqual([{ type: 'text', text: 'hello sse' }]);
} finally {
await client.close();
}
}, 15000);
it('forwards bearer token from envLookup on the SSE and POST requests', async () => {
const server = await startInProcessSseMcpServer({ authToken: 'good-token' });
cleanups.push(server.close);
const client = new SseMcpClient(
{
transport: 'sse',
url: server.url,
bearerTokenEnvVar: 'EXAMPLE_TOKEN',
},
{ envLookup: (name) => (name === 'EXAMPLE_TOKEN' ? 'good-token' : undefined) },
);
try {
await client.connect();
const result = await client.callTool('echo', { text: 'with auth' });
expect(result.content).toEqual([{ type: 'text', text: 'with auth' }]);
} finally {
await client.close();
}
}, 15000);
it('classifies terminal SSE transport errors without treating reconnect flaps as terminal', () => {
const unauthorized = new Error('Unauthorized');
unauthorized.name = 'UnauthorizedError';
expect(isTerminalSseTransportError(unauthorized)).toBe(true);
expect(
isTerminalSseTransportError(
new SseError(
204,
'Server sent HTTP 204',
{} as ConstructorParameters<typeof SseError>[2],
),
),
).toBe(true);
expect(isTerminalSseTransportError(new Error('fetch failed'))).toBe(false);
});
});

View file

@ -194,7 +194,7 @@ describe('loadMcpServers', () => {
const home = makeTempDir();
const cwd = makeTempDir();
await writeJson(join(home, 'mcp.json'), {
mcpServers: { bad: { transport: 'sse', url: 'https://x' } },
mcpServers: { bad: { transport: 'websocket', url: 'https://x' } },
});
await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({
code: ErrorCodes.CONFIG_INVALID,
@ -243,6 +243,28 @@ describe('loadMcpServers', () => {
});
});
it('loads explicit SSE server config', async () => {
const home = makeTempDir();
const cwd = makeTempDir();
await writeJson(join(home, 'mcp.json'), {
mcpServers: {
legacy: {
transport: 'sse',
url: 'https://mcp.example.com/sse',
headers: { 'X-Tenant': 'kimi' },
bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
},
},
});
const servers = await loadMcpServers({ cwd, homeDir: home });
expect(servers['legacy']).toEqual({
transport: 'sse',
url: 'https://mcp.example.com/sse',
headers: { 'X-Tenant': 'kimi' },
bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
});
});
it('honors KIMI_CODE_HOME env var when homeDir is not supplied', async () => {
const home = makeTempDir();
const cwd = makeTempDir();

View file

@ -117,6 +117,25 @@ describe('McpConnectionManager', () => {
}
});
it('marks SSE servers failed when configured bearer token env var is missing', async () => {
const cm = new McpConnectionManager({ envLookup: () => undefined });
try {
await cm.connectAll({
legacy: {
transport: 'sse',
url: 'https://example.invalid/sse',
bearerTokenEnvVar: 'LEGACY_MCP_TOKEN',
},
});
const entry = cm.get('legacy');
expect(entry?.transport).toBe('sse');
expect(entry?.status).toBe('failed');
expect(entry?.error).toContain('"LEGACY_MCP_TOKEN" is not set or is empty');
} finally {
await cm.shutdown();
}
});
it('marks disabled servers without attempting a connection', async () => {
const cm = new McpConnectionManager();
try {
@ -377,6 +396,47 @@ describe('McpConnectionManager', () => {
}
}, 15000);
it('flips SSE servers into needs-auth when the server returns 401 and no static token is set', async () => {
const server: HttpServer = createHttpServer((_req, res) => {
res.writeHead(401, {
'content-type': 'text/plain',
'www-authenticate': 'Bearer realm="mcp", resource_metadata="http://x/.well-known/oauth-protected-resource"',
});
res.end('unauthorized');
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as HttpAddress).port;
const storeDir = await mkdtemp(join(tmpdir(), 'kimi-mcp-oauth-sse-cm-'));
const oauthService = new McpOAuthService({ store: new JsonFileStore(storeDir) });
const cm = new McpConnectionManager({ oauthService });
try {
await cm.connectAll({
legacy: {
transport: 'sse',
url: `http://127.0.0.1:${port}/sse`,
startupTimeoutMs: 5_000,
},
});
const entry = cm.get('legacy');
expect(entry?.transport).toBe('sse');
expect(entry?.status).toBe('needs-auth');
expect(entry?.error).toContain('run /mcp-config login legacy');
expect(entry?.toolCount).toBe(0);
} finally {
await cm.shutdown();
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
await rm(storeDir, { recursive: true, force: true });
}
}, 15000);
it('flips cached OAuth credentials that require reauth into needs-auth', async () => {
const server: HttpServer = createHttpServer((req, res) => {
if (req.url === '/token') {

View file

@ -341,6 +341,7 @@ describe('PluginManager', () => {
mcpServers: {
finance: { command: 'finance-mcp' },
docs: { url: 'https://example.com/mcp' },
events: { transport: 'sse', url: 'https://example.com/sse' },
},
});
const manager = new PluginManager({ kimiHomeDir: home });
@ -356,10 +357,18 @@ describe('PluginManager', () => {
command: 'finance-mcp',
}),
);
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({
name: 'events',
runtimeName: 'plugin-demo:events',
transport: 'sse',
url: 'https://example.com/sse',
}),
);
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({
mcpServerCount: 2,
enabledMcpServerCount: 2,
mcpServerCount: 3,
enabledMcpServerCount: 3,
}),
);
@ -373,6 +382,10 @@ describe('PluginManager', () => {
'plugin-demo:docs': expect.objectContaining({
url: 'https://example.com/mcp',
}),
'plugin-demo:events': expect.objectContaining({
transport: 'sse',
url: 'https://example.com/sse',
}),
}),
);
@ -381,8 +394,8 @@ describe('PluginManager', () => {
expect(manager.enabledMcpServers()).not.toHaveProperty('plugin-demo:finance');
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({
mcpServerCount: 2,
enabledMcpServerCount: 1,
mcpServerCount: 3,
enabledMcpServerCount: 2,
}),
);

View file

@ -305,6 +305,11 @@ describe('parseManifest', () => {
url: 'https://example.com/mcp',
headers: { 'X-Test': '1' },
},
events: {
transport: 'sse',
url: 'https://example.com/sse',
headers: { 'X-Events': '1' },
},
},
}),
},
@ -324,6 +329,11 @@ describe('parseManifest', () => {
url: 'https://example.com/mcp',
headers: { 'X-Test': '1' },
});
expect(result.manifest?.mcpServers?.['events']).toEqual({
transport: 'sse',
url: 'https://example.com/sse',
headers: { 'X-Events': '1' },
});
});
it('warns and skips invalid plugin mcpServers entries', async () => {

View file

@ -528,7 +528,7 @@ export interface McpServerStatusEvent {
export interface McpServerStatusPayload {
readonly name: string;
readonly transport: 'stdio' | 'http';
readonly transport: 'stdio' | 'http' | 'sse';
readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
readonly toolCount: number;
readonly error?: string;