mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
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:
parent
55060e3942
commit
e083b923e5
2 changed files with 573 additions and 0 deletions
330
packages/server-v2/src/routes/tools.ts
Normal file
330
packages/server-v2/src/routes/tools.ts
Normal 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;
|
||||
}
|
||||
243
packages/server-v2/test/tools.test.ts
Normal file
243
packages/server-v2/test/tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue