@openshain/mcp 0.2.0 → 0.4.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/NOTICE +4 -0
- package/dist/server.js +492 -17
- package/dist/session.d.ts +3 -0
- package/dist/session.js +6 -0
- package/package.json +5 -4
- package/src/server.ts +572 -20
- package/src/session.ts +7 -0
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
1
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
3
|
import {
|
|
3
4
|
CallToolRequestSchema,
|
|
@@ -8,15 +9,27 @@ import {
|
|
|
8
9
|
import {
|
|
9
10
|
type AnyEvent,
|
|
10
11
|
type Artifact,
|
|
12
|
+
ASK_USER,
|
|
11
13
|
compileInputValidator,
|
|
14
|
+
countToolCalls,
|
|
12
15
|
createToolCaller,
|
|
13
16
|
createToolRegistry,
|
|
17
|
+
DecisionFileSchema,
|
|
18
|
+
type DecisionRecord,
|
|
14
19
|
type Event,
|
|
20
|
+
type EventType,
|
|
15
21
|
type InputValidation,
|
|
22
|
+
isKnownEventType,
|
|
16
23
|
isOpenshainError,
|
|
17
24
|
isTerminal,
|
|
25
|
+
loadAuthority,
|
|
18
26
|
loadConfig,
|
|
27
|
+
type PendingApproval,
|
|
28
|
+
parsePayloadFile,
|
|
19
29
|
parseWorkId,
|
|
30
|
+
pendingApprovals,
|
|
31
|
+
pendingQuestions,
|
|
32
|
+
RUNTIME_PROVIDER_ID,
|
|
20
33
|
type RuntimeProviders,
|
|
21
34
|
resolveWorkspacePath,
|
|
22
35
|
SESSION_WORK_TYPE,
|
|
@@ -27,6 +40,8 @@ import {
|
|
|
27
40
|
type Work,
|
|
28
41
|
type WorkId,
|
|
29
42
|
WorkStore,
|
|
43
|
+
workHistory,
|
|
44
|
+
writeDecision,
|
|
30
45
|
} from "@openshain/core";
|
|
31
46
|
import pkg from "../package.json" with { type: "json" };
|
|
32
47
|
import { Session } from "./session.ts";
|
|
@@ -42,15 +57,32 @@ const WORK_TOOLS: Tool[] = [
|
|
|
42
57
|
{
|
|
43
58
|
name: "work_create",
|
|
44
59
|
description:
|
|
45
|
-
|
|
60
|
+
'Start a work for a request from the person you work for, and make it the current work. Tool calls are recorded against the current work. Finish the current work with work_complete or work_fail before starting another. The type "session" records a conversation: no tool can run inside it, so start the actual work with parent set to the session\'s id.',
|
|
46
61
|
inputSchema: {
|
|
47
62
|
type: "object",
|
|
48
63
|
properties: {
|
|
49
|
-
objective: {
|
|
64
|
+
objective: {
|
|
65
|
+
type: "string",
|
|
66
|
+
minLength: 1,
|
|
67
|
+
maxLength: 10_000,
|
|
68
|
+
description: "The request, in the person's words.",
|
|
69
|
+
},
|
|
50
70
|
type: {
|
|
51
71
|
type: "string",
|
|
72
|
+
maxLength: 50,
|
|
52
73
|
description: "A short label for the kind of work. Defaults to request.",
|
|
53
74
|
},
|
|
75
|
+
parent: {
|
|
76
|
+
type: "string",
|
|
77
|
+
maxLength: 100,
|
|
78
|
+
description: "The id of the work this one was started from, such as the session.",
|
|
79
|
+
},
|
|
80
|
+
agent_name: {
|
|
81
|
+
type: "string",
|
|
82
|
+
maxLength: 100,
|
|
83
|
+
description:
|
|
84
|
+
"The name the agent goes by in this work. A session picks one; the works under it carry the same.",
|
|
85
|
+
},
|
|
54
86
|
},
|
|
55
87
|
required: ["objective"],
|
|
56
88
|
additionalProperties: false,
|
|
@@ -68,10 +100,11 @@ const WORK_TOOLS: Tool[] = [
|
|
|
68
100
|
},
|
|
69
101
|
{
|
|
70
102
|
name: "work_get",
|
|
71
|
-
description:
|
|
103
|
+
description:
|
|
104
|
+
"The state of the current work, or of the work with the given id. With history, also the tool calls so far, the calls that never got a result, and the questions still waiting for an answer, so a stopped work can be picked up.",
|
|
72
105
|
inputSchema: {
|
|
73
106
|
type: "object",
|
|
74
|
-
properties: { id: { type: "string" } },
|
|
107
|
+
properties: { id: { type: "string" }, history: { type: "boolean" } },
|
|
75
108
|
additionalProperties: false,
|
|
76
109
|
},
|
|
77
110
|
},
|
|
@@ -87,12 +120,15 @@ const WORK_TOOLS: Tool[] = [
|
|
|
87
120
|
inputSchema: {
|
|
88
121
|
type: "object",
|
|
89
122
|
properties: {
|
|
90
|
-
summary: { type: "string" },
|
|
123
|
+
summary: { type: "string", maxLength: 20_000 },
|
|
91
124
|
artifacts: {
|
|
92
125
|
type: "array",
|
|
93
126
|
items: {
|
|
94
127
|
type: "object",
|
|
95
|
-
properties: {
|
|
128
|
+
properties: {
|
|
129
|
+
path: { type: "string", maxLength: 1000 },
|
|
130
|
+
sha256: { type: "string", maxLength: 64 },
|
|
131
|
+
},
|
|
96
132
|
required: ["path"],
|
|
97
133
|
additionalProperties: false,
|
|
98
134
|
},
|
|
@@ -107,13 +143,141 @@ const WORK_TOOLS: Tool[] = [
|
|
|
107
143
|
description: "Give up on the current work. Say why in a short reason and, if useful, a detail.",
|
|
108
144
|
inputSchema: {
|
|
109
145
|
type: "object",
|
|
110
|
-
properties: {
|
|
146
|
+
properties: {
|
|
147
|
+
reason: { type: "string", maxLength: 200 },
|
|
148
|
+
detail: { type: "string", maxLength: 20_000 },
|
|
149
|
+
},
|
|
111
150
|
required: ["reason"],
|
|
112
151
|
additionalProperties: false,
|
|
113
152
|
},
|
|
114
153
|
},
|
|
154
|
+
{
|
|
155
|
+
name: ASK_USER.name,
|
|
156
|
+
description: ASK_USER.description,
|
|
157
|
+
inputSchema: ASK_USER.inputSchema as Tool["inputSchema"],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "work_answer",
|
|
161
|
+
description:
|
|
162
|
+
"Record the person's answer to a question the current work is waiting on, and let the work continue.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
call_id: { type: "string", maxLength: 100 },
|
|
167
|
+
answer: { type: "string", maxLength: 20_000 },
|
|
168
|
+
},
|
|
169
|
+
required: ["call_id", "answer"],
|
|
170
|
+
additionalProperties: false,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "context",
|
|
175
|
+
description:
|
|
176
|
+
"Where and when you are working: the current time with its offset, the time zone, today's business date, the company folder, the company, the person you work for, and the current work. Call it when a date or a time matters; the answer is recorded.",
|
|
177
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
178
|
+
annotations: { readOnlyHint: true },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "approval_list",
|
|
182
|
+
description:
|
|
183
|
+
"Every tool call held for a person's approval across the works of this workspace, oldest first: approval_id, work_id, the call, the rule, and who may approve.",
|
|
184
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
185
|
+
annotations: { readOnlyHint: true },
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "approval_decide",
|
|
189
|
+
description:
|
|
190
|
+
"Decide a held tool call as the person this connection acts for. approve runs the call now and returns its result; reject refuses it. Either way the work continues.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
approval_id: { type: "string", maxLength: 100 },
|
|
195
|
+
decision: { type: "string", enum: ["approve", "reject"] },
|
|
196
|
+
comment: { type: "string", maxLength: 2000 },
|
|
197
|
+
},
|
|
198
|
+
required: ["approval_id", "decision"],
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "review_decide",
|
|
204
|
+
description:
|
|
205
|
+
"Record what a qualified reviewer decided about a call the policy held for review. approve and modify write a decision under authority/decisions/ and run the call (modify runs the reviewer's input); reject refuses it. The reviewer is named by the company; openshain does not verify a qualification.",
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
approval_id: { type: "string", maxLength: 100 },
|
|
210
|
+
decision: { type: "string", enum: ["approve", "reject", "modify"] },
|
|
211
|
+
reviewer: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
name: { type: "string", maxLength: 200 },
|
|
215
|
+
role: { type: "string", maxLength: 100 },
|
|
216
|
+
qualification: { type: "string", maxLength: 500 },
|
|
217
|
+
},
|
|
218
|
+
required: ["name", "role"],
|
|
219
|
+
additionalProperties: false,
|
|
220
|
+
},
|
|
221
|
+
interpretation: { type: "string", maxLength: 20_000 },
|
|
222
|
+
modified_input: { type: "object" },
|
|
223
|
+
effective_from: { type: "string", maxLength: 10 },
|
|
224
|
+
effective_until: { type: "string", maxLength: 10 },
|
|
225
|
+
applies_to: {
|
|
226
|
+
type: "object",
|
|
227
|
+
properties: {
|
|
228
|
+
action: { type: "string", maxLength: 200 },
|
|
229
|
+
path: { type: "string", maxLength: 1000 },
|
|
230
|
+
},
|
|
231
|
+
additionalProperties: false,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
required: ["approval_id", "decision", "reviewer"],
|
|
235
|
+
additionalProperties: false,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "work_record",
|
|
240
|
+
description:
|
|
241
|
+
"Record an event of the client itself on a work: what the person said (human.message), a prompt command expanded for the model (prompt.expanded), a model call (model.requested, model.completed, model.failed) or its usage (usage.recorded with kind model_inference). The payload is in the file form of spec/schemas/events.v1.json. Tool calls are recorded by the runtime and cannot be recorded here.",
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
work_id: { type: "string" },
|
|
246
|
+
type: {
|
|
247
|
+
type: "string",
|
|
248
|
+
enum: [
|
|
249
|
+
"human.message",
|
|
250
|
+
"prompt.expanded",
|
|
251
|
+
"model.requested",
|
|
252
|
+
"model.completed",
|
|
253
|
+
"model.failed",
|
|
254
|
+
"usage.recorded",
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
payload: { type: "object" },
|
|
258
|
+
},
|
|
259
|
+
required: ["work_id", "type", "payload"],
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
115
263
|
];
|
|
116
264
|
|
|
265
|
+
/** A client event larger than this is refused: the log is for records, not for payloads. */
|
|
266
|
+
const MAX_RECORD_CHARS = 262_144;
|
|
267
|
+
|
|
268
|
+
/** The event types a client may record itself. Everything else is the runtime's to write. */
|
|
269
|
+
const RECORDABLE_TYPES: ReadonlySet<string> = new Set([
|
|
270
|
+
"human.message",
|
|
271
|
+
"prompt.expanded",
|
|
272
|
+
"model.requested",
|
|
273
|
+
"model.completed",
|
|
274
|
+
"model.failed",
|
|
275
|
+
"usage.recorded",
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
const SESSION_HAS_NO_TOOLS =
|
|
279
|
+
"a session records the conversation and runs no tools: call work_create with parent set to the session's id, then call the tool inside that work";
|
|
280
|
+
|
|
117
281
|
const NO_WORK =
|
|
118
282
|
"no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
|
|
119
283
|
|
|
@@ -130,7 +294,14 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
130
294
|
const { workspaceRoot } = options;
|
|
131
295
|
const config = await loadConfig(workspaceRoot);
|
|
132
296
|
const registry = await createToolRegistry(workspaceRoot, config, options.tools);
|
|
133
|
-
|
|
297
|
+
// Reloaded when a reviewer writes a decision, so the next call can cite it.
|
|
298
|
+
let authority = await loadAuthority(workspaceRoot);
|
|
299
|
+
const callTool = createToolCaller({
|
|
300
|
+
registry,
|
|
301
|
+
config,
|
|
302
|
+
workspaceRoot,
|
|
303
|
+
authority: () => authority,
|
|
304
|
+
});
|
|
134
305
|
const works = new WorkStore(workspaceRoot);
|
|
135
306
|
const session = new Session();
|
|
136
307
|
const server = new Server(
|
|
@@ -163,22 +334,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
163
334
|
switch (name) {
|
|
164
335
|
case "work_create": {
|
|
165
336
|
const current = session.current;
|
|
166
|
-
if (current
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
337
|
+
if (current) {
|
|
338
|
+
const open = await works.get(current);
|
|
339
|
+
// A session is a conversation: starting a work under it is the normal thing to do.
|
|
340
|
+
if (!isTerminal(open.status) && open.type !== SESSION_WORK_TYPE) {
|
|
341
|
+
return failure(
|
|
342
|
+
`work ${current} is still in progress; finish it with work_complete or work_fail before starting another`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
170
345
|
}
|
|
171
|
-
const {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
346
|
+
const {
|
|
347
|
+
objective,
|
|
348
|
+
type,
|
|
349
|
+
parent,
|
|
350
|
+
agent_name: agentName,
|
|
351
|
+
} = input as { objective: string; type?: string; parent?: string; agent_name?: string };
|
|
352
|
+
if (parent !== undefined) await works.get(parseWorkId(parent));
|
|
353
|
+
if (type === SESSION_WORK_TYPE && parent !== undefined) {
|
|
354
|
+
return failure("a session is a conversation of its own and cannot have a parent");
|
|
176
355
|
}
|
|
177
356
|
const work = await works.create({
|
|
178
357
|
objective,
|
|
179
358
|
principal: config.principal.id,
|
|
180
359
|
profession: config.profession.id,
|
|
181
360
|
...(type && { type }),
|
|
361
|
+
...(parent !== undefined && { parent }),
|
|
362
|
+
...(agentName !== undefined && { agentName }),
|
|
182
363
|
});
|
|
183
364
|
await works.transition(work.id, "in_progress", "an agent took the work over MCP");
|
|
184
365
|
session.select(work.id);
|
|
@@ -189,13 +370,330 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
189
370
|
const work = await works.get(id);
|
|
190
371
|
if (isTerminal(work.status)) return failure(`work ${id} is already ${work.status}`);
|
|
191
372
|
session.select(id);
|
|
192
|
-
return json(work);
|
|
373
|
+
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
193
374
|
}
|
|
194
375
|
case "work_get": {
|
|
195
|
-
const given =
|
|
376
|
+
const { id: given, history } = input as { id?: string; history?: boolean };
|
|
196
377
|
const id = given ? parseWorkId(given) : session.current;
|
|
197
378
|
if (!id) return failure(NO_WORK);
|
|
198
|
-
|
|
379
|
+
const work = await works.get(id);
|
|
380
|
+
if (!history) return json(work);
|
|
381
|
+
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
382
|
+
}
|
|
383
|
+
case ASK_USER.name: {
|
|
384
|
+
const gate = await openWork();
|
|
385
|
+
if ("refused" in gate) return gate.refused;
|
|
386
|
+
const work = await works.get(gate.id);
|
|
387
|
+
if (work.type === SESSION_WORK_TYPE) return failure(SESSION_HAS_NO_TOOLS);
|
|
388
|
+
if (work.status === "waiting_input") {
|
|
389
|
+
return failure(
|
|
390
|
+
`work ${gate.id} is already waiting for an answer; record it with work_answer before asking again`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const { question } = input as { question: string };
|
|
394
|
+
const callId = newCallId();
|
|
395
|
+
const opened = await works.open(gate.id);
|
|
396
|
+
try {
|
|
397
|
+
await opened.append({
|
|
398
|
+
type: "tool.called",
|
|
399
|
+
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
|
|
400
|
+
});
|
|
401
|
+
await opened.append({ type: "human.input_requested", payload: { callId, question } });
|
|
402
|
+
await opened.transition("waiting_input", "the agent asked the person a question");
|
|
403
|
+
} finally {
|
|
404
|
+
await opened.close();
|
|
405
|
+
}
|
|
406
|
+
return json({ pending: true, call_id: callId, question });
|
|
407
|
+
}
|
|
408
|
+
case "work_answer": {
|
|
409
|
+
const gate = await openWork();
|
|
410
|
+
if ("refused" in gate) return gate.refused;
|
|
411
|
+
const { call_id: callId, answer } = input as { call_id: string; answer: string };
|
|
412
|
+
const work = await works.get(gate.id);
|
|
413
|
+
if (work.status !== "waiting_input") {
|
|
414
|
+
return failure(`work ${gate.id} is ${work.status}, not waiting for an answer`);
|
|
415
|
+
}
|
|
416
|
+
const pending = pendingQuestions(await works.events(gate.id));
|
|
417
|
+
if (!pending.some((q) => q.callId === callId)) {
|
|
418
|
+
return failure(
|
|
419
|
+
`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const opened = await works.open(gate.id);
|
|
423
|
+
try {
|
|
424
|
+
await opened.append({ type: "human.input_provided", payload: { callId, answer } });
|
|
425
|
+
await opened.append({
|
|
426
|
+
type: "tool.completed",
|
|
427
|
+
payload: { callId, content: [{ type: "text", text: answer }], isError: false },
|
|
428
|
+
});
|
|
429
|
+
await opened.transition("in_progress", "the person answered");
|
|
430
|
+
return json(await opened.current());
|
|
431
|
+
} finally {
|
|
432
|
+
await opened.close();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
case "work_record": {
|
|
436
|
+
const { work_id, type, payload } = input as {
|
|
437
|
+
work_id: string;
|
|
438
|
+
type: string;
|
|
439
|
+
payload: unknown;
|
|
440
|
+
};
|
|
441
|
+
const id = parseWorkId(work_id);
|
|
442
|
+
if (!RECORDABLE_TYPES.has(type) || !isKnownEventType(type)) {
|
|
443
|
+
return failure(`type ${type} cannot be recorded by a client`);
|
|
444
|
+
}
|
|
445
|
+
if (JSON.stringify(payload).length > MAX_RECORD_CHARS) {
|
|
446
|
+
return failure(`payload is larger than ${MAX_RECORD_CHARS} characters`);
|
|
447
|
+
}
|
|
448
|
+
if (!session.knows(id)) {
|
|
449
|
+
return failure(
|
|
450
|
+
`work ${id} was not created or selected on this connection; a client records only on its own works`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const parsed = parsePayloadFile(type as EventType, payload);
|
|
454
|
+
if (type === "usage.recorded" && (parsed as { kind: string }).kind !== "model_inference") {
|
|
455
|
+
return failure("usage.recorded from a client must have kind model_inference");
|
|
456
|
+
}
|
|
457
|
+
const opened = await works.open(id);
|
|
458
|
+
try {
|
|
459
|
+
const status = (await opened.current()).status;
|
|
460
|
+
if (isTerminal(status)) return failure(`work ${id} is already ${status}`);
|
|
461
|
+
const event = await opened.append({ type, payload: parsed } as never);
|
|
462
|
+
return json({ id: event.id, seq: event.seq });
|
|
463
|
+
} finally {
|
|
464
|
+
await opened.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
case "context": {
|
|
468
|
+
const now = new Date();
|
|
469
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
470
|
+
const info = {
|
|
471
|
+
now: localIso(now),
|
|
472
|
+
timezone,
|
|
473
|
+
business_date: localIso(now).slice(0, 10),
|
|
474
|
+
workspace: workspaceRoot,
|
|
475
|
+
company: config.company.name,
|
|
476
|
+
principal: { id: config.principal.id, name: config.principal.name },
|
|
477
|
+
profession: config.profession.id,
|
|
478
|
+
work: session.current ?? null,
|
|
479
|
+
};
|
|
480
|
+
const result = json(info);
|
|
481
|
+
// Recorded on the current work when there is one, even a session: it touches no file.
|
|
482
|
+
const current = session.current;
|
|
483
|
+
if (current && !isTerminal((await works.get(current)).status)) {
|
|
484
|
+
const callId = newCallId();
|
|
485
|
+
const opened = await works.open(current);
|
|
486
|
+
try {
|
|
487
|
+
await opened.append({
|
|
488
|
+
type: "tool.called",
|
|
489
|
+
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
|
|
490
|
+
});
|
|
491
|
+
await opened.append({
|
|
492
|
+
type: "tool.completed",
|
|
493
|
+
payload: { callId, content: [{ type: "json", value: info }], isError: false },
|
|
494
|
+
});
|
|
495
|
+
} finally {
|
|
496
|
+
await opened.close();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
case "approval_list": {
|
|
502
|
+
const held: unknown[] = [];
|
|
503
|
+
const { works: all } = await works.list();
|
|
504
|
+
for (const w of all) {
|
|
505
|
+
if (w.status !== "waiting_approval") continue;
|
|
506
|
+
for (const a of pendingApprovals(await works.events(w.id))) {
|
|
507
|
+
held.push({ ...a, work_id: w.id, objective: w.objective });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return json({ approvals: held });
|
|
511
|
+
}
|
|
512
|
+
case "approval_decide": {
|
|
513
|
+
const {
|
|
514
|
+
approval_id: approvalId,
|
|
515
|
+
decision,
|
|
516
|
+
comment,
|
|
517
|
+
} = input as { approval_id: string; decision: "approve" | "reject"; comment?: string };
|
|
518
|
+
const found = await findApproval(works, approvalId);
|
|
519
|
+
if (!found) return failure(`no pending approval ${approvalId}`);
|
|
520
|
+
const { workId, approval } = found;
|
|
521
|
+
const by = config.principal.id;
|
|
522
|
+
if (approval.kind === "review") {
|
|
523
|
+
return failure(
|
|
524
|
+
`${approvalId} waits for a qualified reviewer, not a person's approval; use review_decide`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (approval.approvers && !approval.approvers.includes(by)) {
|
|
528
|
+
return failure(
|
|
529
|
+
`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const opened = await works.open(workId);
|
|
533
|
+
try {
|
|
534
|
+
// Under the lock: another connection may have decided this approval in between.
|
|
535
|
+
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
536
|
+
return failure(`approval ${approvalId} was already decided`);
|
|
537
|
+
}
|
|
538
|
+
await opened.append({
|
|
539
|
+
type: "approval.decided",
|
|
540
|
+
payload: { approvalId, decision, by, ...(comment !== undefined && { comment }) },
|
|
541
|
+
});
|
|
542
|
+
if (decision === "reject") {
|
|
543
|
+
await opened.append({
|
|
544
|
+
type: "tool.rejected",
|
|
545
|
+
payload: {
|
|
546
|
+
callId: approval.call.callId,
|
|
547
|
+
name: approval.call.name,
|
|
548
|
+
code: "rejected_by_person",
|
|
549
|
+
reason: comment ?? `${by} rejected ${approvalId}`,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
await opened.transition("in_progress", `${by} rejected ${approvalId}`);
|
|
553
|
+
return json({ approval_id: approvalId, decision, work_id: workId });
|
|
554
|
+
}
|
|
555
|
+
await opened.transition("in_progress", `${by} approved ${approvalId}`);
|
|
556
|
+
const result = await callTool(
|
|
557
|
+
opened,
|
|
558
|
+
{ id: approval.call.callId, name: approval.call.name, input: approval.call.input },
|
|
559
|
+
{ approvedBy: approvalId },
|
|
560
|
+
);
|
|
561
|
+
return json({
|
|
562
|
+
approval_id: approvalId,
|
|
563
|
+
decision,
|
|
564
|
+
work_id: workId,
|
|
565
|
+
result: { content: result.content, isError: result.isError ?? false },
|
|
566
|
+
});
|
|
567
|
+
} finally {
|
|
568
|
+
await opened.close();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
case "review_decide": {
|
|
572
|
+
const {
|
|
573
|
+
approval_id: approvalId,
|
|
574
|
+
decision,
|
|
575
|
+
reviewer,
|
|
576
|
+
interpretation,
|
|
577
|
+
modified_input: modifiedInput,
|
|
578
|
+
effective_from: effectiveFrom,
|
|
579
|
+
effective_until: effectiveUntil,
|
|
580
|
+
applies_to: appliesTo,
|
|
581
|
+
} = input as {
|
|
582
|
+
approval_id: string;
|
|
583
|
+
decision: "approve" | "reject" | "modify";
|
|
584
|
+
reviewer: { name: string; role: string; qualification?: string };
|
|
585
|
+
interpretation?: string;
|
|
586
|
+
modified_input?: Record<string, unknown>;
|
|
587
|
+
effective_from?: string;
|
|
588
|
+
effective_until?: string;
|
|
589
|
+
applies_to?: { action?: string; path?: string };
|
|
590
|
+
};
|
|
591
|
+
const found = await findApproval(works, approvalId);
|
|
592
|
+
if (!found) return failure(`no pending approval ${approvalId}`);
|
|
593
|
+
const { workId, approval } = found;
|
|
594
|
+
if (approval.kind !== "review") {
|
|
595
|
+
return failure(
|
|
596
|
+
`${approvalId} waits for a person's approval, not a review; use approval_decide`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (decision !== "reject" && (interpretation ?? "") === "") {
|
|
600
|
+
return failure("a decision needs the reviewer's interpretation in their own words");
|
|
601
|
+
}
|
|
602
|
+
if (approval.reviewer && approval.reviewer.role !== reviewer.role) {
|
|
603
|
+
return failure(
|
|
604
|
+
`rule ${approval.ruleId} asks for a ${approval.reviewer.role}; the decision names a ${reviewer.role}`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
if (decision === "modify" && modifiedInput) {
|
|
608
|
+
// Checked before anything is recorded: a refused input leaves the approval pending.
|
|
609
|
+
const before = (approval.call.input ?? {}) as { path?: unknown };
|
|
610
|
+
const after = modifiedInput as { path?: unknown };
|
|
611
|
+
if (before.path !== after.path) {
|
|
612
|
+
return failure(
|
|
613
|
+
`a modified call must touch the same path: ${String(before.path)} was held, ${String(after.path)} was given`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Built and checked before anything is recorded: an input the decision refuses must not
|
|
618
|
+
// consume the approval and leave the work waiting with nobody able to move it.
|
|
619
|
+
const today = new Date();
|
|
620
|
+
let record: DecisionRecord | undefined;
|
|
621
|
+
if (decision !== "reject") {
|
|
622
|
+
const parsed = DecisionFileSchema.safeParse({
|
|
623
|
+
id: `dec_${uuidv7()}`,
|
|
624
|
+
reviewer,
|
|
625
|
+
approval_id: approvalId,
|
|
626
|
+
decided_at: today.toISOString(),
|
|
627
|
+
effective_from: effectiveFrom ?? today.toISOString().slice(0, 10),
|
|
628
|
+
effective_until: effectiveUntil ?? null,
|
|
629
|
+
interpretation,
|
|
630
|
+
applies_to: appliesTo ?? {},
|
|
631
|
+
});
|
|
632
|
+
if (!parsed.success) {
|
|
633
|
+
return failure(
|
|
634
|
+
`the decision is not well formed: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
record = parsed.data;
|
|
638
|
+
}
|
|
639
|
+
const opened = await works.open(workId);
|
|
640
|
+
try {
|
|
641
|
+
// Under the lock: another connection may have decided this approval in between.
|
|
642
|
+
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
643
|
+
return failure(`approval ${approvalId} was already decided`);
|
|
644
|
+
}
|
|
645
|
+
await opened.append({
|
|
646
|
+
type: "approval.decided",
|
|
647
|
+
payload: {
|
|
648
|
+
approvalId,
|
|
649
|
+
decision,
|
|
650
|
+
by: reviewer.name,
|
|
651
|
+
...(interpretation !== undefined && { comment: interpretation }),
|
|
652
|
+
...(decision === "modify" && modifiedInput && { modifiedInput }),
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
if (decision === "reject") {
|
|
656
|
+
await opened.append({ type: "review.decided", payload: { approvalId } });
|
|
657
|
+
await opened.append({
|
|
658
|
+
type: "tool.rejected",
|
|
659
|
+
payload: {
|
|
660
|
+
callId: approval.call.callId,
|
|
661
|
+
name: approval.call.name,
|
|
662
|
+
code: "rejected_by_person",
|
|
663
|
+
reason: interpretation ?? `${reviewer.name} did not approve ${approvalId}`,
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
await opened.transition("in_progress", `${reviewer.name} rejected ${approvalId}`);
|
|
667
|
+
return json({ approval_id: approvalId, decision, work_id: workId });
|
|
668
|
+
}
|
|
669
|
+
const written = record as DecisionRecord;
|
|
670
|
+
const file = await writeDecision(workspaceRoot, written);
|
|
671
|
+
await opened.append({
|
|
672
|
+
type: "review.decided",
|
|
673
|
+
payload: { approvalId, decisionId: written.id },
|
|
674
|
+
});
|
|
675
|
+
await opened.transition("in_progress", `${reviewer.name} decided ${approvalId}`);
|
|
676
|
+
const ranWith =
|
|
677
|
+
decision === "modify" && modifiedInput ? modifiedInput : approval.call.input;
|
|
678
|
+
const ran = await callTool(
|
|
679
|
+
opened,
|
|
680
|
+
{ id: approval.call.callId, name: approval.call.name, input: ranWith },
|
|
681
|
+
{ approvedBy: approvalId },
|
|
682
|
+
);
|
|
683
|
+
// The decision is on disk; a rule that cites its id can use it from here on. Reloaded
|
|
684
|
+
// so that a rule already written for it takes effect without a restart.
|
|
685
|
+
authority = await loadAuthority(workspaceRoot);
|
|
686
|
+
return json({
|
|
687
|
+
approval_id: approvalId,
|
|
688
|
+
decision,
|
|
689
|
+
work_id: workId,
|
|
690
|
+
decision_id: written.id,
|
|
691
|
+
decision_file: relative(workspaceRoot, file),
|
|
692
|
+
result: { content: ran.content, isError: ran.isError ?? false },
|
|
693
|
+
});
|
|
694
|
+
} finally {
|
|
695
|
+
await opened.close();
|
|
696
|
+
}
|
|
199
697
|
}
|
|
200
698
|
case "work_list": {
|
|
201
699
|
const { works: all, problems } = await works.list();
|
|
@@ -203,8 +701,11 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
203
701
|
works: all.map((w) => ({
|
|
204
702
|
id: w.id,
|
|
205
703
|
status: w.status,
|
|
704
|
+
type: w.type,
|
|
206
705
|
objective: w.objective,
|
|
207
706
|
createdAt: w.createdAt,
|
|
707
|
+
...(w.parent !== undefined && { parent: w.parent }),
|
|
708
|
+
...(w.agentName !== undefined && { agentName: w.agentName }),
|
|
208
709
|
})),
|
|
209
710
|
problems: problems.map((p) => ({
|
|
210
711
|
id: p.id,
|
|
@@ -240,8 +741,29 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
240
741
|
default: {
|
|
241
742
|
const gate = await openWork();
|
|
242
743
|
if ("refused" in gate) return gate.refused;
|
|
744
|
+
const work = await works.get(gate.id);
|
|
745
|
+
if (work.type === SESSION_WORK_TYPE) return failure(SESSION_HAS_NO_TOOLS);
|
|
746
|
+
if (work.status === "waiting_input") {
|
|
747
|
+
return failure(
|
|
748
|
+
`work ${gate.id} is waiting for the person's answer; record it with work_answer before calling tools`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (work.status === "waiting_approval") {
|
|
752
|
+
return failure(
|
|
753
|
+
`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
243
756
|
const opened = await works.open(gate.id);
|
|
244
757
|
try {
|
|
758
|
+
const limit = config.limits.maxToolCalls;
|
|
759
|
+
if (countToolCalls(await opened.events()) >= limit) {
|
|
760
|
+
const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
|
|
761
|
+
await opened.append({
|
|
762
|
+
type: "tool.rejected",
|
|
763
|
+
payload: { callId: newCallId(), name, code: "limit_reached", reason },
|
|
764
|
+
});
|
|
765
|
+
return failure(`limit_reached: ${reason}`);
|
|
766
|
+
}
|
|
245
767
|
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
246
768
|
return toMcpResult(result);
|
|
247
769
|
} finally {
|
|
@@ -323,6 +845,7 @@ function toMcpTool(definition: ToolDefinition): Tool {
|
|
|
323
845
|
name: definition.name,
|
|
324
846
|
description: definition.description,
|
|
325
847
|
inputSchema: definition.inputSchema as Tool["inputSchema"],
|
|
848
|
+
annotations: { readOnlyHint: definition.effect === "observe" },
|
|
326
849
|
};
|
|
327
850
|
}
|
|
328
851
|
|
|
@@ -348,3 +871,32 @@ function failure(text: string): CallToolResult {
|
|
|
348
871
|
function newCallId(): string {
|
|
349
872
|
return `call_${uuidv7()}`;
|
|
350
873
|
}
|
|
874
|
+
|
|
875
|
+
/** The work holding a pending approval, found by scanning the works that wait for one. */
|
|
876
|
+
async function findApproval(
|
|
877
|
+
works: WorkStore,
|
|
878
|
+
approvalId: string,
|
|
879
|
+
): Promise<{ workId: WorkId; approval: PendingApproval } | undefined> {
|
|
880
|
+
const { works: all } = await works.list();
|
|
881
|
+
for (const w of all) {
|
|
882
|
+
if (w.status !== "waiting_approval") continue;
|
|
883
|
+
const approval = pendingApprovals(await works.events(w.id)).find(
|
|
884
|
+
(a) => a.approvalId === approvalId,
|
|
885
|
+
);
|
|
886
|
+
if (approval) return { workId: w.id, approval };
|
|
887
|
+
}
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** ISO 8601 with the local offset instead of Z, so the time reads as the person's clock. */
|
|
892
|
+
function localIso(date: Date): string {
|
|
893
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
894
|
+
const offset = -date.getTimezoneOffset();
|
|
895
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
896
|
+
const abs = Math.abs(offset);
|
|
897
|
+
return (
|
|
898
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
899
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
|
|
900
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
901
|
+
);
|
|
902
|
+
}
|