@intentius/chant 0.1.5 → 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.
- package/package.json +2 -1
- package/src/cli/commands/build.ts +17 -0
- package/src/cli/handlers/graph.ts +23 -0
- package/src/cli/handlers/run-client.ts +134 -0
- package/src/cli/handlers/run-report.ts +160 -0
- package/src/cli/handlers/run.ts +453 -0
- package/src/cli/main.test.ts +64 -0
- package/src/cli/main.ts +28 -18
- package/src/cli/mcp/op-tools.ts +204 -0
- package/src/cli/mcp/resource-handlers.ts +69 -50
- package/src/cli/mcp/resources/context.ts +27 -0
- package/src/cli/mcp/server.test.ts +176 -3
- package/src/cli/mcp/server.ts +7 -3
- package/src/cli/mcp/state-tools.ts +0 -51
- package/src/cli/registry.ts +2 -0
- package/src/composite.ts +10 -5
- package/src/index.ts +4 -1
- package/src/op/builders.ts +96 -0
- package/src/op/discover.test.ts +43 -0
- package/src/op/discover.ts +89 -0
- package/src/op/index.ts +6 -0
- package/src/op/op.test.ts +199 -0
- package/src/op/resource.ts +8 -0
- package/src/op/types.ts +60 -0
- package/src/cli/handlers/spell.ts +0 -396
- package/src/spell/discovery.ts +0 -183
- package/src/spell/index.ts +0 -3
- package/src/spell/prompt.ts +0 -133
- package/src/spell/types.ts +0 -89
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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://
|
|
28
|
-
name: "
|
|
29
|
-
description: "
|
|
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://
|
|
34
|
-
name: "
|
|
35
|
-
description: "
|
|
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://
|
|
40
|
-
name: "
|
|
41
|
-
description: "
|
|
42
|
-
mimeType: "
|
|
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
|
-
//
|
|
121
|
-
if (uri === "chant://
|
|
122
|
-
const {
|
|
123
|
-
const list = Array.from(
|
|
124
|
-
name,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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://
|
|
136
|
-
const name = uri.replace("chant://
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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://
|
|
150
|
-
const name = uri.replace("chant://
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
text: JSON.stringify(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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(
|
|
966
|
-
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
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(
|
|
1150
|
+
expect(tools).toHaveLength(13);
|
|
978
1151
|
});
|
|
979
1152
|
});
|
|
980
1153
|
});
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
39
|
-
|
|
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) {
|