@checkstack/ai-backend 0.1.5 → 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 CHANGED
@@ -1,5 +1,61 @@
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
+
29
+ ## 0.1.6
30
+
31
+ ### Patch Changes
32
+
33
+ - f9cfdae: fix(dependency): gate the dependency map behind its own non-public access rule
34
+
35
+ Anonymous users could see the "Dependency Map" nav entry and open the page
36
+ (which then rendered empty) because the map was gated by `dependency.read`,
37
+ which is public so that dependency _warning_ badges stay visible on the
38
+ catalog and dashboard.
39
+
40
+ The full topology map is now gated by a dedicated `dependency.map` access
41
+ rule that is granted to authenticated users by default but is NOT public, so
42
+ anonymous visitors no longer see the nav entry or reach the page. The
43
+ `getAllDependencies`, `getNodePositions`, and `saveNodePositions` endpoints
44
+ move to this rule too, and the dashboard dependency signal now renders as
45
+ plain text (not a map link) for users without map access. Per-system
46
+ dependency warnings stay on the public `dependency.read` rule, so warning
47
+ badges/alerts/signals remain visible to everyone as before.
48
+
49
+ Admins can still grant `dependency.map` to the anonymous role to make the
50
+ map public again.
51
+
52
+ Note: the default-rule sync is add-only, so on existing deployments the
53
+ anonymous role keeps any rules already granted. Since `dependency.map` is a
54
+ brand-new rule the anonymous role never had it, so the map is hidden from
55
+ anonymous users immediately after upgrade with no admin action required.
56
+
57
+ - @checkstack/sdk@0.101.1
58
+
3
59
  ## 0.1.5
4
60
 
5
61
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/ai-backend",
3
- "version": "0.1.5",
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.100.1",
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",
@@ -47,14 +47,14 @@ describe("createAgentRunner", () => {
47
47
  const registry = createAiToolRegistry();
48
48
  const calls: string[] = [];
49
49
  registry.register(
50
- readTool("plugin.read", async () => {
51
- calls.push("plugin.read");
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: "plugin.delete",
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: "plugin.projected",
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 ?? {})["plugin.read"] as {
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(["plugin.read"]);
106
- expect(calls).toEqual(["plugin.read"]);
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: "plugin.read", ok: true }]);
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: "plugin.history",
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 ?? {})["plugin.history"];
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: "incident.list",
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 ?? {})["incident.list"] as {
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 === "incident.list"
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(["incident.list"]);
213
+ expect(offered).toEqual(["incident_list"]);
214
214
  expect(procCalls).toEqual([{ status: "open" }]);
215
- expect(result.toolCalls).toEqual([{ tool: "incident.list", ok: true }]);
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("plugin.boom", async () => {
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 ?? {})["plugin.boom"] as {
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: "plugin.boom", ok: false }]);
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("plugin.ok", async () => ({ ok: true })));
256
+ registry.register(readTool("plugin_ok", async () => ({ ok: true })));
257
257
  registry.register(
258
- readTool("plugin.boom", async () => {
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["plugin.ok"] as { execute: (i: unknown) => Promise<unknown> }).execute({});
277
- await (tools["plugin.boom"] as { execute: (i: unknown) => Promise<unknown> }).execute({});
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: "plugin.ok",
290
+ toolName: "plugin_ok",
291
291
  ok: true,
292
292
  effect: "read",
293
293
  });
294
294
  expect(recorded).toContainEqual({
295
- toolName: "plugin.boom",
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("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");
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(["incident.list"]);
61
- expect(offered).not.toContain("automation.propose");
62
- expect(offered).not.toContain("incident.delete");
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: "automation.propose",
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: "incident.list",
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: "automation.propose",
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: "incident.delete",
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: "demo.mutate",
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("demo.mutate");
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);
@@ -2244,7 +2244,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
2244
2244
  "Where this maps in the data model",
2245
2245
  "Where to go next"
2246
2246
  ],
2247
- "content": "The catalog is the backbone of Checkstack. Everything else (health checks, incidents, maintenances, notifications) attaches to a System. This page explains what a System is, how Groups organise them, and how Dependencies model real-world impact between Systems.\n\n## Systems\n\nA **System** is the smallest unit you monitor. It usually maps to one logical service in your stack: a database, an API, a worker, a third-party endpoint, a Minecraft server, a Jenkins controller, anything you want to know the health of.\n\nEvery system carries:\n\n- A **name** (required) and an optional **description**.\n- **Contacts**, which are either platform users or free-form mailboxes (email addresses). Contacts surface on the system detail page so anyone responding to an incident knows who owns it.\n- **Links**, which are free-form URL hotlinks (runbooks, Jira boards, dashboards) shown alongside the system.\n- Membership in zero or more **Groups**.\n\n> [!TIP]\n> Be ruthless about what counts as a System. One System per service is the right granularity. If you find yourself making a \"Foo - production\" and \"Foo - staging\" pair, that is fine; if you start making \"Foo - login flow\" and \"Foo - checkout flow\", you have probably blurred the line between a System and a health check.\n\n### What a System is not\n\nA System is not the same as a host, an environment, or a Kubernetes pod. It is the *logical* thing you care about. The health check is what decides \"this URL on this host is the way I observe it\".\n\n## Groups\n\nA **Group** is a flat label that bundles related systems together. Use groups to model:\n\n- **Teams.** \"Payments\", \"Identity\", \"Platform\".\n- **Tiers.** \"Tier 1\", \"Customer-facing\", \"Internal-only\".\n- **Domains.** \"Production\", \"Staging\" (though you can also model environments as separate systems).\n\nGroups are flat. Checkstack does not nest groups inside other groups today. A system can belong to multiple groups, so you can cross-cut by team and by tier at the same time.\n\n> [!NOTE]\n> Subscribing to notifications for a group automatically catches every system in that group. When the catalog adds or removes a system from the group, group subscribers update instantly without you re-subscribing per system.\n\nGroups are managed under **Catalog -> Groups** in the UI. You can drag systems between groups, rename groups in place, and delete them when they become empty. The management page carries the same toolbar as the browse view, so you can search and filter the systems and groups lists while you arrange them.\n\n> [!TIP]\n> Drag-and-drop assignment is keyboard-operable. Focus a system's drag handle, press Space or Enter to pick it up, move between groups with the arrow keys, and press Space or Enter again to drop it (Escape cancels). The assign button on each system row is an equivalent pointer-free alternative.\n\n## Dependencies\n\nA **Dependency** is a directional edge between two systems: \"Payment API depends on Payment DB\". When the upstream system is unhealthy, the downstream system's effective state reflects that impact.\n\nEach dependency carries an **impact type**:\n\n- **`informational`** records the link in the dependency map but does not change downstream state.\n- **`degraded`** marks the downstream system as degraded if the upstream is unhealthy.\n- **`critical`** marks the downstream system as unhealthy if the upstream is unhealthy.\n\n```\n depends on\nPayment API ----------> Payment DB\n (downstream) (upstream)\n impactType: critical\n```\n\nYou can attach optional **per-health-check rules** to a dependency. By default the impact applies whenever the upstream system is unhealthy on any of its checks; with rules you can scope the impact to specific checks only. For example, \"Payment API only goes degraded when Payment DB's TLS check fails, not when its replication-lag check fails.\"\n\nA dependency can also be marked **transitive** to let it cascade further down the chain.\n\n> [!IMPORTANT]\n> Dependencies do not auto-open incidents. They affect derived health state and which alerts get suppressed in cascades, nothing more. See [Incidents](/checkstack/user-guide/concepts/incidents/) for the human workflow.\n\nThe dependency map lives under **Catalog -> Dependencies**. Node positions are saved per user, so your layout follows you across devices.\n\n## Putting it together\n\nA small example of how the pieces compose for an e-commerce stack:\n\n```\nGroups:\n - \"Payments team\": [Payment API, Payment DB, Stripe webhook]\n - \"Tier 1\": [Payment API, Checkout API, Storefront]\n\nSystems:\n Storefront ----(critical)---> Checkout API\n Checkout API ----(critical)---> Payment API\n Payment API ----(critical)---> Payment DB\n ----(degraded)---> Stripe webhook\n```\n\nA failing Payment DB now drives the derived state for Payment API, Checkout API, and Storefront. A failing Stripe webhook only degrades Payment API. Anyone subscribed to the \"Payments team\" group sees the relevant notifications; anyone subscribed to \"Tier 1\" sees the customer-facing ones.\n\n## Browsing the catalog\n\nThe catalog home page is a read-only, group-first browse view. It is the landing page for everyone with catalog read access, managers and non-managers alike. It is built to stay legible at hundreds of systems across many groups.\n\nThe view organises systems into collapsible **group sections**. Each section header shows the group name and its member count. A synthetic **Ungrouped** section collects systems that belong to no group. Because a system can belong to several groups, it appears under each group it is a member of.\n\n### Inline health rollups\n\nWhen a health source such as the healthcheck plugin is installed, each group header also shows a **health rollup** pill summarising its members at a glance: \"All healthy\", \"N degraded\", or \"N unhealthy\" (the worst member status wins, with a count). Individual unhealthy or degraded systems still show their own badge on the row; healthy systems show none, so the absence of a badge means healthy or not yet measured.\n\nThe rollup drives the default open state: groups where every member is healthy start **collapsed** so your attention goes to the groups that need it, while any group with a degraded, unhealthy, or not-yet-measured member starts **expanded**. You can always toggle a section open or closed yourself, and that choice is captured in the URL. With no health source installed, headers show member counts only and every group starts expanded.\n\nThe **Health** filter then lets you narrow to a single state. Selecting `unknown` shows systems with no measured health (no checks wired yet); a system with no reported health is never silently counted as healthy.\n\nA toolbar above the sections lets you narrow what you see:\n\n- **Search** matches systems and groups by name and description, case-insensitively. A match inside a collapsed group auto-expands that group; groups with no match drop out while you are searching.\n- **Group**, **Health**, and **Tag** filters narrow the list further. Filters compose: applying more than one shows only the systems that satisfy all of them. (The Health filter activates once a health source such as the healthcheck plugin is installed.)\n- **Density** switches rows between Comfortable (descriptions shown inline) and Compact (single-line rows, descriptions on hover).\n\nEvery part of the view state lives in the URL, so a filtered, searched view is a shareable link. For example:\n\n```text\n/catalog/?q=checkout&group=payments&density=compact\n```\n\nopens the catalog pre-filtered to the Payments group, searching for \"checkout\", in compact density. Open or closed group sections are captured in the link too, so a teammate opening it sees exactly what you see.\n\nManagers see a **Manage catalog** link in the header that jumps to the management page below.\n\n## UI tour\n\n| Where to go | What you do there |\n|-------------|-------------------|\n| **Catalog** (home) | Browse, search, and filter every system, grouped by team or domain. Read-only. |\n| **Catalog -> Systems** | Create, edit, and delete systems. Set contacts and hotlinks. |\n| **Catalog -> Groups** | Create groups, drag systems in and out. |\n| **Catalog -> Dependencies** | Visual graph editor. Click a system to connect it to another. |\n| **System detail page** | See attached health checks, recent runs, contacts, links, and the systems that depend on it. |\n\n## Where this maps in the data model\n\nFor operators who want to peek behind the curtain:\n\n- Systems live in the `catalog-backend` plugin's schema, in the `systems` table.\n- Groups live in the `groups` table; the join is `systems_groups`.\n- Hotlinks and contacts live in `system_links` and `system_contacts`.\n- Dependencies are stored by `dependency-backend` in the `dependencies` table.\n\nYou should rarely need to query these directly, but the structure is open: every read happens through the platform's typed RPC and respects [Teams and access](/checkstack/user-guide/concepts/teams-and-access/).\n\n## Where to go next\n\n- **First system, first check.** Walk through [Set up your first health check](/checkstack/user-guide/guides/first-health-check/).\n- **Notifications.** Read [Notifications](/checkstack/user-guide/concepts/notifications/) to understand how group membership drives delivery.\n- **YAML-as-code.** The [GitOps](/checkstack/user-guide/concepts/gitops/) flow lets you express systems, groups, and dependencies as YAML in a Git repo.",
2247
+ "content": "The catalog is the backbone of Checkstack. Everything else (health checks, incidents, maintenances, notifications) attaches to a System. This page explains what a System is, how Groups organise them, and how Dependencies model real-world impact between Systems.\n\n## Systems\n\nA **System** is the smallest unit you monitor. It usually maps to one logical service in your stack: a database, an API, a worker, a third-party endpoint, a Minecraft server, a Jenkins controller, anything you want to know the health of.\n\nEvery system carries:\n\n- A **name** (required) and an optional **description**.\n- **Contacts**, which are either platform users or free-form mailboxes (email addresses). Contacts surface on the system detail page so anyone responding to an incident knows who owns it.\n- **Links**, which are free-form URL hotlinks (runbooks, Jira boards, dashboards) shown alongside the system.\n- Membership in zero or more **Groups**.\n\n> [!TIP]\n> Be ruthless about what counts as a System. One System per service is the right granularity. If you find yourself making a \"Foo - production\" and \"Foo - staging\" pair, that is fine; if you start making \"Foo - login flow\" and \"Foo - checkout flow\", you have probably blurred the line between a System and a health check.\n\n### What a System is not\n\nA System is not the same as a host, an environment, or a Kubernetes pod. It is the *logical* thing you care about. The health check is what decides \"this URL on this host is the way I observe it\".\n\n## Groups\n\nA **Group** is a flat label that bundles related systems together. Use groups to model:\n\n- **Teams.** \"Payments\", \"Identity\", \"Platform\".\n- **Tiers.** \"Tier 1\", \"Customer-facing\", \"Internal-only\".\n- **Domains.** \"Production\", \"Staging\" (though you can also model environments as separate systems).\n\nGroups are flat. Checkstack does not nest groups inside other groups today. A system can belong to multiple groups, so you can cross-cut by team and by tier at the same time.\n\n> [!NOTE]\n> Subscribing to notifications for a group automatically catches every system in that group. When the catalog adds or removes a system from the group, group subscribers update instantly without you re-subscribing per system.\n\nGroups are managed under **Catalog -> Groups** in the UI. You can drag systems between groups, rename groups in place, and delete them when they become empty. The management page carries the same toolbar as the browse view, so you can search and filter the systems and groups lists while you arrange them.\n\n> [!TIP]\n> Drag-and-drop assignment is keyboard-operable. Focus a system's drag handle, press Space or Enter to pick it up, move between groups with the arrow keys, and press Space or Enter again to drop it (Escape cancels). The assign button on each system row is an equivalent pointer-free alternative.\n\n## Dependencies\n\nA **Dependency** is a directional edge between two systems: \"Payment API depends on Payment DB\". When the upstream system is unhealthy, the downstream system's effective state reflects that impact.\n\nEach dependency carries an **impact type**:\n\n- **`informational`** records the link in the dependency map but does not change downstream state.\n- **`degraded`** marks the downstream system as degraded if the upstream is unhealthy.\n- **`critical`** marks the downstream system as unhealthy if the upstream is unhealthy.\n\n```\n depends on\nPayment API ----------> Payment DB\n (downstream) (upstream)\n impactType: critical\n```\n\nYou can attach optional **per-health-check rules** to a dependency. By default the impact applies whenever the upstream system is unhealthy on any of its checks; with rules you can scope the impact to specific checks only. For example, \"Payment API only goes degraded when Payment DB's TLS check fails, not when its replication-lag check fails.\"\n\nA dependency can also be marked **transitive** to let it cascade further down the chain.\n\n> [!IMPORTANT]\n> Dependencies do not auto-open incidents. They affect derived health state and which alerts get suppressed in cascades, nothing more. See [Incidents](/checkstack/user-guide/concepts/incidents/) for the human workflow.\n\nThe dependency map lives under **Workspace -> Dependency Map**. Node positions are saved per user, so your layout follows you across devices.\n\n> [!NOTE]\n> The full dependency map is gated by its own access rule and is shown to signed-in users by default, not to anonymous visitors. Per-system dependency *warnings* (the badges and dashboard signals) stay public, so anonymous visitors still see when a system is affected by an upstream problem - they just do not get the full topology view. Admins can grant the map to the anonymous role under **Teams & access** to make it public.\n\n## Putting it together\n\nA small example of how the pieces compose for an e-commerce stack:\n\n```\nGroups:\n - \"Payments team\": [Payment API, Payment DB, Stripe webhook]\n - \"Tier 1\": [Payment API, Checkout API, Storefront]\n\nSystems:\n Storefront ----(critical)---> Checkout API\n Checkout API ----(critical)---> Payment API\n Payment API ----(critical)---> Payment DB\n ----(degraded)---> Stripe webhook\n```\n\nA failing Payment DB now drives the derived state for Payment API, Checkout API, and Storefront. A failing Stripe webhook only degrades Payment API. Anyone subscribed to the \"Payments team\" group sees the relevant notifications; anyone subscribed to \"Tier 1\" sees the customer-facing ones.\n\n## Browsing the catalog\n\nThe catalog home page is a read-only, group-first browse view. It is the landing page for everyone with catalog read access, managers and non-managers alike. It is built to stay legible at hundreds of systems across many groups.\n\nThe view organises systems into collapsible **group sections**. Each section header shows the group name and its member count. A synthetic **Ungrouped** section collects systems that belong to no group. Because a system can belong to several groups, it appears under each group it is a member of.\n\n### Inline health rollups\n\nWhen a health source such as the healthcheck plugin is installed, each group header also shows a **health rollup** pill summarising its members at a glance: \"All healthy\", \"N degraded\", or \"N unhealthy\" (the worst member status wins, with a count). Individual unhealthy or degraded systems still show their own badge on the row; healthy systems show none, so the absence of a badge means healthy or not yet measured.\n\nThe rollup drives the default open state: groups where every member is healthy start **collapsed** so your attention goes to the groups that need it, while any group with a degraded, unhealthy, or not-yet-measured member starts **expanded**. You can always toggle a section open or closed yourself, and that choice is captured in the URL. With no health source installed, headers show member counts only and every group starts expanded.\n\nThe **Health** filter then lets you narrow to a single state. Selecting `unknown` shows systems with no measured health (no checks wired yet); a system with no reported health is never silently counted as healthy.\n\nA toolbar above the sections lets you narrow what you see:\n\n- **Search** matches systems and groups by name and description, case-insensitively. A match inside a collapsed group auto-expands that group; groups with no match drop out while you are searching.\n- **Group**, **Health**, and **Tag** filters narrow the list further. Filters compose: applying more than one shows only the systems that satisfy all of them. (The Health filter activates once a health source such as the healthcheck plugin is installed.)\n- **Density** switches rows between Comfortable (descriptions shown inline) and Compact (single-line rows, descriptions on hover).\n\nEvery part of the view state lives in the URL, so a filtered, searched view is a shareable link. For example:\n\n```text\n/catalog/?q=checkout&group=payments&density=compact\n```\n\nopens the catalog pre-filtered to the Payments group, searching for \"checkout\", in compact density. Open or closed group sections are captured in the link too, so a teammate opening it sees exactly what you see.\n\nManagers see a **Manage catalog** link in the header that jumps to the management page below.\n\n## UI tour\n\n| Where to go | What you do there |\n|-------------|-------------------|\n| **Catalog** (home) | Browse, search, and filter every system, grouped by team or domain. Read-only. |\n| **Catalog -> Systems** | Create, edit, and delete systems. Set contacts and hotlinks. |\n| **Catalog -> Groups** | Create groups, drag systems in and out. |\n| **Catalog -> Dependencies** | Visual graph editor. Click a system to connect it to another. |\n| **System detail page** | See attached health checks, recent runs, contacts, links, and the systems that depend on it. |\n\n## Where this maps in the data model\n\nFor operators who want to peek behind the curtain:\n\n- Systems live in the `catalog-backend` plugin's schema, in the `systems` table.\n- Groups live in the `groups` table; the join is `systems_groups`.\n- Hotlinks and contacts live in `system_links` and `system_contacts`.\n- Dependencies are stored by `dependency-backend` in the `dependencies` table.\n\nYou should rarely need to query these directly, but the structure is open: every read happens through the platform's typed RPC and respects [Teams and access](/checkstack/user-guide/concepts/teams-and-access/).\n\n## Where to go next\n\n- **First system, first check.** Walk through [Set up your first health check](/checkstack/user-guide/guides/first-health-check/).\n- **Notifications.** Read [Notifications](/checkstack/user-guide/concepts/notifications/) to understand how group membership drives delivery.\n- **YAML-as-code.** The [GitOps](/checkstack/user-guide/concepts/gitops/) flow lets you express systems, groups, and dependencies as YAML in a Git repo.",
2248
2248
  "truncated": false
2249
2249
  },
2250
2250
  {
@@ -3019,4 +3019,4 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
3019
3019
  ];
3020
3020
 
3021
3021
  /** A content hash of the source tree, so a CI check can detect drift. */
3022
- export const DOCS_INDEX_HASH = "a37429cd2e6f71d57ce52a8084d890e51b47d6c5e662a6ab576e4a05875528d8";
3022
+ export const DOCS_INDEX_HASH = "eb86869a0791795f24c6cad94a2025f66e1314c631a2a320bf5658428d8d157d";
@@ -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("ai.secrets", "ai.tools.manage", () => {
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("incident.list", "incident.incident.read", () => {});
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("billing.refund", "billing.billing.manage", {
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: "billing.refund",
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("incident.escalate", "incident.incident.read", {
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: "incident.escalate",
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("incident.list", "incident.incident.read", () => {}));
257
- registry.register(readTool("hc.status", "healthcheck.config.read", () => {}));
258
- registry.register(readTool("ai.secrets", "ai.tools.manage", () => {}));
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("hc.status")).toBe(false);
278
- expect(narrowNames.has("ai.secrets")).toBe(false);
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
  });
@@ -47,12 +47,12 @@ function buildHandler({
47
47
  }) => Promise<void>;
48
48
  }) {
49
49
  const registry = createAiToolRegistry();
50
- const incidentTool = readTool("incident.list", "incident.incident.read");
51
- const adminTool = readTool("ai.secrets", "ai.tools.manage");
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("incident.close", "incident.incident.read");
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(["incident.list"]);
125
- expect(names).not.toContain("ai.secrets");
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: "ai.secrets", arguments: {} },
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: "incident.list", arguments: { status: "open" } },
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: "incident.close", arguments: {} },
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("incident.list");
225
- expect(names).not.toContain("incident.close");
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: "incident.list", arguments: {} },
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: "incident.list", arguments: { status: "open" } },
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("incident.list");
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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: "demo.mutate",
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 with the plugin id", () => {
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
- expect(registry.hasTool("automation.propose")).toBe(true);
43
- expect(registry.getTool("automation.propose")?.effect).toBe("mutate");
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
- expect(registry.hasTool("automation.propose")).toBe(true);
56
- expect(registry.hasTool("different.automation.propose")).toBe(false);
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
- const tool = registry.getTool("incident.list");
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
- "automation.propose",
103
- "incident.list",
110
+ "automation_propose",
111
+ "incident_list",
104
112
  ]);
105
113
  });
106
114
 
@@ -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
- toolName: tool.name,
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
  });
@@ -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("automation.propose", ["automation.automation.manage"]),
29
+ tool("automation_propose", ["automation.automation.manage"]),
30
30
  );
31
- registry.register(tool("incident.list", ["incident.incident.read"]));
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(["incident.list"]);
38
- expect(names).not.toContain("automation.propose");
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("automation.propose", ["automation.automation.manage"]),
44
+ tool("automation_propose", ["automation.automation.manage"]),
45
45
  );
46
- registry.register(tool("incident.list", ["incident.incident.read"]));
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(["automation.propose", "incident.list"]);
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("incident.list", ["incident.incident.read"]));
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
+ });
@@ -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
+ }
@@ -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
- if (tools.has(tool.name)) {
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 ${tool.name} already registered — likely a duplicate registration.`,
50
+ `AI tool ${name} already registered — likely a duplicate registration.`,
41
51
  );
42
52
  }
43
- tools.set(tool.name, tool);
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(["ai.getDoc", "ai.searchDocs"]);
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 ["ai.searchDocs", "ai.getDoc", "ai.probeUrl"]) {
41
+ for (const expected of ["ai_searchDocs", "ai_getDoc", "ai_probeUrl"]) {
42
42
  expect(names).toContain(expected);
43
43
  }
44
44
  });