@intentius/chant 0.1.6 → 0.1.7

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.
@@ -0,0 +1,204 @@
1
+ import { resolve } from "node:path";
2
+ import { discoverOps } from "../../op/discover";
3
+ import { makeTemporalClient } from "../handlers/run";
4
+ import { resolveWorkflowId } from "../handlers/run-client";
5
+ import { generateReport } from "../handlers/run-report";
6
+ import type { ToolRegistration } from "./state-tools";
7
+
8
+ function workflowFnName(opName: string): string {
9
+ return opName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) + "Workflow";
10
+ }
11
+
12
+ export function createOpListTool(): ToolRegistration {
13
+ return {
14
+ definition: {
15
+ name: "op-list",
16
+ description: "List all Op definitions discovered from *.op.ts files with their current run status",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
21
+ },
22
+ },
23
+ },
24
+ handler: async (params) => {
25
+ const { ops, errors } = await discoverOps();
26
+ const profile = params.profile as string | undefined;
27
+
28
+ let client: Awaited<ReturnType<typeof makeTemporalClient>>["client"] | undefined;
29
+ try {
30
+ ({ client } = await makeTemporalClient(profile, resolve(".")));
31
+ } catch {
32
+ // Temporal not available — degrade gracefully
33
+ }
34
+
35
+ const result = [];
36
+ for (const [name, { config }] of ops) {
37
+ let runStatus = "—";
38
+ if (client) {
39
+ try {
40
+ const desc = await client.workflow.getHandle(resolveWorkflowId(name)).describe();
41
+ runStatus = desc.status.name;
42
+ } catch {
43
+ // workflow not found or Temporal error
44
+ }
45
+ }
46
+ result.push({
47
+ name,
48
+ overview: config.overview,
49
+ phases: config.phases.length,
50
+ taskQueue: config.taskQueue ?? config.name,
51
+ depends: config.depends ?? [],
52
+ runStatus,
53
+ });
54
+ }
55
+
56
+ return { ops: result, errors };
57
+ },
58
+ };
59
+ }
60
+
61
+ export function createOpRunTool(): ToolRegistration {
62
+ return {
63
+ definition: {
64
+ name: "op-run",
65
+ description:
66
+ "Submit an Op workflow to Temporal. The worker must already be running — start it first with `chant run <name>`.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
71
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
72
+ },
73
+ required: ["name"],
74
+ },
75
+ },
76
+ handler: async (params) => {
77
+ const name = params.name as string;
78
+ const profile = params.profile as string | undefined;
79
+
80
+ const { ops } = await discoverOps();
81
+ if (!ops.has(name)) {
82
+ const available = [...ops.keys()];
83
+ return `Op "${name}" not found. Available: ${available.join(", ") || "none"}`;
84
+ }
85
+
86
+ const { config } = ops.get(name)!;
87
+ const { client, profile: resolvedProfile } = await makeTemporalClient(profile, resolve("."));
88
+ const workflowId = resolveWorkflowId(name);
89
+ const taskQueue = resolvedProfile.taskQueue ?? config.taskQueue ?? name;
90
+
91
+ await client.workflow.start(workflowFnName(name), {
92
+ taskQueue,
93
+ workflowId,
94
+ workflowIdConflictPolicy: "FAIL",
95
+ });
96
+
97
+ return { workflowId, message: `Workflow "${workflowId}" submitted to task queue "${taskQueue}"` };
98
+ },
99
+ };
100
+ }
101
+
102
+ export function createOpStatusTool(): ToolRegistration {
103
+ return {
104
+ definition: {
105
+ name: "op-status",
106
+ description: "Return the current run state of an Op workflow",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
111
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
112
+ },
113
+ required: ["name"],
114
+ },
115
+ },
116
+ handler: async (params) => {
117
+ const name = params.name as string;
118
+ const profile = params.profile as string | undefined;
119
+
120
+ const { client } = await makeTemporalClient(profile, resolve("."));
121
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
122
+ const desc = await handle.describe();
123
+ const history = await handle.fetchHistory();
124
+
125
+ const events = history.events ?? [];
126
+ const activitiesCompleted = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
127
+ const activitiesScheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
128
+
129
+ return {
130
+ workflowId: desc.workflowId,
131
+ runId: desc.runId,
132
+ status: desc.status.name,
133
+ startTime: desc.startTime,
134
+ closeTime: desc.closeTime ?? null,
135
+ taskQueue: desc.taskQueue,
136
+ activitiesCompleted,
137
+ activitiesScheduled,
138
+ };
139
+ },
140
+ };
141
+ }
142
+
143
+ export function createOpSignalTool(): ToolRegistration {
144
+ return {
145
+ definition: {
146
+ name: "op-signal",
147
+ description: "Send a named signal to an Op workflow (e.g. to unblock a gate step)",
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {
151
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
152
+ signal: { type: "string", description: "Signal name (e.g. gate-dns-delegation)" },
153
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
154
+ },
155
+ required: ["name", "signal"],
156
+ },
157
+ },
158
+ handler: async (params) => {
159
+ const name = params.name as string;
160
+ const signal = params.signal as string;
161
+ const profile = params.profile as string | undefined;
162
+
163
+ const { client } = await makeTemporalClient(profile, resolve("."));
164
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
165
+ await handle.signal(signal);
166
+
167
+ return `Signal "${signal}" sent to Op "${name}"`;
168
+ },
169
+ };
170
+ }
171
+
172
+ export function createOpReportTool(): ToolRegistration {
173
+ return {
174
+ definition: {
175
+ name: "op-report",
176
+ description: "Return a markdown deployment report for the latest run of an Op",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ name: { type: "string", description: "Op name (e.g. alb-deploy)" },
181
+ profile: { type: "string", description: "Temporal profile name from chant.config.ts (optional)" },
182
+ },
183
+ required: ["name"],
184
+ },
185
+ },
186
+ handler: async (params) => {
187
+ const name = params.name as string;
188
+ const profile = params.profile as string | undefined;
189
+
190
+ const { ops } = await discoverOps();
191
+ if (!ops.has(name)) {
192
+ return `Op "${name}" not found`;
193
+ }
194
+ const { config } = ops.get(name)!;
195
+
196
+ const { client } = await makeTemporalClient(profile, resolve("."));
197
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
198
+ const desc = await handle.describe();
199
+ const history = await handle.fetchHistory();
200
+
201
+ return generateReport(name, config, desc, history);
202
+ },
203
+ };
204
+ }
@@ -1,9 +1,10 @@
1
+ import { resolve } from "node:path";
1
2
  import type { ResourceDefinition } from "./types";
2
3
  import { getContext } from "./resources/context";
3
4
  import { readSnapshot, readEnvironmentSnapshots } from "../../state/git";
4
- import { discoverSpells } from "../../spell/discovery";
5
- import { generatePrompt } from "../../spell/prompt";
6
- import { getRuntime } from "../../runtime-adapter";
5
+ import { discoverOps } from "../../op/discover";
6
+ import { makeTemporalClient } from "../handlers/run";
7
+ import { resolveWorkflowId } from "../handlers/run-client";
7
8
 
8
9
  type PluginResourceEntry = { definition: ResourceDefinition; handler: () => Promise<string> };
9
10
 
@@ -24,22 +25,22 @@ export const coreResourceDefinitions: ResourceDefinition[] = [
24
25
  mimeType: "application/json",
25
26
  },
26
27
  {
27
- uri: "chant://spells",
28
- name: "Spells",
29
- description: "List all spells with status, tasks, and lexicon",
28
+ uri: "chant://ops",
29
+ name: "Ops",
30
+ description: "All Op definitions discovered from *.op.ts files",
30
31
  mimeType: "application/json",
31
32
  },
32
33
  {
33
- uri: "chant://spell/{name}",
34
- name: "Spell details",
35
- description: "Show spell definition and status",
34
+ uri: "chant://ops/{name}/runs",
35
+ name: "Op run history",
36
+ description: "Workflow run history for a named Op",
36
37
  mimeType: "application/json",
37
38
  },
38
39
  {
39
- uri: "chant://spell/{name}/prompt",
40
- name: "Spell bootstrap prompt",
41
- description: "Bootstrap prompt for agent consumption",
42
- mimeType: "text/markdown",
40
+ uri: "chant://ops/{name}/runs/latest",
41
+ name: "Op latest run",
42
+ description: "Latest run state for a named Op",
43
+ mimeType: "application/json",
43
44
  },
44
45
  {
45
46
  uri: "chant://state/{environment}",
@@ -84,6 +85,10 @@ export function collectExamples(
84
85
  return examples;
85
86
  }
86
87
 
88
+ function opWorkflowFnName(opName: string): string {
89
+ return opName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) + "Workflow";
90
+ }
91
+
87
92
  /**
88
93
  * Handle resources/read request — checks plugin resources after core
89
94
  */
@@ -117,51 +122,65 @@ export async function handleResourcesRead(
117
122
  };
118
123
  }
119
124
 
120
- // Spell resources
121
- if (uri === "chant://spells") {
122
- const { spells } = await discoverSpells();
123
- const list = Array.from(spells.entries()).map(([name, s]) => ({
124
- name,
125
- status: s.status,
126
- tasks: `${s.definition.tasks.filter((t) => t.done).length}/${s.definition.tasks.length}`,
127
- lexicon: s.definition.lexicon ?? null,
128
- overview: s.definition.overview,
125
+ // Op resources
126
+ if (uri === "chant://ops") {
127
+ const { ops } = await discoverOps();
128
+ const list = Array.from(ops.values()).map(({ config }) => ({
129
+ name: config.name,
130
+ overview: config.overview,
131
+ phases: config.phases.length,
132
+ taskQueue: config.taskQueue ?? config.name,
133
+ depends: config.depends ?? [],
129
134
  }));
130
135
  return {
131
136
  contents: [{ uri, mimeType: "application/json", text: JSON.stringify(list, null, 2) }],
132
137
  };
133
138
  }
134
139
 
135
- if (uri.startsWith("chant://spell/") && uri.endsWith("/prompt")) {
136
- const name = uri.replace("chant://spell/", "").replace("/prompt", "");
137
- const { spells } = await discoverSpells();
138
- const spell = spells.get(name);
139
- if (!spell) throw new Error(`Spell "${name}" not found`);
140
- const rt = getRuntime();
141
- const gitRootResult = await rt.spawn(["git", "rev-parse", "--show-toplevel"]);
142
- const gitRoot = gitRootResult.stdout.trim();
143
- const prompt = await generatePrompt(spell.definition, { gitRoot });
144
- return {
145
- contents: [{ uri, mimeType: "text/markdown", text: prompt }],
146
- };
140
+ if (uri.startsWith("chant://ops/") && uri.endsWith("/runs/latest")) {
141
+ const name = uri.replace("chant://ops/", "").replace("/runs/latest", "");
142
+ try {
143
+ const { client } = await makeTemporalClient(undefined, resolve("."));
144
+ const handle = client.workflow.getHandle(resolveWorkflowId(name));
145
+ const desc = await handle.describe();
146
+ const history = await handle.fetchHistory();
147
+ const events = history.events ?? [];
148
+ const result = {
149
+ workflowId: desc.workflowId,
150
+ runId: desc.runId,
151
+ status: desc.status.name,
152
+ startTime: desc.startTime,
153
+ closeTime: desc.closeTime ?? null,
154
+ taskQueue: desc.taskQueue,
155
+ activitiesCompleted: events.filter((e) => e.eventType === "ActivityTaskCompleted").length,
156
+ activitiesScheduled: events.filter((e) => e.eventType === "ActivityTaskScheduled").length,
157
+ };
158
+ return {
159
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
160
+ };
161
+ } catch (err) {
162
+ return {
163
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }],
164
+ };
165
+ }
147
166
  }
148
167
 
149
- if (uri.startsWith("chant://spell/")) {
150
- const name = uri.replace("chant://spell/", "");
151
- const { spells } = await discoverSpells();
152
- const spell = spells.get(name);
153
- if (!spell) throw new Error(`Spell "${name}" not found`);
154
- return {
155
- contents: [{
156
- uri,
157
- mimeType: "application/json",
158
- text: JSON.stringify({
159
- ...spell.definition,
160
- status: spell.status,
161
- filePath: spell.filePath,
162
- }, null, 2),
163
- }],
164
- };
168
+ if (uri.startsWith("chant://ops/") && uri.endsWith("/runs")) {
169
+ const name = uri.replace("chant://ops/", "").replace("/runs", "");
170
+ try {
171
+ const { client } = await makeTemporalClient(undefined, resolve("."));
172
+ const runs: unknown[] = [];
173
+ for await (const run of client.workflow.list({ query: `WorkflowType = "${opWorkflowFnName(name)}"` })) {
174
+ runs.push({ runId: run.runId, status: run.status.name, startTime: run.startTime, closeTime: run.closeTime ?? null });
175
+ }
176
+ return {
177
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(runs, null, 2) }],
178
+ };
179
+ } catch (err) {
180
+ return {
181
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }],
182
+ };
183
+ }
165
184
  }
166
185
 
167
186
  // State resources: chant://state/{environment} and chant://state/{environment}/{lexicon}
@@ -49,6 +49,33 @@ chant import template.json # Convert external template
49
49
  chant import template.json -o ./src/ # Custom output dir
50
50
  \`\`\`
51
51
 
52
+ ## Ops (Temporal Workflows)
53
+
54
+ Ops are durable workflow definitions backed by Temporal. Each \`*.op.ts\` file declares an Op with named phases and activity steps.
55
+
56
+ ### Op MCP Tools
57
+
58
+ | Tool | Description |
59
+ |---|---|
60
+ | \`op-list\` | List all discovered Ops with current run status. Optional \`profile\` param. |
61
+ | \`op-run\` | Submit an Op workflow. Requires \`name\`. Worker must already be running via \`chant run <name>\`. |
62
+ | \`op-status\` | Get current run state (status, activity counts, times). Requires \`name\`. |
63
+ | \`op-signal\` | Send a signal to unblock a gate step. Requires \`name\` and \`signal\`. |
64
+ | \`op-report\` | Return a markdown deployment report for the latest run. Requires \`name\`. |
65
+
66
+ All Op tools accept an optional \`profile\` parameter matching a profile name in \`chant.config.ts\` \`temporal.profiles\`.
67
+
68
+ ### Op MCP Resources
69
+
70
+ | URI | Description |
71
+ |---|---|
72
+ | \`chant://ops\` | JSON array of all Op definitions (name, overview, phases, taskQueue, depends) |
73
+ | \`chant://ops/{name}/runs\` | Workflow run history for a named Op |
74
+ | \`chant://ops/{name}/runs/latest\` | Latest run state for a named Op |
75
+
76
+ ### Workflow IDs
77
+ Ops use deterministic workflow IDs: \`chant-op-<opName>\` (e.g. \`chant-op-alb-deploy\`).
78
+
52
79
  ## Best Practices
53
80
 
54
81
  1. **Flat Declarations**: Keep declarations at module level, avoid deep nesting
@@ -195,6 +195,62 @@ describe("McpServer", () => {
195
195
  expect(props.lexicon).toBeDefined();
196
196
  expect(props.limit).toBeDefined();
197
197
  });
198
+
199
+ describe("Op tools schema", () => {
200
+ async function getToolProps(name: string): Promise<Record<string, unknown>> {
201
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
202
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
203
+ const tool = result.tools.find((t) => t.name === name)!;
204
+ return tool.inputSchema.properties as Record<string, unknown>;
205
+ }
206
+
207
+ test("op-list has profile property", async () => {
208
+ const props = await getToolProps("op-list");
209
+ expect(props.profile).toBeDefined();
210
+ });
211
+
212
+ test("op-run has name (required) and profile", async () => {
213
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
214
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
215
+ const tool = result.tools.find((t) => t.name === "op-run")!;
216
+ const props = tool.inputSchema.properties as Record<string, unknown>;
217
+ expect(props.name).toBeDefined();
218
+ expect(props.profile).toBeDefined();
219
+ expect(tool.inputSchema.required).toContain("name");
220
+ });
221
+
222
+ test("op-status has name (required) and profile", async () => {
223
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
224
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
225
+ const tool = result.tools.find((t) => t.name === "op-status")!;
226
+ const props = tool.inputSchema.properties as Record<string, unknown>;
227
+ expect(props.name).toBeDefined();
228
+ expect(props.profile).toBeDefined();
229
+ expect(tool.inputSchema.required).toContain("name");
230
+ });
231
+
232
+ test("op-signal has name and signal (both required) and profile", async () => {
233
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
234
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
235
+ const tool = result.tools.find((t) => t.name === "op-signal")!;
236
+ const props = tool.inputSchema.properties as Record<string, unknown>;
237
+ expect(props.name).toBeDefined();
238
+ expect(props.signal).toBeDefined();
239
+ expect(props.profile).toBeDefined();
240
+ expect(tool.inputSchema.required).toContain("name");
241
+ expect(tool.inputSchema.required).toContain("signal");
242
+ });
243
+
244
+ test("op-report has name (required) and profile", async () => {
245
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
246
+ const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
247
+ const tool = result.tools.find((t) => t.name === "op-report")!;
248
+ const props = tool.inputSchema.properties as Record<string, unknown>;
249
+ expect(props.name).toBeDefined();
250
+ expect(props.profile).toBeDefined();
251
+ expect(tool.inputSchema.required).toContain("name");
252
+ });
253
+ });
198
254
  });
199
255
 
200
256
  describe("tools/call", () => {
@@ -302,6 +358,74 @@ describe("McpServer", () => {
302
358
  expect(parsed.total).toBe(0);
303
359
  expect(parsed.results).toEqual([]);
304
360
  });
361
+
362
+ describe("Op tool handlers", () => {
363
+ test("op-list returns list without throwing when Temporal unavailable", async () => {
364
+ const response = await server.handleRequest({
365
+ jsonrpc: "2.0",
366
+ id: 1,
367
+ method: "tools/call",
368
+ params: { name: "op-list", arguments: {} },
369
+ });
370
+ expect(response.error).toBeUndefined();
371
+ const result = response.result as { content: Array<{ type: string; text: string }>; isError?: boolean };
372
+ expect(result.content[0].type).toBe("text");
373
+ // May be empty list or error-degraded — but no thrown error
374
+ expect(result.isError).toBeUndefined();
375
+ });
376
+
377
+ test("op-run returns isError when Temporal unavailable", async () => {
378
+ const response = await server.handleRequest({
379
+ jsonrpc: "2.0",
380
+ id: 1,
381
+ method: "tools/call",
382
+ params: { name: "op-run", arguments: { name: "nonexistent-op" } },
383
+ });
384
+ expect(response.error).toBeUndefined();
385
+ const result = response.result as { content: Array<{ text: string }>; isError?: boolean };
386
+ // Either "not found" or Temporal error — either way should not be a protocol error
387
+ expect(result.content[0].text.length).toBeGreaterThan(0);
388
+ });
389
+
390
+ test("op-status returns isError when Temporal unavailable", async () => {
391
+ const response = await server.handleRequest({
392
+ jsonrpc: "2.0",
393
+ id: 1,
394
+ method: "tools/call",
395
+ params: { name: "op-status", arguments: { name: "nonexistent-op" } },
396
+ });
397
+ expect(response.error).toBeUndefined();
398
+ const result = response.result as { content: Array<{ text: string }>; isError: boolean };
399
+ expect(result.isError).toBe(true);
400
+ expect(result.content[0].text).toContain("Error:");
401
+ });
402
+
403
+ test("op-signal returns isError when Temporal unavailable", async () => {
404
+ const response = await server.handleRequest({
405
+ jsonrpc: "2.0",
406
+ id: 1,
407
+ method: "tools/call",
408
+ params: { name: "op-signal", arguments: { name: "nonexistent-op", signal: "gate" } },
409
+ });
410
+ expect(response.error).toBeUndefined();
411
+ const result = response.result as { content: Array<{ text: string }>; isError: boolean };
412
+ expect(result.isError).toBe(true);
413
+ expect(result.content[0].text).toContain("Error:");
414
+ });
415
+
416
+ test("op-report returns content without throwing when op not found", async () => {
417
+ const response = await server.handleRequest({
418
+ jsonrpc: "2.0",
419
+ id: 1,
420
+ method: "tools/call",
421
+ params: { name: "op-report", arguments: { name: "nonexistent-op" } },
422
+ });
423
+ expect(response.error).toBeUndefined();
424
+ const result = response.result as { content: Array<{ text: string }>; isError?: boolean };
425
+ // Op not found → returns a "not found" message or Temporal error, not a protocol error
426
+ expect(result.content[0].text.length).toBeGreaterThan(0);
427
+ });
428
+ });
305
429
  });
306
430
 
307
431
  // -----------------------------------------------------------------------
@@ -446,6 +570,9 @@ describe("McpServer", () => {
446
570
  const uris = result.resources.map((r) => r.uri);
447
571
  expect(uris).toContain("chant://context");
448
572
  expect(uris).toContain("chant://examples/list");
573
+ expect(uris).toContain("chant://ops");
574
+ expect(uris).toContain("chant://ops/{name}/runs");
575
+ expect(uris).toContain("chant://ops/{name}/runs/latest");
449
576
 
450
577
  // Each resource has required fields
451
578
  for (const resource of result.resources) {
@@ -549,6 +676,48 @@ describe("McpServer", () => {
549
676
  expect(response.error).toBeDefined();
550
677
  expect(response.error?.message).toContain("Unknown resource");
551
678
  });
679
+
680
+ describe("Op resources", () => {
681
+ test("chant://ops returns an array", async () => {
682
+ const response = await server.handleRequest({
683
+ jsonrpc: "2.0",
684
+ id: 1,
685
+ method: "resources/read",
686
+ params: { uri: "chant://ops" },
687
+ });
688
+ expect(response.error).toBeUndefined();
689
+ const result = response.result as { contents: Array<{ text: string; mimeType: string }> };
690
+ expect(result.contents[0].mimeType).toBe("application/json");
691
+ const ops = JSON.parse(result.contents[0].text);
692
+ expect(Array.isArray(ops)).toBe(true);
693
+ });
694
+
695
+ test("chant://ops/{name}/runs degrades gracefully when Temporal unavailable", async () => {
696
+ const response = await server.handleRequest({
697
+ jsonrpc: "2.0",
698
+ id: 1,
699
+ method: "resources/read",
700
+ params: { uri: "chant://ops/nonexistent/runs" },
701
+ });
702
+ expect(response.error).toBeUndefined();
703
+ const result = response.result as { contents: Array<{ text: string }> };
704
+ const data = JSON.parse(result.contents[0].text);
705
+ expect(data.error).toBeDefined();
706
+ });
707
+
708
+ test("chant://ops/{name}/runs/latest degrades gracefully when Temporal unavailable", async () => {
709
+ const response = await server.handleRequest({
710
+ jsonrpc: "2.0",
711
+ id: 1,
712
+ method: "resources/read",
713
+ params: { uri: "chant://ops/nonexistent/runs/latest" },
714
+ });
715
+ expect(response.error).toBeUndefined();
716
+ const result = response.result as { contents: Array<{ text: string }> };
717
+ const data = JSON.parse(result.contents[0].text);
718
+ expect(data.error).toBeDefined();
719
+ });
720
+ });
552
721
  });
553
722
 
554
723
  // -----------------------------------------------------------------------
@@ -962,8 +1131,12 @@ describe("McpServer", () => {
962
1131
 
963
1132
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
964
1133
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
965
- expect(tools).toHaveLength(9);
966
- expect(tools.map((t) => t.name).sort()).toEqual(["build", "explain", "import", "lint", "scaffold", "search", "spell-done", "state-diff", "state-snapshot"]);
1134
+ expect(tools).toHaveLength(13);
1135
+ expect(tools.map((t) => t.name).sort()).toEqual([
1136
+ "build", "explain", "import", "lint",
1137
+ "op-list", "op-report", "op-run", "op-signal", "op-status",
1138
+ "scaffold", "search", "state-diff", "state-snapshot",
1139
+ ]);
967
1140
 
968
1141
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
969
1142
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
@@ -974,7 +1147,7 @@ describe("McpServer", () => {
974
1147
  const s = new McpServer([]);
975
1148
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
976
1149
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
977
- expect(tools).toHaveLength(9);
1150
+ expect(tools).toHaveLength(13);
978
1151
  });
979
1152
  });
980
1153
  });
@@ -8,7 +8,8 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import type { LexiconPlugin } from "../../lexicon";
10
10
  import type { McpRequest, McpResponse, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
- import { createSnapshotTool, createDiffTool, createSpellDoneTool } from "./state-tools";
11
+ import { createSnapshotTool, createDiffTool } from "./state-tools";
12
+ import { createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool } from "./op-tools";
12
13
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
13
14
 
14
15
  /**
@@ -35,8 +36,11 @@ export class McpServer {
35
36
  const diff = createDiffTool(plugins ?? []);
36
37
  this.registerTool(diff.definition, diff.handler);
37
38
 
38
- const spellDone = createSpellDoneTool();
39
- this.registerTool(spellDone.definition, spellDone.handler);
39
+ // Register Op tools
40
+ for (const factory of [createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool]) {
41
+ const t = factory();
42
+ this.registerTool(t.definition, t.handler);
43
+ }
40
44
 
41
45
  // Register plugin contributions
42
46
  if (plugins) {