mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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
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:
parent
93928066dc
commit
18f299fd0b
31 changed files with 587 additions and 86 deletions
8
.changeset/sse-mcp-servers.md
Normal file
8
.changeset/sse-mcp-servers.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ kimi acp
|
|||
| `promptCapabilities.audio` | `false` | 暂不支持音频 prompt |
|
||||
| `promptCapabilities.embeddedContext` | `true` | 客户端可发送 `resource`/`resource_link` 嵌入式资源块,文本内容会以 `<resource uri="...">...</resource>` 形式注入 prompt;blob 资源被丢弃并写 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 日志
|
||||
|
||||
## 下一步
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
32
packages/agent-core/src/mcp/client-remote.ts
Normal file
32
packages/agent-core/src/mcp/client-remote.ts
Normal 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';
|
||||
}
|
||||
169
packages/agent-core/src/mcp/client-sse.ts
Normal file
169
packages/agent-core/src/mcp/client-sse.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('./')) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
142
packages/agent-core/test/mcp/client-sse.test.ts
Normal file
142
packages/agent-core/test/mcp/client-sse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue