From e083b923e5996029d856f1988b61fb33c03cbbfd Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 21:15:29 +0800 Subject: [PATCH] feat(server-v2): add /api/v1 tools and MCP server routes - GET /tools, GET /mcp/servers, POST /mcp/servers/{id}:restart - resolve IToolRegistry / IMcpService from the most-recent session's main agent; empty list / 40408 when no session is live - project v2 models to the protocol wire shapes, matching packages/server (source/status mapping, mcp_server_id, null input_schema) --- packages/server-v2/src/routes/tools.ts | 330 +++++++++++++++++++++++++ packages/server-v2/test/tools.test.ts | 243 ++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 packages/server-v2/src/routes/tools.ts create mode 100644 packages/server-v2/test/tools.test.ts diff --git a/packages/server-v2/src/routes/tools.ts b/packages/server-v2/src/routes/tools.ts new file mode 100644 index 000000000..94b866f97 --- /dev/null +++ b/packages/server-v2/src/routes/tools.ts @@ -0,0 +1,330 @@ +/** + * `/tools` + `/mcp/servers*` REST routes — server-v2 port. + * + * 3 endpoints (REST.md §3.8), mirroring the v1 server's wire contract + * (`packages/server/src/routes/tools.ts`): + * + * GET /tools query: {session_id?} data: {tools: ToolDescriptor[]} + * GET /mcp/servers - data: {servers: McpServer[]} + * POST /mcp/servers/{mcp_server_id}:restart body: empty data: {restarting: true} + * + * **Thin wrapper over Agent-scoped services**: `IToolRegistry.list` / + * `IMcpService.list` / `IMcpService.reconnect` are already exposed on the + * `/api/v2` RPC action map (`tools:list`, `mcp:list`, `mcp:reconnect`). These + * REST routes borrow them by interface and project their v2 models into the + * protocol's `ToolDescriptor` / `McpServer` shapes. + * + * **Resolution**: v1 serves these from a global singleton that falls back to + * the most-recent session. v2 has no global tool/MCP state — both services are + * Agent-scoped — so we reproduce the fallback: `core` → `ISessionIndex` (pick + * the newest session by `createdAt`, or the explicit `session_id`) → + * `ISessionLifecycleService` → `IAgentLifecycleService` (the `main` agent) → + * the service. When no session is live, or the main agent does not exist yet + * (server-v2 gap G10), the GET endpoints answer an empty list and `:restart` + * answers `40408`, exactly like v1. + * + * **Model projection**: + * - Tool `source`: `user`→`skill` (wire name), `builtin`/`mcp` pass through. + * - Tool `input_schema`: always `null`, matching v1 (`packages/server`'s + * `ToolInfo` carries no JSON schema). v2's registry does expose + * `parameters`, but we keep byte-for-byte wire parity with v1. + * - Tool `mcp_server_id`: parsed from the qualified name `mcp____` + * (v2's double-underscore form, not v1's `mcp::` colon form). + * - MCP `status`: `pending`→`connecting`, `connected`→`connected`, + * `failed`/`needs-auth`→`error`, `disabled`→`disconnected`. + * - MCP `last_error`: carried from `entry.error` when non-empty. + * + * **Error mapping**: + * - `:restart` of an unknown / unreachable server → `40408 mcp.server_not_found`. + * - malformed `{tail}` (bad action, bare id) → `40001 validation.failed`. + * - other errors → 50001 via the global `installErrorHandler`. + * + * **Anti-corruption**: route resolves `IToolRegistry` / `IMcpService` via the + * accessor; no SDK imports. + */ + +import { + ErrorCodes, + IAgentLifecycleService, + IMcpService, + ISessionIndex, + ISessionLifecycleService, + IToolRegistry, + KimiError, + type Scope, + type ToolInfo, + type ToolSource, +} from '@moonshot-ai/agent-core-v2'; +import { + ErrorCode, + listMcpServersResponseSchema, + listToolsQuerySchema, + listToolsResponseSchema, + restartMcpServerResultSchema, + type McpServer, + type ToolDescriptor, +} from '@moonshot-ai/protocol'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { parseActionSuffix } from './action-suffix'; + +/** Agent id that owns the session's tool registry and MCP connections. */ +const MAIN_AGENT_ID = 'main'; + +/** v2 MCP tool-name prefix / separator (see `mcp/tool-naming.ts`). */ +const MCP_NAME_PREFIX = 'mcp__'; +const MCP_NAME_SEPARATOR = '__'; + +/** One entry from the agent's MCP server list (type not re-exported publicly). */ +type McpEntry = ReturnType[number]; + +interface ToolsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerToolsRoutes(app: ToolsRouteHost, core: Scope): void { + // GET /tools ---------------------------------------------------------- + const listToolsRoute = defineRoute( + { + method: 'GET', + path: '/tools', + querystring: listToolsQuerySchema, + success: { data: listToolsResponseSchema }, + description: 'List available tools', + tags: ['tools'], + }, + async (req, reply) => { + const agent = await resolveEffectiveAgent(core, req.query.session_id); + const tools = + agent === undefined + ? [] + : agent.accessor.get(IToolRegistry).list().map(toProtocolTool); + reply.send(okEnvelope({ tools }, req.id)); + }, + ); + app.get( + listToolsRoute.path, + listToolsRoute.options, + listToolsRoute.handler as Parameters[2], + ); + + // GET /mcp/servers ---------------------------------------------------- + const listMcpServersRoute = defineRoute( + { + method: 'GET', + path: '/mcp/servers', + success: { data: listMcpServersResponseSchema }, + description: 'List configured MCP servers', + tags: ['tools'], + }, + async (req, reply) => { + const agent = await resolveEffectiveAgent(core, undefined); + const servers = + agent === undefined + ? [] + : agent.accessor.get(IMcpService).list().map(toProtocolMcpServer); + reply.send(okEnvelope({ servers }, req.id)); + }, + ); + app.get( + listMcpServersRoute.path, + listMcpServersRoute.options, + listMcpServersRoute.handler as Parameters[2], + ); + + // POST /mcp/servers/{mcp_server_id}:restart --------------------------- + const restartMcpServerRoute = defineRoute( + { + method: 'POST', + path: '/mcp/servers/{tail}', + success: { data: restartMcpServerResultSchema }, + errors: { + [ErrorCode.MCP_SERVER_NOT_FOUND]: {}, + }, + description: 'Restart an MCP server by ID', + tags: ['tools'], + operationId: 'restartMcpServer', + }, + async (req, reply) => { + const { tail } = req.params as { tail: string }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['restart'] as const, + resourceLabel: 'mcp_server', + }); + if (parsed.kind === 'invalid') { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id)); + return; + } + if (parsed.kind === 'bare') { + // No bare form for /mcp/servers/{id} — only :restart. + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + + const agent = await resolveEffectiveAgent(core, undefined); + if (agent === undefined) { + reply.send(mcpServerNotFound(parsed.id, req.id)); + return; + } + const mcp = agent.accessor.get(IMcpService); + // Pre-check existence so a missing/idle connection manager (where + // `reconnect` is a no-op) still reports 40408 for unknown servers. + if (!mcp.list().some((entry) => entry.name === parsed.id)) { + reply.send(mcpServerNotFound(parsed.id, req.id)); + return; + } + try { + await mcp.reconnect(parsed.id); + reply.send(okEnvelope({ restarting: true }, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, + ); + app.post( + restartMcpServerRoute.path, + restartMcpServerRoute.options, + restartMcpServerRoute.handler as Parameters[2], + ); +} + +// --------------------------------------------------------------------------- +// Resolution — walk core → newest session → main agent. Returns `undefined` +// when no session is live or the main agent has not been created yet (gap G10); +// callers translate that into an empty list (GETs) or 40408 (restart). +// --------------------------------------------------------------------------- + +async function resolveEffectiveAgent(core: Scope, sessionId: string | undefined) { + const sid = sessionId ?? (await mostRecentSessionId(core)); + if (sid === undefined) return undefined; + const session = core.accessor.get(ISessionLifecycleService).get(sid); + return session?.accessor.get(IAgentLifecycleService).getHandle(MAIN_AGENT_ID); +} + +/** Pick the most-recently-created session id, mirroring v1's fallback. */ +async function mostRecentSessionId(core: Scope): Promise { + const page = await core.accessor.get(ISessionIndex).list({}); + const [first, ...rest] = page.items; + if (first === undefined) return undefined; + let newest = first; + for (const item of rest) { + if (item.createdAt > newest.createdAt) newest = item; + } + return newest.id; +} + +// --------------------------------------------------------------------------- +// Projection — v2 models → protocol wire shapes (see module header). +// --------------------------------------------------------------------------- + +function mapToolSource(source: ToolSource): ToolDescriptor['source'] { + switch (source) { + case 'builtin': + return 'builtin'; + case 'user': + return 'skill'; + case 'mcp': + return 'mcp'; + } +} + +/** Extract the MCP server id from a qualified `mcp____` name. */ +function parseMcpServerId(toolName: string): string | undefined { + if (!toolName.startsWith(MCP_NAME_PREFIX)) return undefined; + const rest = toolName.slice(MCP_NAME_PREFIX.length); + const sep = rest.indexOf(MCP_NAME_SEPARATOR); + if (sep <= 0) return undefined; + return rest.slice(0, sep); +} + +function toProtocolTool(info: ToolInfo): ToolDescriptor { + const source = mapToolSource(info.source); + const base: ToolDescriptor = { + name: info.name, + description: info.description, + input_schema: null, + source, + }; + if (source === 'mcp') { + const serverId = parseMcpServerId(info.name); + if (serverId !== undefined) return { ...base, mcp_server_id: serverId }; + } + return base; +} + +function mapMcpStatus(status: McpEntry['status']): McpServer['status'] { + switch (status) { + case 'pending': + return 'connecting'; + case 'connected': + return 'connected'; + case 'disabled': + return 'disconnected'; + case 'failed': + return 'error'; + case 'needs-auth': + return 'error'; + } +} + +function toProtocolMcpServer(entry: McpEntry): McpServer { + const base: McpServer = { + id: entry.name, + name: entry.name, + transport: entry.transport, + status: mapMcpStatus(entry.status), + tool_count: entry.toolCount, + }; + if (entry.error !== undefined && entry.error.length > 0) { + return { ...base, last_error: entry.error }; + } + return base; +} + +// --------------------------------------------------------------------------- +// Error envelopes +// --------------------------------------------------------------------------- + +function mcpServerNotFound(serverId: string, requestId: string): unknown { + return errEnvelope( + ErrorCode.MCP_SERVER_NOT_FOUND, + `MCP server ${serverId} does not exist`, + requestId, + ); +} + +/** + * Map a thrown error to the right envelope. `reconnect` surfaces an unknown + * server as a coded `KimiError`; everything else propagates to the global + * `installErrorHandler` (→ 50001). See module header for the table. + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof KimiError && err.code === ErrorCodes.MCP_SERVER_NOT_FOUND) { + reply.send(errEnvelope(ErrorCode.MCP_SERVER_NOT_FOUND, err.message, requestId)); + return; + } + throw err; +} diff --git a/packages/server-v2/test/tools.test.ts b/packages/server-v2/test/tools.test.ts new file mode 100644 index 000000000..97c67234f --- /dev/null +++ b/packages/server-v2/test/tools.test.ts @@ -0,0 +1,243 @@ +/** + * `/api/v1` tools + MCP routes — server-v2 port of `packages/server/test/tools.e2e.test.ts`. + * + * Covers the wire contract of the three endpoints: + * - GET /api/v1/tools → envelope shape + tools[] + * - GET /api/v1/mcp/servers → envelope shape + servers[] + * - POST /api/v1/mcp/servers/{id}:restart → {restarting:true} / 40408 + * - POST /api/v1/mcp/servers/foo:bogus → 40001 unsupported action + * + * Unlike v1 (which sources these from a global singleton), server-v2 resolves + * `IToolRegistry` / `IMcpService` from the most-recent session's `main` agent. + * The empty-list / 40408 fallbacks for "no session yet" and "no main agent yet" + * (gap G10) are exercised explicitly. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + IAgentLifecycleService, + ISessionLifecycleService, + IToolRegistry, + modelResolverSeed, + SingleModelResolver, + type ExecutableTool, +} from '@moonshot-ai/agent-core-v2'; +import { + listMcpServersResponseSchema, + listToolsResponseSchema, +} from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +interface ToolWire { + name: string; + description: string; + input_schema: unknown; + source: string; + mcp_server_id?: string; +} + +describe('server-v2 /api/v1 tools + mcp', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-tools-')); + const modelResolver = new SingleModelResolver({ + type: 'openai', + model: 'stub', + apiKey: 'stub', + }); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: modelResolverSeed(modelResolver), + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const { body } = await postJson<{ id: string }>('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + expect(body.code).toBe(0); + return body.data.id; + } + + // The main agent scope is not created automatically on session creation + // (server-v2 gap G10); create it here so IToolRegistry / IMcpService resolve. + async function ensureMainAgent(sessionId: string) { + const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + let agent = session.accessor.get(IAgentLifecycleService).getHandle('main'); + agent ??= await session.accessor.get(IAgentLifecycleService).createMain(); + return agent; + } + + function makeTool(name: string, parameters?: Record): ExecutableTool { + return { + name, + description: `tool ${name}`, + parameters, + resolveExecution: () => ({ + approvalRule: 'always-allow', + execute: async () => ({ output: '' }), + }), + } as ExecutableTool; + } + + describe('GET /api/v1/tools', () => { + it('returns an empty list before any session exists', async () => { + const { status, body } = await getJson<{ tools: ToolWire[] }>('/api/v1/tools'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(listToolsResponseSchema.parse(body.data).tools).toEqual([]); + }); + + it('returns an empty list when the session has no main agent yet', async () => { + await createSession(); + const { body } = await getJson<{ tools: ToolWire[] }>('/api/v1/tools'); + expect(body.code).toBe(0); + expect(listToolsResponseSchema.parse(body.data).tools).toEqual([]); + }); + + it('projects registered tools with source mapping and mcp server id', async () => { + const id = await createSession(); + const agent = await ensureMainAgent(id); + const registry = agent.accessor.get(IToolRegistry); + const schema = { type: 'object', properties: { msg: { type: 'string' } } }; + registry.register(makeTool('Echo', schema), { source: 'builtin' }); + registry.register(makeTool('MySkill'), { source: 'user' }); + registry.register(makeTool('mcp__myserver__search'), { source: 'mcp' }); + + const { body } = await getJson<{ tools: ToolWire[] }>('/api/v1/tools'); + expect(body.code).toBe(0); + const tools = listToolsResponseSchema.parse(body.data).tools; + + const echo = tools.find((t) => t.name === 'Echo'); + // v1 parity: `input_schema` is always null on the wire, even though v2's + // registry carries the real JSON schema (`parameters`). + expect(echo).toMatchObject({ source: 'builtin', input_schema: null }); + expect(echo?.mcp_server_id).toBeUndefined(); + + const skill = tools.find((t) => t.name === 'MySkill'); + // v2 `user` source maps to the wire `skill` name. + expect(skill).toMatchObject({ source: 'skill' }); + + const mcp = tools.find((t) => t.name === 'mcp__myserver__search'); + // Qualified name `mcp____` yields the server id. + expect(mcp).toMatchObject({ source: 'mcp', mcp_server_id: 'myserver' }); + }); + + it('accepts an explicit session_id query', async () => { + const sid = await createSession(); + await ensureMainAgent(sid); + const { body } = await getJson<{ tools: ToolWire[] }>( + `/api/v1/tools?session_id=${sid}`, + ); + expect(body.code).toBe(0); + expect(listToolsResponseSchema.safeParse(body.data).success).toBe(true); + }); + + it('rejects an empty session_id with 40001', async () => { + const { body } = await getJson('/api/v1/tools?session_id='); + expect(body.code).toBe(40001); + }); + }); + + describe('GET /api/v1/mcp/servers', () => { + it('returns an empty list before any session exists', async () => { + const { status, body } = await getJson<{ servers: unknown[] }>('/api/v1/mcp/servers'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(listMcpServersResponseSchema.parse(body.data).servers).toEqual([]); + }); + + it('returns an empty list when the session has no main agent yet', async () => { + await createSession(); + const { body } = await getJson<{ servers: unknown[] }>('/api/v1/mcp/servers'); + expect(body.code).toBe(0); + expect(listMcpServersResponseSchema.parse(body.data).servers).toEqual([]); + }); + + it('returns a parseable servers list once the main agent exists', async () => { + const id = await createSession(); + await ensureMainAgent(id); + const { body } = await getJson<{ servers: unknown[] }>('/api/v1/mcp/servers'); + expect(body.code).toBe(0); + // No MCP servers configured in the sandboxed home → empty, but the route + // must still resolve IMcpService successfully and answer a valid shape. + expect(listMcpServersResponseSchema.parse(body.data).servers).toEqual([]); + }); + }); + + describe('POST /api/v1/mcp/servers/{id}:restart', () => { + it('returns 40408 for an unknown server id', async () => { + const id = await createSession(); + await ensureMainAgent(id); + const { body } = await postJson('/api/v1/mcp/servers/does-not-exist:restart'); + expect(body.code).toBe(40408); + expect(body.msg).toMatch(/does not exist/); + }); + + it('returns 40408 even before any session is created', async () => { + const { body } = await postJson('/api/v1/mcp/servers/x:restart'); + expect(body.code).toBe(40408); + }); + + it('rejects an unsupported action with 40001', async () => { + await createSession(); + const { body } = await postJson('/api/v1/mcp/servers/foo:bogus'); + expect(body.code).toBe(40001); + expect(body.msg).toMatch(/unsupported action/); + }); + + it('rejects a bare {id} (no action) with 40001', async () => { + await createSession(); + const { body } = await postJson('/api/v1/mcp/servers/foo'); + expect(body.code).toBe(40001); + }); + }); +});