@checkstack/ai-backend 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +2 -2
- package/src/agent-runner.test.ts +24 -24
- package/src/chat/agent-loop.test.ts +10 -10
- package/src/chat/auto-apply.test.ts +2 -2
- package/src/hardening/handler-authz.test.ts +11 -11
- package/src/mcp/server.test.ts +13 -13
- package/src/propose-apply/service.test.ts +13 -13
- package/src/registry-wiring.test.ts +17 -9
- package/src/registry-wiring.ts +5 -1
- package/src/resolver.test.ts +8 -8
- package/src/tool-name.test.ts +42 -0
- package/src/tool-name.ts +37 -0
- package/src/tool-registry.ts +14 -4
- package/src/tools/docs-tools.test.ts +1 -1
- package/src/tools/tool-set.e2e.test.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @checkstack/ai-backend
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2428bfc: fix(ai): make AI tool names provider-safe (no "." in names)
|
|
8
|
+
|
|
9
|
+
LLM providers (and the MCP spec) require tool names to match
|
|
10
|
+
`^[a-zA-Z0-9_-]+$`, but our tool names are qualified as `<plugin>.<tool>`
|
|
11
|
+
(e.g. `incident.list`, `dependency.list`). The "." caused the model backend to
|
|
12
|
+
reject the tool list, so chat tool-calling failed after deploy.
|
|
13
|
+
|
|
14
|
+
Tool names are now normalized to a provider-safe form at the single
|
|
15
|
+
registration chokepoint (the tool registry) and in the projection-routing
|
|
16
|
+
table: the "." namespace separator is mapped to "\_" (so `incident.list`
|
|
17
|
+
becomes `incident_list`). The registry key, the name serialized out to the
|
|
18
|
+
model / MCP client, and the name the model echoes back in a tool call are all
|
|
19
|
+
the same normalized string, so the round-trip needs no reverse lookup. Any
|
|
20
|
+
other illegal character is an authoring mistake and is now rejected at
|
|
21
|
+
registration rather than silently rewritten.
|
|
22
|
+
|
|
23
|
+
BREAKING: AI tool names exposed over the MCP `tools/list` endpoint change from
|
|
24
|
+
the dotted form (`incident.list`) to the underscored form (`incident_list`).
|
|
25
|
+
MCP clients that referenced tools by their dotted names must update to the
|
|
26
|
+
underscored names. (Chat was already broken by the provider rejection, so this
|
|
27
|
+
only changes the working MCP surface.)
|
|
28
|
+
|
|
3
29
|
## 0.1.6
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/ai-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@checkstack/common": "0.15.0",
|
|
22
22
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
23
23
|
"@checkstack/integration-backend": "0.4.5",
|
|
24
|
-
"@checkstack/sdk": "0.
|
|
24
|
+
"@checkstack/sdk": "0.102.0",
|
|
25
25
|
"@orpc/client": "^1.14.4",
|
|
26
26
|
"@orpc/contract": "^1.14.4",
|
|
27
27
|
"@orpc/server": "^1.14.4",
|
package/src/agent-runner.test.ts
CHANGED
|
@@ -47,14 +47,14 @@ describe("createAgentRunner", () => {
|
|
|
47
47
|
const registry = createAiToolRegistry();
|
|
48
48
|
const calls: string[] = [];
|
|
49
49
|
registry.register(
|
|
50
|
-
readTool("
|
|
51
|
-
calls.push("
|
|
50
|
+
readTool("plugin_read", async () => {
|
|
51
|
+
calls.push("plugin_read");
|
|
52
52
|
return { ok: true };
|
|
53
53
|
}),
|
|
54
54
|
);
|
|
55
55
|
// A destructive tool must NOT be offered.
|
|
56
56
|
registry.register({
|
|
57
|
-
name: "
|
|
57
|
+
name: "plugin_delete",
|
|
58
58
|
description: "delete",
|
|
59
59
|
effect: "destructive",
|
|
60
60
|
input: z.object({}),
|
|
@@ -63,7 +63,7 @@ describe("createAgentRunner", () => {
|
|
|
63
63
|
} as RegisteredAiTool);
|
|
64
64
|
// A projected read (deferred sentinel) must NOT be offered in v1.
|
|
65
65
|
registry.register({
|
|
66
|
-
name: "
|
|
66
|
+
name: "plugin_projected",
|
|
67
67
|
description: "projected",
|
|
68
68
|
effect: "read",
|
|
69
69
|
input: z.object({}),
|
|
@@ -77,7 +77,7 @@ describe("createAgentRunner", () => {
|
|
|
77
77
|
const generateText = mock(async (args: { tools?: Record<string, unknown> }) => {
|
|
78
78
|
offeredToolNames = Object.keys(args.tools ?? {});
|
|
79
79
|
// Simulate the model calling the read tool once.
|
|
80
|
-
const t = (args.tools ?? {})["
|
|
80
|
+
const t = (args.tools ?? {})["plugin_read"] as {
|
|
81
81
|
execute: (i: unknown) => Promise<unknown>;
|
|
82
82
|
};
|
|
83
83
|
await t.execute({});
|
|
@@ -102,11 +102,11 @@ describe("createAgentRunner", () => {
|
|
|
102
102
|
outputSchema: z.object({ severity: z.string() }),
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
expect(offeredToolNames.sort()).toEqual(["
|
|
106
|
-
expect(calls).toEqual(["
|
|
105
|
+
expect(offeredToolNames.sort()).toEqual(["plugin_read"]);
|
|
106
|
+
expect(calls).toEqual(["plugin_read"]);
|
|
107
107
|
expect(result.text).toBe("done");
|
|
108
108
|
expect(result.object).toEqual({ severity: "high" });
|
|
109
|
-
expect(result.toolCalls).toEqual([{ tool: "
|
|
109
|
+
expect(result.toolCalls).toEqual([{ tool: "plugin_read", ok: true }]);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
it("hands the model a date-safe schema for tools with Date inputs (no throw)", async () => {
|
|
@@ -116,7 +116,7 @@ describe("createAgentRunner", () => {
|
|
|
116
116
|
// chat. The runner must gate date inputs through dateSafeModelSchema too.
|
|
117
117
|
const registry = createAiToolRegistry();
|
|
118
118
|
registry.register({
|
|
119
|
-
name: "
|
|
119
|
+
name: "plugin_history",
|
|
120
120
|
description: "history",
|
|
121
121
|
effect: "read",
|
|
122
122
|
input: z.object({ since: z.date() }),
|
|
@@ -130,7 +130,7 @@ describe("createAgentRunner", () => {
|
|
|
130
130
|
async (args: {
|
|
131
131
|
tools?: Record<string, { inputSchema: unknown }>;
|
|
132
132
|
}) => {
|
|
133
|
-
const t = (args.tools ?? {})["
|
|
133
|
+
const t = (args.tools ?? {})["plugin_history"];
|
|
134
134
|
// Exactly what the SDK does internally to build the model request; this
|
|
135
135
|
// threw before the fix.
|
|
136
136
|
offeredSchema = await asSchema(t.inputSchema as never).jsonSchema;
|
|
@@ -161,7 +161,7 @@ describe("createAgentRunner", () => {
|
|
|
161
161
|
it("offers a projected read tool and routes it through the principal's client", async () => {
|
|
162
162
|
const registry = createAiToolRegistry();
|
|
163
163
|
registry.register({
|
|
164
|
-
name: "
|
|
164
|
+
name: "incident_list",
|
|
165
165
|
description: "list incidents",
|
|
166
166
|
effect: "read",
|
|
167
167
|
input: z.object({}),
|
|
@@ -186,7 +186,7 @@ describe("createAgentRunner", () => {
|
|
|
186
186
|
let offered: string[] = [];
|
|
187
187
|
const generateText = mock(async (args: { tools?: Record<string, unknown> }) => {
|
|
188
188
|
offered = Object.keys(args.tools ?? {});
|
|
189
|
-
const t = (args.tools ?? {})["
|
|
189
|
+
const t = (args.tools ?? {})["incident_list"] as {
|
|
190
190
|
execute: (i: unknown) => Promise<unknown>;
|
|
191
191
|
};
|
|
192
192
|
await t.execute({ status: "open" });
|
|
@@ -197,7 +197,7 @@ describe("createAgentRunner", () => {
|
|
|
197
197
|
resolver,
|
|
198
198
|
resolveConnection: async () => connection,
|
|
199
199
|
getProjectionRoute: (name) =>
|
|
200
|
-
name === "
|
|
200
|
+
name === "incident_list"
|
|
201
201
|
? { pluginId: "incident", procedureKey: "listIncidents" }
|
|
202
202
|
: undefined,
|
|
203
203
|
modelFns: { generateText: generateText as never },
|
|
@@ -210,15 +210,15 @@ describe("createAgentRunner", () => {
|
|
|
210
210
|
prompt: "go",
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
expect(offered).toEqual(["
|
|
213
|
+
expect(offered).toEqual(["incident_list"]);
|
|
214
214
|
expect(procCalls).toEqual([{ status: "open" }]);
|
|
215
|
-
expect(result.toolCalls).toEqual([{ tool: "
|
|
215
|
+
expect(result.toolCalls).toEqual([{ tool: "incident_list", ok: true }]);
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
it("records a tool failure and surfaces it to the model instead of aborting", async () => {
|
|
219
219
|
const registry = createAiToolRegistry();
|
|
220
220
|
registry.register(
|
|
221
|
-
readTool("
|
|
221
|
+
readTool("plugin_boom", async () => {
|
|
222
222
|
throw new Error("missing access: plugin.read");
|
|
223
223
|
}),
|
|
224
224
|
);
|
|
@@ -226,7 +226,7 @@ describe("createAgentRunner", () => {
|
|
|
226
226
|
|
|
227
227
|
let toolResult: unknown;
|
|
228
228
|
const generateText = mock(async (args: { tools?: Record<string, unknown> }) => {
|
|
229
|
-
const t = (args.tools ?? {})["
|
|
229
|
+
const t = (args.tools ?? {})["plugin_boom"] as {
|
|
230
230
|
execute: (i: unknown) => Promise<unknown>;
|
|
231
231
|
};
|
|
232
232
|
toolResult = await t.execute({});
|
|
@@ -247,15 +247,15 @@ describe("createAgentRunner", () => {
|
|
|
247
247
|
});
|
|
248
248
|
|
|
249
249
|
expect(toolResult).toEqual({ error: "missing access: plugin.read" });
|
|
250
|
-
expect(result.toolCalls).toEqual([{ tool: "
|
|
250
|
+
expect(result.toolCalls).toEqual([{ tool: "plugin_boom", ok: false }]);
|
|
251
251
|
expect(result.object).toBeUndefined();
|
|
252
252
|
});
|
|
253
253
|
|
|
254
254
|
it("calls recordToolCall for each invocation (ok and failure)", async () => {
|
|
255
255
|
const registry = createAiToolRegistry();
|
|
256
|
-
registry.register(readTool("
|
|
256
|
+
registry.register(readTool("plugin_ok", async () => ({ ok: true })));
|
|
257
257
|
registry.register(
|
|
258
|
-
readTool("
|
|
258
|
+
readTool("plugin_boom", async () => {
|
|
259
259
|
throw new Error("nope");
|
|
260
260
|
}),
|
|
261
261
|
);
|
|
@@ -273,8 +273,8 @@ describe("createAgentRunner", () => {
|
|
|
273
273
|
|
|
274
274
|
const generateText = mock(async (args: { tools?: Record<string, unknown> }) => {
|
|
275
275
|
const tools = args.tools ?? {};
|
|
276
|
-
await (tools["
|
|
277
|
-
await (tools["
|
|
276
|
+
await (tools["plugin_ok"] as { execute: (i: unknown) => Promise<unknown> }).execute({});
|
|
277
|
+
await (tools["plugin_boom"] as { execute: (i: unknown) => Promise<unknown> }).execute({});
|
|
278
278
|
return { text: "x", usage: {} };
|
|
279
279
|
});
|
|
280
280
|
|
|
@@ -287,12 +287,12 @@ describe("createAgentRunner", () => {
|
|
|
287
287
|
await runner({ principal, rpcClient, connectionId: "c", prompt: "go" });
|
|
288
288
|
|
|
289
289
|
expect(recorded).toContainEqual({
|
|
290
|
-
toolName: "
|
|
290
|
+
toolName: "plugin_ok",
|
|
291
291
|
ok: true,
|
|
292
292
|
effect: "read",
|
|
293
293
|
});
|
|
294
294
|
expect(recorded).toContainEqual({
|
|
295
|
-
toolName: "
|
|
295
|
+
toolName: "plugin_boom",
|
|
296
296
|
ok: false,
|
|
297
297
|
effect: "read",
|
|
298
298
|
});
|
|
@@ -28,9 +28,9 @@ function tool(
|
|
|
28
28
|
|
|
29
29
|
function setup() {
|
|
30
30
|
const registry = createAiToolRegistry();
|
|
31
|
-
const read = tool("
|
|
32
|
-
const mutate = tool("
|
|
33
|
-
const destroy = tool("
|
|
31
|
+
const read = tool("incident_list", "read", "incident.incident.read");
|
|
32
|
+
const mutate = tool("automation_propose", "mutate", "automation.automation.manage");
|
|
33
|
+
const destroy = tool("incident_delete", "destructive", "incident.incident.manage");
|
|
34
34
|
registry.register(read);
|
|
35
35
|
registry.register(mutate);
|
|
36
36
|
registry.register(destroy);
|
|
@@ -57,15 +57,15 @@ describe("agent loop tool gating (matrix #14)", () => {
|
|
|
57
57
|
test("the loop only offers resolver-allowed tools", () => {
|
|
58
58
|
const { resolver } = setup();
|
|
59
59
|
const offered = offeredTools({ principal: limited, resolver }).map((t) => t.name);
|
|
60
|
-
expect(offered).toEqual(["
|
|
61
|
-
expect(offered).not.toContain("
|
|
62
|
-
expect(offered).not.toContain("
|
|
60
|
+
expect(offered).toEqual(["incident_list"]);
|
|
61
|
+
expect(offered).not.toContain("automation_propose");
|
|
62
|
+
expect(offered).not.toContain("incident_delete");
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("a model-requested tool OUTSIDE the principal's set is refused server-side", () => {
|
|
66
66
|
const { resolver, registry } = setup();
|
|
67
67
|
const d = disposeAgentTool({
|
|
68
|
-
toolName: "
|
|
68
|
+
toolName: "automation_propose",
|
|
69
69
|
principal: limited,
|
|
70
70
|
resolver,
|
|
71
71
|
getTool: (n) => registry.getTool(n),
|
|
@@ -87,7 +87,7 @@ describe("agent loop tool gating (matrix #14)", () => {
|
|
|
87
87
|
test("a read tool auto-runs", () => {
|
|
88
88
|
const { resolver, registry } = setup();
|
|
89
89
|
const d = disposeAgentTool({
|
|
90
|
-
toolName: "
|
|
90
|
+
toolName: "incident_list",
|
|
91
91
|
principal: limited,
|
|
92
92
|
resolver,
|
|
93
93
|
getTool: (n) => registry.getTool(n),
|
|
@@ -98,7 +98,7 @@ describe("agent loop tool gating (matrix #14)", () => {
|
|
|
98
98
|
test("a mutate tool requires a confirm card (never silently mutates)", () => {
|
|
99
99
|
const { resolver, registry } = setup();
|
|
100
100
|
const d = disposeAgentTool({
|
|
101
|
-
toolName: "
|
|
101
|
+
toolName: "automation_propose",
|
|
102
102
|
principal: power,
|
|
103
103
|
resolver,
|
|
104
104
|
getTool: (n) => registry.getTool(n),
|
|
@@ -109,7 +109,7 @@ describe("agent loop tool gating (matrix #14)", () => {
|
|
|
109
109
|
test("a destructive tool requires a confirm card", () => {
|
|
110
110
|
const { resolver, registry } = setup();
|
|
111
111
|
const d = disposeAgentTool({
|
|
112
|
-
toolName: "
|
|
112
|
+
toolName: "incident_delete",
|
|
113
113
|
principal: power,
|
|
114
114
|
resolver,
|
|
115
115
|
getTool: (n) => registry.getTool(n),
|
|
@@ -129,7 +129,7 @@ function mutatingTool(): {
|
|
|
129
129
|
created: input.value,
|
|
130
130
|
}));
|
|
131
131
|
const tool: RegisteredAiTool<{ value: string }, { created: string }> = {
|
|
132
|
-
name: "
|
|
132
|
+
name: "demo_mutate",
|
|
133
133
|
description: "demo mutating tool",
|
|
134
134
|
effect: "mutate",
|
|
135
135
|
input: ManageInput,
|
|
@@ -208,7 +208,7 @@ describe("AUTO-mode mutate auto-apply path", () => {
|
|
|
208
208
|
// proposed -> applied, with the applier stamped. Not a weaker/parallel path.
|
|
209
209
|
const applied = [...store.rows.values()].filter((r) => r.status === "applied");
|
|
210
210
|
expect(applied).toHaveLength(1);
|
|
211
|
-
expect(applied[0]?.toolName).toBe("
|
|
211
|
+
expect(applied[0]?.toolName).toBe("demo_mutate");
|
|
212
212
|
expect(applied[0]?.effect).toBe("mutate");
|
|
213
213
|
expect(applied[0]?.appliedById).toBe("u1");
|
|
214
214
|
expect(applied[0]?.id).toBe(result.toolCallId);
|
|
@@ -154,7 +154,7 @@ describe("HARDENING: a misbehaving model cannot escape the resolver gate", () =>
|
|
|
154
154
|
test("isAllowed refuses a tool whose rule the principal lacks", () => {
|
|
155
155
|
const registry = createAiToolRegistry();
|
|
156
156
|
let ran = false;
|
|
157
|
-
const adminTool = readTool("
|
|
157
|
+
const adminTool = readTool("ai_secrets", "ai.tools.manage", () => {
|
|
158
158
|
ran = true;
|
|
159
159
|
});
|
|
160
160
|
registry.register(adminTool);
|
|
@@ -168,7 +168,7 @@ describe("HARDENING: a misbehaving model cannot escape the resolver gate", () =>
|
|
|
168
168
|
|
|
169
169
|
test("a service principal (no access rules) is refused every tool", () => {
|
|
170
170
|
const registry = createAiToolRegistry();
|
|
171
|
-
const tool = readTool("
|
|
171
|
+
const tool = readTool("incident_list", "incident.incident.read", () => {});
|
|
172
172
|
registry.register(tool);
|
|
173
173
|
const resolver = createAiToolResolver({ registry });
|
|
174
174
|
const service: AuthUser = { type: "service", pluginId: "svc" };
|
|
@@ -181,7 +181,7 @@ describe("HARDENING: propose refuses a model-picked out-of-scope tool BEFORE dry
|
|
|
181
181
|
const registry = createAiToolRegistry();
|
|
182
182
|
let dryRan = false;
|
|
183
183
|
let executed = false;
|
|
184
|
-
const tool = mutateTool("
|
|
184
|
+
const tool = mutateTool("billing_refund", "billing.billing.manage", {
|
|
185
185
|
onDryRun: () => {
|
|
186
186
|
dryRan = true;
|
|
187
187
|
},
|
|
@@ -200,7 +200,7 @@ describe("HARDENING: propose refuses a model-picked out-of-scope tool BEFORE dry
|
|
|
200
200
|
await expect(
|
|
201
201
|
service.propose({
|
|
202
202
|
principal: limited, // lacks billing.billing.manage
|
|
203
|
-
toolName: "
|
|
203
|
+
toolName: "billing_refund",
|
|
204
204
|
input: { amount: 100 },
|
|
205
205
|
transport: "chat",
|
|
206
206
|
rpcClient,
|
|
@@ -217,7 +217,7 @@ describe("HARDENING: bad model-supplied args are rejected (no execution on garba
|
|
|
217
217
|
test("propose rejects args that fail the tool's own zod schema", async () => {
|
|
218
218
|
const registry = createAiToolRegistry();
|
|
219
219
|
let dryRan = false;
|
|
220
|
-
const tool = mutateTool("
|
|
220
|
+
const tool = mutateTool("incident_escalate", "incident.incident.read", {
|
|
221
221
|
onDryRun: () => {
|
|
222
222
|
dryRan = true;
|
|
223
223
|
},
|
|
@@ -237,7 +237,7 @@ describe("HARDENING: bad model-supplied args are rejected (no execution on garba
|
|
|
237
237
|
await expect(
|
|
238
238
|
service.propose({
|
|
239
239
|
principal: limited,
|
|
240
|
-
toolName: "
|
|
240
|
+
toolName: "incident_escalate",
|
|
241
241
|
input: { amount: -5 },
|
|
242
242
|
transport: "chat",
|
|
243
243
|
rpcClient,
|
|
@@ -253,9 +253,9 @@ describe("HARDENING: scope-narrowing can never WIDEN the surfaced toolset", () =
|
|
|
253
253
|
// only ever shrink the visible tools — never add one the principal lacks.
|
|
254
254
|
test("narrowing the principal's rules monotonically shrinks the visible tools", () => {
|
|
255
255
|
const registry = createAiToolRegistry();
|
|
256
|
-
registry.register(readTool("
|
|
257
|
-
registry.register(readTool("
|
|
258
|
-
registry.register(readTool("
|
|
256
|
+
registry.register(readTool("incident_list", "incident.incident.read", () => {}));
|
|
257
|
+
registry.register(readTool("hc_status", "healthcheck.config.read", () => {}));
|
|
258
|
+
registry.register(readTool("ai_secrets", "ai.tools.manage", () => {}));
|
|
259
259
|
const resolver = createAiToolResolver({ registry });
|
|
260
260
|
|
|
261
261
|
const wide: AuthUser = {
|
|
@@ -274,8 +274,8 @@ describe("HARDENING: scope-narrowing can never WIDEN the surfaced toolset", () =
|
|
|
274
274
|
|
|
275
275
|
// Narrowed is a strict subset — never a superset.
|
|
276
276
|
for (const name of narrowNames) expect(wideNames.has(name)).toBe(true);
|
|
277
|
-
expect(narrowNames.has("
|
|
278
|
-
expect(narrowNames.has("
|
|
277
|
+
expect(narrowNames.has("hc_status")).toBe(false);
|
|
278
|
+
expect(narrowNames.has("ai_secrets")).toBe(false);
|
|
279
279
|
// And the narrowing never invented a tool outside the wide set.
|
|
280
280
|
expect([...narrowNames].every((n) => wideNames.has(n))).toBe(true);
|
|
281
281
|
});
|
package/src/mcp/server.test.ts
CHANGED
|
@@ -47,12 +47,12 @@ function buildHandler({
|
|
|
47
47
|
}) => Promise<void>;
|
|
48
48
|
}) {
|
|
49
49
|
const registry = createAiToolRegistry();
|
|
50
|
-
const incidentTool = readTool("
|
|
51
|
-
const adminTool = readTool("
|
|
50
|
+
const incidentTool = readTool("incident_list", "incident.incident.read");
|
|
51
|
+
const adminTool = readTool("ai_secrets", "ai.tools.manage");
|
|
52
52
|
// A mutating tool the limited principal IS allowed for (same access rule as
|
|
53
53
|
// incident.list). The ONLY thing that may refuse a bare tools/call for it is
|
|
54
54
|
// the structural effect-gate, not the resolver.
|
|
55
|
-
const mutating = mutateTool("
|
|
55
|
+
const mutating = mutateTool("incident_close", "incident.incident.read");
|
|
56
56
|
registry.register(incidentTool);
|
|
57
57
|
registry.register(adminTool);
|
|
58
58
|
registry.register(mutating);
|
|
@@ -121,8 +121,8 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
121
121
|
);
|
|
122
122
|
const json = await res.json();
|
|
123
123
|
const names = json.result.tools.map((t: { name: string }) => t.name);
|
|
124
|
-
expect(names).toEqual(["
|
|
125
|
-
expect(names).not.toContain("
|
|
124
|
+
expect(names).toEqual(["incident_list"]);
|
|
125
|
+
expect(names).not.toContain("ai_secrets");
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
test("tools/list returns 401 for an unauthenticated caller", async () => {
|
|
@@ -150,7 +150,7 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
150
150
|
jsonrpc: "2.0",
|
|
151
151
|
id: 4,
|
|
152
152
|
method: "tools/call",
|
|
153
|
-
params: { name: "
|
|
153
|
+
params: { name: "ai_secrets", arguments: {} },
|
|
154
154
|
}),
|
|
155
155
|
);
|
|
156
156
|
expect(res.status).toBe(403);
|
|
@@ -175,7 +175,7 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
175
175
|
jsonrpc: "2.0",
|
|
176
176
|
id: 5,
|
|
177
177
|
method: "tools/call",
|
|
178
|
-
params: { name: "
|
|
178
|
+
params: { name: "incident_list", arguments: { status: "open" } },
|
|
179
179
|
},
|
|
180
180
|
"tok-123",
|
|
181
181
|
),
|
|
@@ -205,7 +205,7 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
205
205
|
jsonrpc: "2.0",
|
|
206
206
|
id: 7,
|
|
207
207
|
method: "tools/call",
|
|
208
|
-
params: { name: "
|
|
208
|
+
params: { name: "incident_close", arguments: {} },
|
|
209
209
|
}),
|
|
210
210
|
);
|
|
211
211
|
expect(res.status).toBe(403);
|
|
@@ -221,8 +221,8 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
221
221
|
);
|
|
222
222
|
const json = await res.json();
|
|
223
223
|
const names = json.result.tools.map((t: { name: string }) => t.name);
|
|
224
|
-
expect(names).toContain("
|
|
225
|
-
expect(names).not.toContain("
|
|
224
|
+
expect(names).toContain("incident_list");
|
|
225
|
+
expect(names).not.toContain("incident_close");
|
|
226
226
|
});
|
|
227
227
|
|
|
228
228
|
// §14.5: per-principal tool budget enforced on tools/call (shared-Postgres).
|
|
@@ -241,7 +241,7 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
241
241
|
jsonrpc: "2.0",
|
|
242
242
|
id: 9,
|
|
243
243
|
method: "tools/call",
|
|
244
|
-
params: { name: "
|
|
244
|
+
params: { name: "incident_list", arguments: {} },
|
|
245
245
|
}),
|
|
246
246
|
);
|
|
247
247
|
expect(res.status).toBe(429);
|
|
@@ -264,12 +264,12 @@ describe("MCP server (read-only Streamable-HTTP)", () => {
|
|
|
264
264
|
jsonrpc: "2.0",
|
|
265
265
|
id: 10,
|
|
266
266
|
method: "tools/call",
|
|
267
|
-
params: { name: "
|
|
267
|
+
params: { name: "incident_list", arguments: { status: "open" } },
|
|
268
268
|
}),
|
|
269
269
|
);
|
|
270
270
|
expect(res.status).toBe(200);
|
|
271
271
|
expect(recorded).toHaveLength(1);
|
|
272
|
-
expect(recorded[0]?.toolName).toBe("
|
|
272
|
+
expect(recorded[0]?.toolName).toBe("incident_list");
|
|
273
273
|
// The args hash is a SHA-256 hex digest, never the raw args.
|
|
274
274
|
expect(recorded[0]?.argsHash).toMatch(/^[0-9a-f]{64}$/);
|
|
275
275
|
});
|
|
@@ -135,7 +135,7 @@ function mutatingTool(
|
|
|
135
135
|
): RegisteredAiTool<{ value: string }, { created: string }> {
|
|
136
136
|
let executed = 0;
|
|
137
137
|
const tool: RegisteredAiTool<{ value: string }, { created: string }> = {
|
|
138
|
-
name: "
|
|
138
|
+
name: "demo_mutate",
|
|
139
139
|
description: "demo mutating tool",
|
|
140
140
|
effect: "mutate",
|
|
141
141
|
input: ManageInput,
|
|
@@ -200,7 +200,7 @@ describe("propose/apply lifecycle (matrix #11)", () => {
|
|
|
200
200
|
|
|
201
201
|
const proposal = await service.propose({
|
|
202
202
|
principal: allowed,
|
|
203
|
-
toolName: "
|
|
203
|
+
toolName: "demo_mutate",
|
|
204
204
|
input: { value: "alpha" },
|
|
205
205
|
transport: "chat",
|
|
206
206
|
});
|
|
@@ -222,7 +222,7 @@ describe("propose/apply lifecycle (matrix #11)", () => {
|
|
|
222
222
|
|
|
223
223
|
const proposal = await service.propose({
|
|
224
224
|
principal: allowed,
|
|
225
|
-
toolName: "
|
|
225
|
+
toolName: "demo_mutate",
|
|
226
226
|
input: { value: "beta" },
|
|
227
227
|
transport: "chat",
|
|
228
228
|
});
|
|
@@ -239,7 +239,7 @@ describe("propose/apply lifecycle (matrix #11)", () => {
|
|
|
239
239
|
const { service } = setup(tool);
|
|
240
240
|
const proposal = await service.propose({
|
|
241
241
|
principal: allowed,
|
|
242
|
-
toolName: "
|
|
242
|
+
toolName: "demo_mutate",
|
|
243
243
|
input: { value: "gamma" },
|
|
244
244
|
transport: "chat",
|
|
245
245
|
});
|
|
@@ -265,7 +265,7 @@ describe("propose/apply authorization (matrix #11 / decision 5)", () => {
|
|
|
265
265
|
await expect(
|
|
266
266
|
service.propose({
|
|
267
267
|
principal: notAllowed,
|
|
268
|
-
toolName: "
|
|
268
|
+
toolName: "demo_mutate",
|
|
269
269
|
input: { value: "x" },
|
|
270
270
|
transport: "chat",
|
|
271
271
|
}),
|
|
@@ -277,7 +277,7 @@ describe("propose/apply authorization (matrix #11 / decision 5)", () => {
|
|
|
277
277
|
const { service } = setup(tool);
|
|
278
278
|
const proposal = await service.propose({
|
|
279
279
|
principal: allowed,
|
|
280
|
-
toolName: "
|
|
280
|
+
toolName: "demo_mutate",
|
|
281
281
|
input: { value: "x" },
|
|
282
282
|
transport: "chat",
|
|
283
283
|
});
|
|
@@ -294,7 +294,7 @@ describe("propose/apply authorization (matrix #11 / decision 5)", () => {
|
|
|
294
294
|
await expect(
|
|
295
295
|
service.propose({
|
|
296
296
|
principal: allowed,
|
|
297
|
-
toolName: "
|
|
297
|
+
toolName: "demo_mutate",
|
|
298
298
|
input: { value: "x" },
|
|
299
299
|
transport: "chat",
|
|
300
300
|
}),
|
|
@@ -306,7 +306,7 @@ describe("propose/apply authorization (matrix #11 / decision 5)", () => {
|
|
|
306
306
|
await expect(
|
|
307
307
|
service.propose({
|
|
308
308
|
principal: { type: "service", pluginId: "x" },
|
|
309
|
-
toolName: "
|
|
309
|
+
toolName: "demo_mutate",
|
|
310
310
|
input: { value: "x" },
|
|
311
311
|
transport: "chat",
|
|
312
312
|
}),
|
|
@@ -320,7 +320,7 @@ describe("propose does NOT mutate (matrix #12)", () => {
|
|
|
320
320
|
const { service } = setup(tool);
|
|
321
321
|
await service.propose({
|
|
322
322
|
principal: allowed,
|
|
323
|
-
toolName: "
|
|
323
|
+
toolName: "demo_mutate",
|
|
324
324
|
input: { value: "x" },
|
|
325
325
|
transport: "chat",
|
|
326
326
|
});
|
|
@@ -334,7 +334,7 @@ describe("audit rows (matrix #13)", () => {
|
|
|
334
334
|
const { service, store } = setup(tool);
|
|
335
335
|
const proposal = await service.propose({
|
|
336
336
|
principal: allowed,
|
|
337
|
-
toolName: "
|
|
337
|
+
toolName: "demo_mutate",
|
|
338
338
|
input: { value: "x" },
|
|
339
339
|
transport: "chat",
|
|
340
340
|
});
|
|
@@ -358,7 +358,7 @@ describe("audit rows (matrix #13)", () => {
|
|
|
358
358
|
// Proposed by u1.
|
|
359
359
|
const proposal = await service.propose({
|
|
360
360
|
principal: allowed,
|
|
361
|
-
toolName: "
|
|
361
|
+
toolName: "demo_mutate",
|
|
362
362
|
input: { value: "x" },
|
|
363
363
|
transport: "chat",
|
|
364
364
|
});
|
|
@@ -386,7 +386,7 @@ describe("audit rows (matrix #13)", () => {
|
|
|
386
386
|
const { service, store } = setup(tool);
|
|
387
387
|
const proposal = await service.propose({
|
|
388
388
|
principal: allowed,
|
|
389
|
-
toolName: "
|
|
389
|
+
toolName: "demo_mutate",
|
|
390
390
|
input: { value: "x" },
|
|
391
391
|
transport: "chat",
|
|
392
392
|
});
|
|
@@ -411,7 +411,7 @@ describe("audit rows (matrix #13)", () => {
|
|
|
411
411
|
const { service, store } = setup(tool, () => current);
|
|
412
412
|
const proposal = await service.propose({
|
|
413
413
|
principal: allowed,
|
|
414
|
-
toolName: "
|
|
414
|
+
toolName: "demo_mutate",
|
|
415
415
|
input: { value: "x" },
|
|
416
416
|
transport: "chat",
|
|
417
417
|
});
|
|
@@ -30,7 +30,7 @@ function handAuthoredTool(): RegisteredAiTool {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
describe("createRegistryExtensionPoints (end-to-end registration)", () => {
|
|
33
|
-
test("registerTool qualifies an unqualified name
|
|
33
|
+
test("registerTool qualifies an unqualified name, registered provider-safe", () => {
|
|
34
34
|
const registry = createAiToolRegistry();
|
|
35
35
|
const { toolExtensionPoint } = createRegistryExtensionPoints({ registry });
|
|
36
36
|
|
|
@@ -39,11 +39,15 @@ describe("createRegistryExtensionPoints (end-to-end registration)", () => {
|
|
|
39
39
|
definePluginMetadata({ pluginId: "automation" }),
|
|
40
40
|
);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Qualified to `automation.propose`, then normalized to the provider-safe
|
|
43
|
+
// name set (the "." the provider rejects becomes "_").
|
|
44
|
+
expect(registry.hasTool("automation_propose")).toBe(true);
|
|
45
|
+
expect(registry.getTool("automation_propose")?.effect).toBe("mutate");
|
|
46
|
+
// The dotted form is NOT a key (the provider would never send it).
|
|
47
|
+
expect(registry.hasTool("automation.propose")).toBe(false);
|
|
44
48
|
});
|
|
45
49
|
|
|
46
|
-
test("registerTool leaves an already-qualified name unchanged", () => {
|
|
50
|
+
test("registerTool leaves an already-qualified name unchanged (modulo sanitization)", () => {
|
|
47
51
|
const registry = createAiToolRegistry();
|
|
48
52
|
const { toolExtensionPoint } = createRegistryExtensionPoints({ registry });
|
|
49
53
|
|
|
@@ -52,8 +56,10 @@ describe("createRegistryExtensionPoints (end-to-end registration)", () => {
|
|
|
52
56
|
definePluginMetadata({ pluginId: "different" }),
|
|
53
57
|
);
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
// Already qualified, so it is not re-prefixed with "different"; only "."
|
|
60
|
+
// is sanitized to "_".
|
|
61
|
+
expect(registry.hasTool("automation_propose")).toBe(true);
|
|
62
|
+
expect(registry.hasTool("different_automation_propose")).toBe(false);
|
|
57
63
|
});
|
|
58
64
|
|
|
59
65
|
test("expose builds and registers a projected tool from a contract procedure", () => {
|
|
@@ -72,7 +78,8 @@ describe("createRegistryExtensionPoints (end-to-end registration)", () => {
|
|
|
72
78
|
execute: () => Promise.resolve({}),
|
|
73
79
|
});
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
// The authored name "incident.list" is normalized to the provider-safe key.
|
|
82
|
+
const tool = registry.getTool("incident_list");
|
|
76
83
|
expect(tool).toBeDefined();
|
|
77
84
|
// Access rules read verbatim from the source procedure, qualified.
|
|
78
85
|
expect(tool?.requiredAccessRules).toEqual(["incident.incident.read"]);
|
|
@@ -98,9 +105,10 @@ describe("createRegistryExtensionPoints (end-to-end registration)", () => {
|
|
|
98
105
|
execute: () => Promise.resolve({}),
|
|
99
106
|
});
|
|
100
107
|
|
|
108
|
+
// Registry keys/names are the provider-safe form of each authored name.
|
|
101
109
|
expect(registry.getTools().map((t) => t.name).sort()).toEqual([
|
|
102
|
-
"
|
|
103
|
-
"
|
|
110
|
+
"automation_propose",
|
|
111
|
+
"incident_list",
|
|
104
112
|
]);
|
|
105
113
|
});
|
|
106
114
|
|
package/src/registry-wiring.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
AiToolProjectionExtensionPoint,
|
|
5
5
|
} from "./extension-points";
|
|
6
6
|
import { buildProjectedTool } from "./projection";
|
|
7
|
+
import { toProviderToolName } from "./tool-name";
|
|
7
8
|
import type { AiToolRegistry } from "./tool-registry";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -57,7 +58,10 @@ export function createRegistryExtensionPoints({
|
|
|
57
58
|
const tool = buildProjectedTool(input);
|
|
58
59
|
registry.register(tool);
|
|
59
60
|
exposedProjections.push({
|
|
60
|
-
|
|
61
|
+
// Match the registry's canonical (provider-safe) key so the chat
|
|
62
|
+
// read-loop and MCP transport resolve this route by the same name the
|
|
63
|
+
// model is given and echoes back.
|
|
64
|
+
toolName: toProviderToolName(tool.name),
|
|
61
65
|
pluginId: input.sourcePluginMetadata.pluginId,
|
|
62
66
|
procedureKey: input.procedureKey,
|
|
63
67
|
});
|
package/src/resolver.test.ts
CHANGED
|
@@ -26,24 +26,24 @@ describe("createAiToolResolver.resolveTools", () => {
|
|
|
26
26
|
test("a principal lacking automation.manage never sees automation.propose", () => {
|
|
27
27
|
const registry = createAiToolRegistry();
|
|
28
28
|
registry.register(
|
|
29
|
-
tool("
|
|
29
|
+
tool("automation_propose", ["automation.automation.manage"]),
|
|
30
30
|
);
|
|
31
|
-
registry.register(tool("
|
|
31
|
+
registry.register(tool("incident_list", ["incident.incident.read"]));
|
|
32
32
|
const resolver = createAiToolResolver({ registry });
|
|
33
33
|
|
|
34
34
|
const principal = userWith(["incident.incident.read"]);
|
|
35
35
|
const names = resolver.resolveTools(principal).map((t) => t.name);
|
|
36
36
|
|
|
37
|
-
expect(names).toEqual(["
|
|
38
|
-
expect(names).not.toContain("
|
|
37
|
+
expect(names).toEqual(["incident_list"]);
|
|
38
|
+
expect(names).not.toContain("automation_propose");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
test("an admin (accessRules ['*']) sees all tools", () => {
|
|
42
42
|
const registry = createAiToolRegistry();
|
|
43
43
|
registry.register(
|
|
44
|
-
tool("
|
|
44
|
+
tool("automation_propose", ["automation.automation.manage"]),
|
|
45
45
|
);
|
|
46
|
-
registry.register(tool("
|
|
46
|
+
registry.register(tool("incident_list", ["incident.incident.read"]));
|
|
47
47
|
const resolver = createAiToolResolver({ registry });
|
|
48
48
|
|
|
49
49
|
const names = resolver
|
|
@@ -51,12 +51,12 @@ describe("createAiToolResolver.resolveTools", () => {
|
|
|
51
51
|
.map((t) => t.name)
|
|
52
52
|
.sort();
|
|
53
53
|
|
|
54
|
-
expect(names).toEqual(["
|
|
54
|
+
expect(names).toEqual(["automation_propose", "incident_list"]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
test("a service principal (no access rules) sees no tools", () => {
|
|
58
58
|
const registry = createAiToolRegistry();
|
|
59
|
-
registry.register(tool("
|
|
59
|
+
registry.register(tool("incident_list", ["incident.incident.read"]));
|
|
60
60
|
const resolver = createAiToolResolver({ registry });
|
|
61
61
|
|
|
62
62
|
const service: AuthUser = { type: "service", pluginId: "automation" };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PROVIDER_TOOL_NAME_PATTERN, toProviderToolName } from "./tool-name";
|
|
3
|
+
|
|
4
|
+
describe("toProviderToolName", () => {
|
|
5
|
+
test("maps the '.' namespace separator to '_'", () => {
|
|
6
|
+
expect(toProviderToolName("incident.list")).toBe("incident_list");
|
|
7
|
+
expect(toProviderToolName("catalog.listSystems")).toBe(
|
|
8
|
+
"catalog_listSystems",
|
|
9
|
+
);
|
|
10
|
+
expect(toProviderToolName("dependency.list")).toBe("dependency_list");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("maps every '.' in a multi-dot name", () => {
|
|
14
|
+
expect(toProviderToolName("a.b.c")).toBe("a_b_c");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("leaves an already provider-safe name unchanged", () => {
|
|
18
|
+
expect(toProviderToolName("incident_list")).toBe("incident_list");
|
|
19
|
+
expect(toProviderToolName("get-status")).toBe("get-status");
|
|
20
|
+
expect(toProviderToolName("Tool_123")).toBe("Tool_123");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the result always matches the provider pattern", () => {
|
|
24
|
+
for (const name of ["incident.list", "a.b.c", "get-status", "x"]) {
|
|
25
|
+
expect(PROVIDER_TOOL_NAME_PATTERN.test(toProviderToolName(name))).toBe(
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("throws on an illegal character rather than silently rewriting it", () => {
|
|
32
|
+
expect(() => toProviderToolName("foo$bar")).toThrow(/Invalid AI tool name/);
|
|
33
|
+
expect(() => toProviderToolName("with space")).toThrow(
|
|
34
|
+
/Invalid AI tool name/,
|
|
35
|
+
);
|
|
36
|
+
expect(() => toProviderToolName("emoji😀")).toThrow(/Invalid AI tool name/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("throws on an empty name", () => {
|
|
40
|
+
expect(() => toProviderToolName("")).toThrow(/Invalid AI tool name/);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/tool-name.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider tool-name constraint.
|
|
3
|
+
*
|
|
4
|
+
* LLM providers (and the MCP spec) require tool names to match
|
|
5
|
+
* `^[a-zA-Z0-9_-]+$`.
|
|
6
|
+
*/
|
|
7
|
+
export const PROVIDER_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a canonical tool name to its provider-safe form.
|
|
11
|
+
*
|
|
12
|
+
* Tool names are qualified as `<plugin>.<tool>` - the "." is the namespace
|
|
13
|
+
* separator of the naming convention, which the provider rejects. It is mapped
|
|
14
|
+
* deterministically to "_" (so `incident.list` -> `incident_list`).
|
|
15
|
+
*
|
|
16
|
+
* Any OTHER disallowed character is an authoring mistake in the tool
|
|
17
|
+
* definition, not stray input, so it is NOT silently rewritten: this throws so
|
|
18
|
+
* the bad name surfaces at registration (startup) instead of being masked.
|
|
19
|
+
*
|
|
20
|
+
* The mapping is applied at the single registration chokepoint (the tool
|
|
21
|
+
* registry) and the projection-routing table, so the registry key, the name
|
|
22
|
+
* sent to the model / MCP client, and the name the model echoes back in a tool
|
|
23
|
+
* call are all identical - the round-trip (name-out === name-in) holds without
|
|
24
|
+
* any reverse lookup.
|
|
25
|
+
*/
|
|
26
|
+
export function toProviderToolName(name: string): string {
|
|
27
|
+
const normalized = name.replaceAll(".", "_");
|
|
28
|
+
if (!PROVIDER_TOOL_NAME_PATTERN.test(normalized)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid AI tool name "${name}": after normalizing the "." separator to ` +
|
|
31
|
+
`"_" it must match ${String(PROVIDER_TOOL_NAME_PATTERN)} (letters, ` +
|
|
32
|
+
`digits, "_" and "-" only). Rename the tool to use only those ` +
|
|
33
|
+
`characters, with "." reserved for the <plugin>.<tool> separator.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
2
2
|
import type { AiTool } from "@checkstack/ai-common";
|
|
3
|
+
import { toProviderToolName } from "./tool-name";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A tool whose executors run with a Checkstack {@link AuthUser} principal and
|
|
@@ -21,7 +22,12 @@ export type RegisteredAiTool<TInput = unknown, TOutput = unknown> = AiTool<
|
|
|
21
22
|
* registry, so no capability is implemented twice.
|
|
22
23
|
*
|
|
23
24
|
* Tool names are already fully qualified (`<plugin>.<tool>`) by the extension
|
|
24
|
-
* points before they reach `register`.
|
|
25
|
+
* points before they reach `register`. `register` then maps the name to its
|
|
26
|
+
* provider-safe form (see {@link toProviderToolName}) and uses that as the
|
|
27
|
+
* canonical key, so every consumer (serializer, chat SDK tools, MCP
|
|
28
|
+
* `tools/list`, and the tool-call resolution path) sees and resolves the same
|
|
29
|
+
* provider-safe name. A name with an illegal character (beyond the "."
|
|
30
|
+
* separator) is rejected here rather than rewritten.
|
|
25
31
|
*/
|
|
26
32
|
export interface AiToolRegistry {
|
|
27
33
|
register(tool: RegisteredAiTool): void;
|
|
@@ -35,12 +41,16 @@ export function createAiToolRegistry(): AiToolRegistry {
|
|
|
35
41
|
|
|
36
42
|
return {
|
|
37
43
|
register(tool: RegisteredAiTool): void {
|
|
38
|
-
|
|
44
|
+
// Map to the provider-safe name (e.g. `incident.list` -> `incident_list`)
|
|
45
|
+
// and key the registry on it, so the name sent to the model and the name
|
|
46
|
+
// it echoes back both match this entry. Throws on an illegal name.
|
|
47
|
+
const name = toProviderToolName(tool.name);
|
|
48
|
+
if (tools.has(name)) {
|
|
39
49
|
throw new Error(
|
|
40
|
-
`AI tool ${
|
|
50
|
+
`AI tool ${name} already registered — likely a duplicate registration.`,
|
|
41
51
|
);
|
|
42
52
|
}
|
|
43
|
-
tools.set(tool.name,
|
|
53
|
+
tools.set(name, name === tool.name ? tool : { ...tool, name });
|
|
44
54
|
},
|
|
45
55
|
|
|
46
56
|
getTools(): RegisteredAiTool[] {
|
|
@@ -120,7 +120,7 @@ describe("docs tools registration + resolution", () => {
|
|
|
120
120
|
.resolveTools(userWith(["ai.chat.read"]))
|
|
121
121
|
.map((t) => t.name)
|
|
122
122
|
.sort();
|
|
123
|
-
expect(names).toEqual(["
|
|
123
|
+
expect(names).toEqual(["ai_getDoc", "ai_searchDocs"]);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
test("a principal without ai.chat.read sees neither docs tool", () => {
|
|
@@ -38,7 +38,7 @@ describe("ai-backend's own platform tool set", () => {
|
|
|
38
38
|
test("docs + probe tools are registered and qualified", () => {
|
|
39
39
|
const registry = buildOwnRegistry();
|
|
40
40
|
const names = registry.getTools().map((t) => t.name);
|
|
41
|
-
for (const expected of ["
|
|
41
|
+
for (const expected of ["ai_searchDocs", "ai_getDoc", "ai_probeUrl"]) {
|
|
42
42
|
expect(names).toContain(expected);
|
|
43
43
|
}
|
|
44
44
|
});
|