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)
This commit is contained in:
haozhe.yang 2026-06-29 21:15:29 +08:00
parent 55060e3942
commit e083b923e5
2 changed files with 573 additions and 0 deletions

View file

@ -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__<server>__<tool>`
* (v2's double-underscore form, not v1's `mcp:<server>:<tool>` 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<IMcpService['list']>[number];
interface ToolsRouteHost {
get(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
handler: (
req: { id: string; query: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | 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<ToolsRouteHost['get']>[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<ToolsRouteHost['get']>[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<ToolsRouteHost['post']>[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<string | undefined> {
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__<server>__<tool>` 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;
}

View file

@ -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<T> {
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<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function postJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
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<T> };
}
async function createSession(): Promise<string> {
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<string, unknown>): 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__<server>__<tool>` 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<null>('/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<null>('/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<null>('/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<null>('/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<null>('/api/v1/mcp/servers/foo');
expect(body.code).toBe(40001);
});
});
});