From bb6ab0d6983f4bb91eefdeef450da0fdd3d76773 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 16:24:27 +0800 Subject: [PATCH 1/2] feat(kap-server): expose per-tool active state in GET /api/v1/tools --- .changeset/tools-list-active-flag.md | 7 ++++++ packages/kap-server/src/protocol/tool.ts | 4 ++++ packages/kap-server/src/routes/tools.ts | 23 +++++++++++++++----- packages/kap-server/test/tools.test.ts | 23 +++++++++++++++++++- packages/protocol/src/__tests__/tool.test.ts | 5 +++++ packages/protocol/src/tool.ts | 4 ++++ 6 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 .changeset/tools-list-active-flag.md diff --git a/.changeset/tools-list-active-flag.md b/.changeset/tools-list-active-flag.md new file mode 100644 index 0000000000..3dcf3ab92d --- /dev/null +++ b/.changeset/tools-list-active-flag.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kap-server": minor +"@moonshot-ai/protocol": minor +"@moonshot-ai/kimi-code": patch +--- + +Add an `active` flag to each tool in the server's tool listing API, reflecting the profile, global, and per-session tool gates (v2 engine only). diff --git a/packages/kap-server/src/protocol/tool.ts b/packages/kap-server/src/protocol/tool.ts index 92ad7fac0b..e2e044fbf4 100644 --- a/packages/kap-server/src/protocol/tool.ts +++ b/packages/kap-server/src/protocol/tool.ts @@ -9,6 +9,10 @@ export const toolDescriptorSchema = z.object({ input_schema: z.unknown(), source: toolSourceSchema, mcp_server_id: z.string().min(1).optional(), + // v2 extension beyond the v1 wire shape: effective availability after the + // profile / global `[tools]` config / session denylist gates. Optional so + // the legacy v1 projection (no gate concept) still satisfies the schema. + active: z.boolean().optional(), }); export type ToolDescriptor = z.infer; diff --git a/packages/kap-server/src/routes/tools.ts b/packages/kap-server/src/routes/tools.ts index 1de21a5193..f729dcf356 100644 --- a/packages/kap-server/src/routes/tools.ts +++ b/packages/kap-server/src/routes/tools.ts @@ -9,7 +9,8 @@ * POST /mcp/servers/{mcp_server_id}:restart body: empty data: {restarting: true} * * **Thin wrapper over Agent-scoped services**: `IAgentToolRegistryService.list` / - * `IAgentMcpService.list` / `IAgentMcpService.reconnect` are already exposed on the + * `IAgentToolPolicyService.isToolActive` / `IAgentMcpService.list` / + * `IAgentMcpService.reconnect` are already exposed on the * RPC dispatcher (`/api/v1/debug`). These * REST routes borrow them by interface and project their v2 models into the * protocol's `ToolDescriptor` / `McpServer` shapes. @@ -30,6 +31,9 @@ * `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). + * - Tool `active`: effective availability from `IAgentToolPolicyService.isToolActive` + * (bound profile policy ∩ global `[tools]` config ∩ session denylist). + * Deliberate v2 extension beyond the v1 wire shape — v1 had no tool gates. * - MCP `status`: `pending`→`connecting`, `connected`→`connected`, * `failed`/`needs-auth`→`error`, `disabled`→`disconnected`. * - MCP `last_error`: carried from `entry.error` when non-empty. @@ -49,6 +53,7 @@ import { ISessionIndex, ISessionLifecycleService, IAgentToolRegistryService, + IAgentToolPolicyService, Error2, type Scope, type ToolInfo, @@ -107,10 +112,15 @@ export function registerToolsRoutes(app: ToolsRouteHost, core: Scope): void { }, async (req, reply) => { const agent = await resolveEffectiveAgent(core, req.query.session_id); - const tools = - agent === undefined - ? [] - : agent.accessor.get(IAgentToolRegistryService).list().map(toProtocolTool); + if (agent === undefined) { + reply.send(okEnvelope({ tools: [] }, req.id)); + return; + } + const registry = agent.accessor.get(IAgentToolRegistryService); + const policy = agent.accessor.get(IAgentToolPolicyService); + const tools = registry + .list() + .map((info) => toProtocolTool(info, policy.isToolActive(info.name, info.source))); reply.send(okEnvelope({ tools }, req.id)); }, ); @@ -253,13 +263,14 @@ function parseMcpServerId(toolName: string): string | undefined { return rest.slice(0, sep); } -function toProtocolTool(info: ToolInfo): ToolDescriptor { +function toProtocolTool(info: ToolInfo, active: boolean): ToolDescriptor { const source = mapToolSource(info.source); const base: ToolDescriptor = { name: info.name, description: info.description, input_schema: null, source, + active, }; if (source === 'mcp') { const serverId = parseMcpServerId(info.name); diff --git a/packages/kap-server/test/tools.test.ts b/packages/kap-server/test/tools.test.ts index dee7179442..a1d4ebee1e 100644 --- a/packages/kap-server/test/tools.test.ts +++ b/packages/kap-server/test/tools.test.ts @@ -21,6 +21,7 @@ import { IAgentLifecycleService, IAgentToolRegistryService, ISessionLifecycleService, + ISessionToolPolicy, IModelCatalog, type ExecutableTool, } from '@moonshot-ai/agent-core-v2'; @@ -46,6 +47,7 @@ interface ToolWire { input_schema: unknown; source: string; mcp_server_id?: string; + active?: boolean; } describe('server-v2 /api/v1 tools + mcp', () => { @@ -180,7 +182,8 @@ describe('server-v2 /api/v1 tools + mcp', () => { 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 }); + // With no gate in play every tool is `active: true` (v2 extension). + expect(echo).toMatchObject({ source: 'builtin', input_schema: null, active: true }); expect(echo?.mcp_server_id).toBeUndefined(); const skill = tools.find((t) => t.name === 'MySkill'); @@ -202,6 +205,24 @@ describe('server-v2 /api/v1 tools + mcp', () => { expect(listToolsResponseSchema.safeParse(body.data).success).toBe(true); }); + it('marks tools denied by the session tool policy as inactive', async () => { + const id = await createSession(); + await ensureMainAgent(id); + + // Set the session-scope denylist directly: this harness's model catalog is + // a throwing stub, so the REST prompt path (which requires a bound profile) + // is unavailable here. The composed gate read by the route is the same. + const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + if (session === undefined) throw new Error(`session ${id} not found`); + await session.accessor.get(ISessionToolPolicy).setDisabledTools(['Bash']); + + const { body } = await getJson<{ tools: ToolWire[] }>(`/api/v1/tools?session_id=${id}`); + expect(body.code).toBe(0); + const tools = listToolsResponseSchema.parse(body.data).tools; + expect(tools.find((t) => t.name === 'Bash')).toMatchObject({ active: false }); + expect(tools.find((t) => t.name === 'Read')).toMatchObject({ active: true }); + }); + it('rejects an empty session_id with 40001', async () => { const { body } = await getJson('/api/v1/tools?session_id='); expect(body.code).toBe(40001); diff --git a/packages/protocol/src/__tests__/tool.test.ts b/packages/protocol/src/__tests__/tool.test.ts index 8db5b49400..06530a930e 100644 --- a/packages/protocol/src/__tests__/tool.test.ts +++ b/packages/protocol/src/__tests__/tool.test.ts @@ -47,6 +47,11 @@ describe('toolDescriptorSchema', () => { expect(toolDescriptorSchema.parse(tool).input_schema).toBeNull(); }); + it('round-trips the optional v2 active flag', () => { + const tool: ToolDescriptor = { ...sample, active: false }; + expect(toolDescriptorSchema.parse(tool).active).toBe(false); + }); + it('rejects missing name', () => { expect(toolDescriptorSchema.safeParse({ ...sample, name: '' }).success).toBe(false); }); diff --git a/packages/protocol/src/tool.ts b/packages/protocol/src/tool.ts index 92ad7fac0b..e2e044fbf4 100644 --- a/packages/protocol/src/tool.ts +++ b/packages/protocol/src/tool.ts @@ -9,6 +9,10 @@ export const toolDescriptorSchema = z.object({ input_schema: z.unknown(), source: toolSourceSchema, mcp_server_id: z.string().min(1).optional(), + // v2 extension beyond the v1 wire shape: effective availability after the + // profile / global `[tools]` config / session denylist gates. Optional so + // the legacy v1 projection (no gate concept) still satisfies the schema. + active: z.boolean().optional(), }); export type ToolDescriptor = z.infer; From 2aeeb076981e73995ba964c48ca078f8ce020fc3 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 16:44:11 +0800 Subject: [PATCH 2/2] Update tools-list-active-flag.md Signed-off-by: 7Sageer <7sageer@djwcb.cn> --- .changeset/tools-list-active-flag.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/tools-list-active-flag.md b/.changeset/tools-list-active-flag.md index 3dcf3ab92d..91d127fcd7 100644 --- a/.changeset/tools-list-active-flag.md +++ b/.changeset/tools-list-active-flag.md @@ -1,7 +1,7 @@ --- -"@moonshot-ai/kap-server": minor -"@moonshot-ai/protocol": minor +"@moonshot-ai/kap-server": patch +"@moonshot-ai/protocol": patch "@moonshot-ai/kimi-code": patch --- -Add an `active` flag to each tool in the server's tool listing API, reflecting the profile, global, and per-session tool gates (v2 engine only). +Add an `active` flag to each tool in the server's tool listing API.