Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tools-list-active-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@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.
4 changes: 4 additions & 0 deletions packages/kap-server/src/protocol/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof toolDescriptorSchema>;

Expand Down
23 changes: 17 additions & 6 deletions packages/kap-server/src/routes/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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__<server>__<tool>`
* (v2's double-underscore form, not v1's `mcp:<server>:<tool>` 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.
Expand All @@ -49,6 +53,7 @@ import {
ISessionIndex,
ISessionLifecycleService,
IAgentToolRegistryService,
IAgentToolPolicyService,
Error2,
type Scope,
type ToolInfo,
Expand Down Expand Up @@ -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));
},
);
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 22 additions & 1 deletion packages/kap-server/test/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
IAgentLifecycleService,
IAgentToolRegistryService,
ISessionLifecycleService,
ISessionToolPolicy,
IModelCatalog,
type ExecutableTool,
} from '@moonshot-ai/agent-core-v2';
Expand All @@ -46,6 +47,7 @@ interface ToolWire {
input_schema: unknown;
source: string;
mcp_server_id?: string;
active?: boolean;
}

describe('server-v2 /api/v1 tools + mcp', () => {
Expand Down Expand Up @@ -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');
Expand All @@ -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<null>('/api/v1/tools?session_id=');
expect(body.code).toBe(40001);
Expand Down
5 changes: 5 additions & 0 deletions packages/protocol/src/__tests__/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/protocol/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof toolDescriptorSchema>;

Expand Down
Loading