@openshain/mcp 0.4.1 → 0.5.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/dist/server.js +61 -77
- package/package.json +3 -3
- package/src/server.ts +64 -67
package/dist/server.js
CHANGED
|
@@ -194,6 +194,7 @@ const WORK_TOOLS = [
|
|
|
194
194
|
"model.completed",
|
|
195
195
|
"model.failed",
|
|
196
196
|
"usage.recorded",
|
|
197
|
+
"conversation.compacted",
|
|
197
198
|
],
|
|
198
199
|
},
|
|
199
200
|
payload: { type: "object" },
|
|
@@ -213,6 +214,8 @@ const RECORDABLE_TYPES = new Set([
|
|
|
213
214
|
"model.completed",
|
|
214
215
|
"model.failed",
|
|
215
216
|
"usage.recorded",
|
|
217
|
+
// The conversation is the client's to shorten: the runtime holds the events either way.
|
|
218
|
+
"conversation.compacted",
|
|
216
219
|
]);
|
|
217
220
|
const SESSION_HAS_NO_TOOLS = "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";
|
|
218
221
|
const NO_WORK = "no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
|
|
@@ -235,6 +238,20 @@ export async function createMcpServer(options) {
|
|
|
235
238
|
authority: () => authority,
|
|
236
239
|
});
|
|
237
240
|
const works = new WorkStore(workspaceRoot);
|
|
241
|
+
/**
|
|
242
|
+
* Opens a work for one piece of work on it, and closes it however that ends. The lock a work
|
|
243
|
+
* holds is the single-writer rule, so it must not outlive the call that took it; releasing it
|
|
244
|
+
* here means no caller can forget to.
|
|
245
|
+
*/
|
|
246
|
+
async function withWork(id, use) {
|
|
247
|
+
const opened = await works.open(id);
|
|
248
|
+
try {
|
|
249
|
+
return await use(opened);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await opened.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
238
255
|
const session = new Session();
|
|
239
256
|
const server = new Server({ name: "openshain", version: pkg.version }, { capabilities: { tools: {} } });
|
|
240
257
|
/** The current work when it can still take events; otherwise the reason it cannot. */
|
|
@@ -290,6 +307,11 @@ export async function createMcpServer(options) {
|
|
|
290
307
|
const work = await works.get(id);
|
|
291
308
|
if (isTerminal(work.status))
|
|
292
309
|
return failure(`work ${id} is already ${work.status}`);
|
|
310
|
+
// Selecting is what lets a client record into a work. Another person's conversation is
|
|
311
|
+
// theirs: a client that could select it could write what they never said.
|
|
312
|
+
if (work.principal !== config.principal.id) {
|
|
313
|
+
return failure(`work ${id} belongs to ${work.principal}; a client selects only the works of the person it acts for`);
|
|
314
|
+
}
|
|
293
315
|
session.select(id);
|
|
294
316
|
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
295
317
|
}
|
|
@@ -315,18 +337,14 @@ export async function createMcpServer(options) {
|
|
|
315
337
|
}
|
|
316
338
|
const { question } = input;
|
|
317
339
|
const callId = newCallId();
|
|
318
|
-
|
|
319
|
-
try {
|
|
340
|
+
await withWork(gate.id, async (opened) => {
|
|
320
341
|
await opened.append({
|
|
321
342
|
type: "tool.called",
|
|
322
343
|
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
|
|
323
344
|
});
|
|
324
345
|
await opened.append({ type: "human.input_requested", payload: { callId, question } });
|
|
325
346
|
await opened.transition("waiting_input", "the agent asked the person a question");
|
|
326
|
-
}
|
|
327
|
-
finally {
|
|
328
|
-
await opened.close();
|
|
329
|
-
}
|
|
347
|
+
});
|
|
330
348
|
return json({ pending: true, call_id: callId, question });
|
|
331
349
|
}
|
|
332
350
|
case "work_answer": {
|
|
@@ -342,8 +360,7 @@ export async function createMcpServer(options) {
|
|
|
342
360
|
if (!pending.some((q) => q.callId === callId)) {
|
|
343
361
|
return failure(`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`);
|
|
344
362
|
}
|
|
345
|
-
|
|
346
|
-
try {
|
|
363
|
+
return await withWork(gate.id, async (opened) => {
|
|
347
364
|
await opened.append({ type: "human.input_provided", payload: { callId, answer } });
|
|
348
365
|
await opened.append({
|
|
349
366
|
type: "tool.completed",
|
|
@@ -351,10 +368,7 @@ export async function createMcpServer(options) {
|
|
|
351
368
|
});
|
|
352
369
|
await opened.transition("in_progress", "the person answered");
|
|
353
370
|
return json(await opened.current());
|
|
354
|
-
}
|
|
355
|
-
finally {
|
|
356
|
-
await opened.close();
|
|
357
|
-
}
|
|
371
|
+
});
|
|
358
372
|
}
|
|
359
373
|
case "work_record": {
|
|
360
374
|
const { work_id, type, payload } = input;
|
|
@@ -372,17 +386,13 @@ export async function createMcpServer(options) {
|
|
|
372
386
|
if (type === "usage.recorded" && parsed.kind !== "model_inference") {
|
|
373
387
|
return failure("usage.recorded from a client must have kind model_inference");
|
|
374
388
|
}
|
|
375
|
-
|
|
376
|
-
try {
|
|
389
|
+
return await withWork(id, async (opened) => {
|
|
377
390
|
const status = (await opened.current()).status;
|
|
378
391
|
if (isTerminal(status))
|
|
379
392
|
return failure(`work ${id} is already ${status}`);
|
|
380
393
|
const event = await opened.append({ type, payload: parsed });
|
|
381
394
|
return json({ id: event.id, seq: event.seq });
|
|
382
|
-
}
|
|
383
|
-
finally {
|
|
384
|
-
await opened.close();
|
|
385
|
-
}
|
|
395
|
+
});
|
|
386
396
|
}
|
|
387
397
|
case "context": {
|
|
388
398
|
const now = new Date();
|
|
@@ -402,8 +412,7 @@ export async function createMcpServer(options) {
|
|
|
402
412
|
const current = session.current;
|
|
403
413
|
if (current && !isTerminal((await works.get(current)).status)) {
|
|
404
414
|
const callId = newCallId();
|
|
405
|
-
|
|
406
|
-
try {
|
|
415
|
+
await withWork(current, async (opened) => {
|
|
407
416
|
await opened.append({
|
|
408
417
|
type: "tool.called",
|
|
409
418
|
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
|
|
@@ -412,10 +421,7 @@ export async function createMcpServer(options) {
|
|
|
412
421
|
type: "tool.completed",
|
|
413
422
|
payload: { callId, content: [{ type: "json", value: info }], isError: false },
|
|
414
423
|
});
|
|
415
|
-
}
|
|
416
|
-
finally {
|
|
417
|
-
await opened.close();
|
|
418
|
-
}
|
|
424
|
+
});
|
|
419
425
|
}
|
|
420
426
|
return result;
|
|
421
427
|
}
|
|
@@ -444,8 +450,7 @@ export async function createMcpServer(options) {
|
|
|
444
450
|
if (approval.approvers && !approval.approvers.includes(by)) {
|
|
445
451
|
return failure(`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`);
|
|
446
452
|
}
|
|
447
|
-
|
|
448
|
-
try {
|
|
453
|
+
return await withWork(workId, async (opened) => {
|
|
449
454
|
// Under the lock: another connection may have decided this approval in between.
|
|
450
455
|
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
451
456
|
return failure(`approval ${approvalId} was already decided`);
|
|
@@ -475,10 +480,7 @@ export async function createMcpServer(options) {
|
|
|
475
480
|
work_id: workId,
|
|
476
481
|
result: { content: result.content, isError: result.isError ?? false },
|
|
477
482
|
});
|
|
478
|
-
}
|
|
479
|
-
finally {
|
|
480
|
-
await opened.close();
|
|
481
|
-
}
|
|
483
|
+
});
|
|
482
484
|
}
|
|
483
485
|
case "review_decide": {
|
|
484
486
|
const { approval_id: approvalId, decision, reviewer, interpretation, modified_input: modifiedInput, effective_from: effectiveFrom, effective_until: effectiveUntil, applies_to: appliesTo, } = input;
|
|
@@ -523,8 +525,7 @@ export async function createMcpServer(options) {
|
|
|
523
525
|
}
|
|
524
526
|
record = parsed.data;
|
|
525
527
|
}
|
|
526
|
-
|
|
527
|
-
try {
|
|
528
|
+
return await withWork(workId, async (opened) => {
|
|
528
529
|
// Under the lock: another connection may have decided this approval in between.
|
|
529
530
|
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
530
531
|
return failure(`approval ${approvalId} was already decided`);
|
|
@@ -573,10 +574,7 @@ export async function createMcpServer(options) {
|
|
|
573
574
|
decision_file: relative(workspaceRoot, file),
|
|
574
575
|
result: { content: ran.content, isError: ran.isError ?? false },
|
|
575
576
|
});
|
|
576
|
-
}
|
|
577
|
-
finally {
|
|
578
|
-
await opened.close();
|
|
579
|
-
}
|
|
577
|
+
});
|
|
580
578
|
}
|
|
581
579
|
case "work_list": {
|
|
582
580
|
const { works: all, problems } = await works.list();
|
|
@@ -602,7 +600,7 @@ export async function createMcpServer(options) {
|
|
|
602
600
|
if ("refused" in gate)
|
|
603
601
|
return gate.refused;
|
|
604
602
|
const { summary, artifacts } = input;
|
|
605
|
-
const work = await
|
|
603
|
+
const work = await withWork(gate.id, (opened) => complete(workspaceRoot, opened, summary, artifacts ?? []));
|
|
606
604
|
session.clear();
|
|
607
605
|
return json(work);
|
|
608
606
|
}
|
|
@@ -611,13 +609,9 @@ export async function createMcpServer(options) {
|
|
|
611
609
|
if ("refused" in gate)
|
|
612
610
|
return gate.refused;
|
|
613
611
|
const { reason, detail } = input;
|
|
614
|
-
|
|
615
|
-
try {
|
|
612
|
+
await withWork(gate.id, async (opened) => {
|
|
616
613
|
await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
|
|
617
|
-
}
|
|
618
|
-
finally {
|
|
619
|
-
await opened.close();
|
|
620
|
-
}
|
|
614
|
+
});
|
|
621
615
|
session.clear();
|
|
622
616
|
return json(await works.get(gate.id));
|
|
623
617
|
}
|
|
@@ -634,8 +628,7 @@ export async function createMcpServer(options) {
|
|
|
634
628
|
if (work.status === "waiting_approval") {
|
|
635
629
|
return failure(`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`);
|
|
636
630
|
}
|
|
637
|
-
|
|
638
|
-
try {
|
|
631
|
+
return await withWork(gate.id, async (opened) => {
|
|
639
632
|
const limit = config.limits.maxToolCalls;
|
|
640
633
|
if (countToolCalls(await opened.events()) >= limit) {
|
|
641
634
|
const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
|
|
@@ -647,10 +640,7 @@ export async function createMcpServer(options) {
|
|
|
647
640
|
}
|
|
648
641
|
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
649
642
|
return toMcpResult(result);
|
|
650
|
-
}
|
|
651
|
-
finally {
|
|
652
|
-
await opened.close();
|
|
653
|
-
}
|
|
643
|
+
});
|
|
654
644
|
}
|
|
655
645
|
}
|
|
656
646
|
}
|
|
@@ -674,38 +664,32 @@ export async function createMcpServer(options) {
|
|
|
674
664
|
* wrote; every path must be inside the workspace, and the runtime hashes them all. A path no tool
|
|
675
665
|
* of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
|
|
676
666
|
*/
|
|
677
|
-
async function complete(
|
|
667
|
+
async function complete(workspaceRoot, opened, summary, claimed) {
|
|
678
668
|
for (const { path } of claimed)
|
|
679
669
|
await resolveWorkspacePath(workspaceRoot, path);
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
for (const
|
|
686
|
-
|
|
687
|
-
for (const { path, sha256 } of event.payload.after ?? [])
|
|
688
|
-
byPath.set(path, sha256);
|
|
689
|
-
}
|
|
690
|
-
const written = new Set(byPath.keys());
|
|
691
|
-
for (const { path, sha256 } of claimed)
|
|
692
|
-
if (!byPath.has(path))
|
|
693
|
-
byPath.set(path, sha256 ?? "");
|
|
694
|
-
const artifacts = [];
|
|
695
|
-
for (const [path, reported] of byPath) {
|
|
696
|
-
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
697
|
-
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
698
|
-
}
|
|
699
|
-
await opened.append({
|
|
700
|
-
type: "evidence.recorded",
|
|
701
|
-
payload: { claim: summary, refs, artifacts },
|
|
702
|
-
});
|
|
703
|
-
await opened.append({ type: "work.completed", payload: { summary } });
|
|
704
|
-
return opened.current();
|
|
670
|
+
const events = await opened.events();
|
|
671
|
+
const refs = [];
|
|
672
|
+
const byPath = new Map();
|
|
673
|
+
for (const event of writesWithAfter(events)) {
|
|
674
|
+
refs.push(event.id);
|
|
675
|
+
for (const { path, sha256 } of event.payload.after ?? [])
|
|
676
|
+
byPath.set(path, sha256);
|
|
705
677
|
}
|
|
706
|
-
|
|
707
|
-
|
|
678
|
+
const written = new Set(byPath.keys());
|
|
679
|
+
for (const { path, sha256 } of claimed)
|
|
680
|
+
if (!byPath.has(path))
|
|
681
|
+
byPath.set(path, sha256 ?? "");
|
|
682
|
+
const artifacts = [];
|
|
683
|
+
for (const [path, reported] of byPath) {
|
|
684
|
+
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
685
|
+
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
708
686
|
}
|
|
687
|
+
await opened.append({
|
|
688
|
+
type: "evidence.recorded",
|
|
689
|
+
payload: { claim: summary, refs, artifacts },
|
|
690
|
+
});
|
|
691
|
+
await opened.append({ type: "work.completed", payload: { summary } });
|
|
692
|
+
return opened.current();
|
|
709
693
|
}
|
|
710
694
|
function writesWithAfter(events) {
|
|
711
695
|
return events.filter((e) => e.type === "tool.completed" &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openshain/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server that exposes an openshain workspace to any agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
49
|
-
"@openshain/core": "0.
|
|
49
|
+
"@openshain/core": "0.5.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@openshain/tools": "0.
|
|
52
|
+
"@openshain/tools": "0.5.0"
|
|
53
53
|
},
|
|
54
54
|
"publishConfig": {
|
|
55
55
|
"access": "public"
|
package/src/server.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
uuidv7,
|
|
41
41
|
verifyArtifact,
|
|
42
42
|
type Work,
|
|
43
|
+
type WorkHandle,
|
|
43
44
|
type WorkId,
|
|
44
45
|
WorkStore,
|
|
45
46
|
workHistory,
|
|
@@ -254,6 +255,7 @@ const WORK_TOOLS: Tool[] = [
|
|
|
254
255
|
"model.completed",
|
|
255
256
|
"model.failed",
|
|
256
257
|
"usage.recorded",
|
|
258
|
+
"conversation.compacted",
|
|
257
259
|
],
|
|
258
260
|
},
|
|
259
261
|
payload: { type: "object" },
|
|
@@ -275,6 +277,8 @@ const RECORDABLE_TYPES: ReadonlySet<string> = new Set([
|
|
|
275
277
|
"model.completed",
|
|
276
278
|
"model.failed",
|
|
277
279
|
"usage.recorded",
|
|
280
|
+
// The conversation is the client's to shorten: the runtime holds the events either way.
|
|
281
|
+
"conversation.compacted",
|
|
278
282
|
]);
|
|
279
283
|
|
|
280
284
|
const SESSION_HAS_NO_TOOLS =
|
|
@@ -305,6 +309,20 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
305
309
|
authority: () => authority,
|
|
306
310
|
});
|
|
307
311
|
const works = new WorkStore(workspaceRoot);
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Opens a work for one piece of work on it, and closes it however that ends. The lock a work
|
|
315
|
+
* holds is the single-writer rule, so it must not outlive the call that took it; releasing it
|
|
316
|
+
* here means no caller can forget to.
|
|
317
|
+
*/
|
|
318
|
+
async function withWork<T>(id: WorkId, use: (opened: WorkHandle) => Promise<T>): Promise<T> {
|
|
319
|
+
const opened = await works.open(id);
|
|
320
|
+
try {
|
|
321
|
+
return await use(opened);
|
|
322
|
+
} finally {
|
|
323
|
+
await opened.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
308
326
|
const session = new Session();
|
|
309
327
|
const server = new Server(
|
|
310
328
|
{ name: "openshain", version: pkg.version },
|
|
@@ -371,6 +389,13 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
371
389
|
const id = parseWorkId((input as { id: string }).id);
|
|
372
390
|
const work = await works.get(id);
|
|
373
391
|
if (isTerminal(work.status)) return failure(`work ${id} is already ${work.status}`);
|
|
392
|
+
// Selecting is what lets a client record into a work. Another person's conversation is
|
|
393
|
+
// theirs: a client that could select it could write what they never said.
|
|
394
|
+
if (work.principal !== config.principal.id) {
|
|
395
|
+
return failure(
|
|
396
|
+
`work ${id} belongs to ${work.principal}; a client selects only the works of the person it acts for`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
374
399
|
session.select(id);
|
|
375
400
|
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
376
401
|
}
|
|
@@ -394,17 +419,14 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
394
419
|
}
|
|
395
420
|
const { question } = input as { question: string };
|
|
396
421
|
const callId = newCallId();
|
|
397
|
-
|
|
398
|
-
try {
|
|
422
|
+
await withWork(gate.id, async (opened) => {
|
|
399
423
|
await opened.append({
|
|
400
424
|
type: "tool.called",
|
|
401
425
|
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
|
|
402
426
|
});
|
|
403
427
|
await opened.append({ type: "human.input_requested", payload: { callId, question } });
|
|
404
428
|
await opened.transition("waiting_input", "the agent asked the person a question");
|
|
405
|
-
}
|
|
406
|
-
await opened.close();
|
|
407
|
-
}
|
|
429
|
+
});
|
|
408
430
|
return json({ pending: true, call_id: callId, question });
|
|
409
431
|
}
|
|
410
432
|
case "work_answer": {
|
|
@@ -421,8 +443,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
421
443
|
`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`,
|
|
422
444
|
);
|
|
423
445
|
}
|
|
424
|
-
|
|
425
|
-
try {
|
|
446
|
+
return await withWork(gate.id, async (opened) => {
|
|
426
447
|
await opened.append({ type: "human.input_provided", payload: { callId, answer } });
|
|
427
448
|
await opened.append({
|
|
428
449
|
type: "tool.completed",
|
|
@@ -430,9 +451,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
430
451
|
});
|
|
431
452
|
await opened.transition("in_progress", "the person answered");
|
|
432
453
|
return json(await opened.current());
|
|
433
|
-
}
|
|
434
|
-
await opened.close();
|
|
435
|
-
}
|
|
454
|
+
});
|
|
436
455
|
}
|
|
437
456
|
case "work_record": {
|
|
438
457
|
const { work_id, type, payload } = input as {
|
|
@@ -456,15 +475,12 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
456
475
|
if (type === "usage.recorded" && (parsed as { kind: string }).kind !== "model_inference") {
|
|
457
476
|
return failure("usage.recorded from a client must have kind model_inference");
|
|
458
477
|
}
|
|
459
|
-
|
|
460
|
-
try {
|
|
478
|
+
return await withWork(id, async (opened) => {
|
|
461
479
|
const status = (await opened.current()).status;
|
|
462
480
|
if (isTerminal(status)) return failure(`work ${id} is already ${status}`);
|
|
463
481
|
const event = await opened.append({ type, payload: parsed } as never);
|
|
464
482
|
return json({ id: event.id, seq: event.seq });
|
|
465
|
-
}
|
|
466
|
-
await opened.close();
|
|
467
|
-
}
|
|
483
|
+
});
|
|
468
484
|
}
|
|
469
485
|
case "context": {
|
|
470
486
|
const now = new Date();
|
|
@@ -484,8 +500,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
484
500
|
const current = session.current;
|
|
485
501
|
if (current && !isTerminal((await works.get(current)).status)) {
|
|
486
502
|
const callId = newCallId();
|
|
487
|
-
|
|
488
|
-
try {
|
|
503
|
+
await withWork(current, async (opened) => {
|
|
489
504
|
await opened.append({
|
|
490
505
|
type: "tool.called",
|
|
491
506
|
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
|
|
@@ -494,9 +509,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
494
509
|
type: "tool.completed",
|
|
495
510
|
payload: { callId, content: [{ type: "json", value: info }], isError: false },
|
|
496
511
|
});
|
|
497
|
-
}
|
|
498
|
-
await opened.close();
|
|
499
|
-
}
|
|
512
|
+
});
|
|
500
513
|
}
|
|
501
514
|
return result;
|
|
502
515
|
}
|
|
@@ -531,8 +544,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
531
544
|
`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`,
|
|
532
545
|
);
|
|
533
546
|
}
|
|
534
|
-
|
|
535
|
-
try {
|
|
547
|
+
return await withWork(workId, async (opened) => {
|
|
536
548
|
// Under the lock: another connection may have decided this approval in between.
|
|
537
549
|
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
538
550
|
return failure(`approval ${approvalId} was already decided`);
|
|
@@ -566,9 +578,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
566
578
|
work_id: workId,
|
|
567
579
|
result: { content: result.content, isError: result.isError ?? false },
|
|
568
580
|
});
|
|
569
|
-
}
|
|
570
|
-
await opened.close();
|
|
571
|
-
}
|
|
581
|
+
});
|
|
572
582
|
}
|
|
573
583
|
case "review_decide": {
|
|
574
584
|
const {
|
|
@@ -638,8 +648,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
638
648
|
}
|
|
639
649
|
record = parsed.data;
|
|
640
650
|
}
|
|
641
|
-
|
|
642
|
-
try {
|
|
651
|
+
return await withWork(workId, async (opened) => {
|
|
643
652
|
// Under the lock: another connection may have decided this approval in between.
|
|
644
653
|
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
645
654
|
return failure(`approval ${approvalId} was already decided`);
|
|
@@ -693,9 +702,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
693
702
|
decision_file: relative(workspaceRoot, file),
|
|
694
703
|
result: { content: ran.content, isError: ran.isError ?? false },
|
|
695
704
|
});
|
|
696
|
-
}
|
|
697
|
-
await opened.close();
|
|
698
|
-
}
|
|
705
|
+
});
|
|
699
706
|
}
|
|
700
707
|
case "work_list": {
|
|
701
708
|
const { works: all, problems } = await works.list();
|
|
@@ -723,7 +730,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
723
730
|
summary: string;
|
|
724
731
|
artifacts?: { path: string; sha256?: string }[];
|
|
725
732
|
};
|
|
726
|
-
const work = await
|
|
733
|
+
const work = await withWork(gate.id, (opened) =>
|
|
734
|
+
complete(workspaceRoot, opened, summary, artifacts ?? []),
|
|
735
|
+
);
|
|
727
736
|
session.clear();
|
|
728
737
|
return json(work);
|
|
729
738
|
}
|
|
@@ -731,12 +740,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
731
740
|
const gate = await openWork();
|
|
732
741
|
if ("refused" in gate) return gate.refused;
|
|
733
742
|
const { reason, detail } = input as { reason: string; detail?: string };
|
|
734
|
-
|
|
735
|
-
try {
|
|
743
|
+
await withWork(gate.id, async (opened) => {
|
|
736
744
|
await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
|
|
737
|
-
}
|
|
738
|
-
await opened.close();
|
|
739
|
-
}
|
|
745
|
+
});
|
|
740
746
|
session.clear();
|
|
741
747
|
return json(await works.get(gate.id));
|
|
742
748
|
}
|
|
@@ -755,8 +761,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
755
761
|
`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`,
|
|
756
762
|
);
|
|
757
763
|
}
|
|
758
|
-
|
|
759
|
-
try {
|
|
764
|
+
return await withWork(gate.id, async (opened) => {
|
|
760
765
|
const limit = config.limits.maxToolCalls;
|
|
761
766
|
if (countToolCalls(await opened.events()) >= limit) {
|
|
762
767
|
const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
|
|
@@ -768,9 +773,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
768
773
|
}
|
|
769
774
|
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
770
775
|
return toMcpResult(result);
|
|
771
|
-
}
|
|
772
|
-
await opened.close();
|
|
773
|
-
}
|
|
776
|
+
});
|
|
774
777
|
}
|
|
775
778
|
}
|
|
776
779
|
}
|
|
@@ -799,38 +802,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
799
802
|
* of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
|
|
800
803
|
*/
|
|
801
804
|
async function complete(
|
|
802
|
-
works: WorkStore,
|
|
803
805
|
workspaceRoot: string,
|
|
804
|
-
|
|
806
|
+
opened: WorkHandle,
|
|
805
807
|
summary: string,
|
|
806
808
|
claimed: { path: string; sha256?: string }[],
|
|
807
809
|
): Promise<Work> {
|
|
808
810
|
for (const { path } of claimed) await resolveWorkspacePath(workspaceRoot, path);
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
for (const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
823
|
-
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
824
|
-
}
|
|
825
|
-
await opened.append({
|
|
826
|
-
type: "evidence.recorded",
|
|
827
|
-
payload: { claim: summary, refs, artifacts },
|
|
828
|
-
});
|
|
829
|
-
await opened.append({ type: "work.completed", payload: { summary } });
|
|
830
|
-
return opened.current();
|
|
831
|
-
} finally {
|
|
832
|
-
await opened.close();
|
|
811
|
+
const events = await opened.events();
|
|
812
|
+
const refs: string[] = [];
|
|
813
|
+
const byPath = new Map<string, string>();
|
|
814
|
+
for (const event of writesWithAfter(events)) {
|
|
815
|
+
refs.push(event.id);
|
|
816
|
+
for (const { path, sha256 } of event.payload.after ?? []) byPath.set(path, sha256);
|
|
817
|
+
}
|
|
818
|
+
const written = new Set(byPath.keys());
|
|
819
|
+
for (const { path, sha256 } of claimed) if (!byPath.has(path)) byPath.set(path, sha256 ?? "");
|
|
820
|
+
const artifacts: Artifact[] = [];
|
|
821
|
+
for (const [path, reported] of byPath) {
|
|
822
|
+
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
823
|
+
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
833
824
|
}
|
|
825
|
+
await opened.append({
|
|
826
|
+
type: "evidence.recorded",
|
|
827
|
+
payload: { claim: summary, refs, artifacts },
|
|
828
|
+
});
|
|
829
|
+
await opened.append({ type: "work.completed", payload: { summary } });
|
|
830
|
+
return opened.current();
|
|
834
831
|
}
|
|
835
832
|
|
|
836
833
|
function writesWithAfter(events: AnyEvent[]): Event<"tool.completed">[] {
|