@openshain/mcp 0.4.0 → 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 +65 -91
- package/package.json +3 -3
- package/src/server.ts +69 -83
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
-
import { ASK_USER, compileInputValidator, countToolCalls, createToolCaller, createToolRegistry, DecisionFileSchema, isKnownEventType, isOpenshainError, isTerminal, loadAuthority, loadConfig, parsePayloadFile, parseWorkId, pendingApprovals, pendingQuestions, RUNTIME_PROVIDER_ID, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, workHistory, writeDecision, } from "@openshain/core";
|
|
4
|
+
import { ASK_USER, businessDate, companyTime, compileInputValidator, countToolCalls, createToolCaller, createToolRegistry, DecisionFileSchema, isKnownEventType, isOpenshainError, isTerminal, loadAuthority, loadConfig, parsePayloadFile, parseWorkId, pendingApprovals, pendingQuestions, RUNTIME_PROVIDER_ID, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, workHistory, writeDecision, } from "@openshain/core";
|
|
5
5
|
import pkg from "../package.json" with { type: "json" };
|
|
6
6
|
import { Session } from "./session.js";
|
|
7
7
|
/** The tools every session has, before the workspace's own. Their names are reserved in the runtime. */
|
|
@@ -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,25 +386,21 @@ 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();
|
|
389
|
-
const timezone =
|
|
399
|
+
const timezone = config.company.timezone;
|
|
390
400
|
const info = {
|
|
391
|
-
now:
|
|
401
|
+
now: companyTime(timezone, now),
|
|
392
402
|
timezone,
|
|
393
|
-
business_date:
|
|
403
|
+
business_date: businessDate(timezone, now),
|
|
394
404
|
workspace: workspaceRoot,
|
|
395
405
|
company: config.company.name,
|
|
396
406
|
principal: { id: config.principal.id, name: config.principal.name },
|
|
@@ -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" &&
|
|
@@ -749,13 +733,3 @@ async function findApproval(works, approvalId) {
|
|
|
749
733
|
}
|
|
750
734
|
return undefined;
|
|
751
735
|
}
|
|
752
|
-
/** ISO 8601 with the local offset instead of Z, so the time reads as the person's clock. */
|
|
753
|
-
function localIso(date) {
|
|
754
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
755
|
-
const offset = -date.getTimezoneOffset();
|
|
756
|
-
const sign = offset >= 0 ? "+" : "-";
|
|
757
|
-
const abs = Math.abs(offset);
|
|
758
|
-
return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
759
|
-
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
|
|
760
|
-
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`);
|
|
761
|
-
}
|
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
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type AnyEvent,
|
|
11
11
|
type Artifact,
|
|
12
12
|
ASK_USER,
|
|
13
|
+
businessDate,
|
|
14
|
+
companyTime,
|
|
13
15
|
compileInputValidator,
|
|
14
16
|
countToolCalls,
|
|
15
17
|
createToolCaller,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
uuidv7,
|
|
39
41
|
verifyArtifact,
|
|
40
42
|
type Work,
|
|
43
|
+
type WorkHandle,
|
|
41
44
|
type WorkId,
|
|
42
45
|
WorkStore,
|
|
43
46
|
workHistory,
|
|
@@ -252,6 +255,7 @@ const WORK_TOOLS: Tool[] = [
|
|
|
252
255
|
"model.completed",
|
|
253
256
|
"model.failed",
|
|
254
257
|
"usage.recorded",
|
|
258
|
+
"conversation.compacted",
|
|
255
259
|
],
|
|
256
260
|
},
|
|
257
261
|
payload: { type: "object" },
|
|
@@ -273,6 +277,8 @@ const RECORDABLE_TYPES: ReadonlySet<string> = new Set([
|
|
|
273
277
|
"model.completed",
|
|
274
278
|
"model.failed",
|
|
275
279
|
"usage.recorded",
|
|
280
|
+
// The conversation is the client's to shorten: the runtime holds the events either way.
|
|
281
|
+
"conversation.compacted",
|
|
276
282
|
]);
|
|
277
283
|
|
|
278
284
|
const SESSION_HAS_NO_TOOLS =
|
|
@@ -303,6 +309,20 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
303
309
|
authority: () => authority,
|
|
304
310
|
});
|
|
305
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
|
+
}
|
|
306
326
|
const session = new Session();
|
|
307
327
|
const server = new Server(
|
|
308
328
|
{ name: "openshain", version: pkg.version },
|
|
@@ -369,6 +389,13 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
369
389
|
const id = parseWorkId((input as { id: string }).id);
|
|
370
390
|
const work = await works.get(id);
|
|
371
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
|
+
}
|
|
372
399
|
session.select(id);
|
|
373
400
|
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
374
401
|
}
|
|
@@ -392,17 +419,14 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
392
419
|
}
|
|
393
420
|
const { question } = input as { question: string };
|
|
394
421
|
const callId = newCallId();
|
|
395
|
-
|
|
396
|
-
try {
|
|
422
|
+
await withWork(gate.id, async (opened) => {
|
|
397
423
|
await opened.append({
|
|
398
424
|
type: "tool.called",
|
|
399
425
|
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
|
|
400
426
|
});
|
|
401
427
|
await opened.append({ type: "human.input_requested", payload: { callId, question } });
|
|
402
428
|
await opened.transition("waiting_input", "the agent asked the person a question");
|
|
403
|
-
}
|
|
404
|
-
await opened.close();
|
|
405
|
-
}
|
|
429
|
+
});
|
|
406
430
|
return json({ pending: true, call_id: callId, question });
|
|
407
431
|
}
|
|
408
432
|
case "work_answer": {
|
|
@@ -419,8 +443,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
419
443
|
`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`,
|
|
420
444
|
);
|
|
421
445
|
}
|
|
422
|
-
|
|
423
|
-
try {
|
|
446
|
+
return await withWork(gate.id, async (opened) => {
|
|
424
447
|
await opened.append({ type: "human.input_provided", payload: { callId, answer } });
|
|
425
448
|
await opened.append({
|
|
426
449
|
type: "tool.completed",
|
|
@@ -428,9 +451,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
428
451
|
});
|
|
429
452
|
await opened.transition("in_progress", "the person answered");
|
|
430
453
|
return json(await opened.current());
|
|
431
|
-
}
|
|
432
|
-
await opened.close();
|
|
433
|
-
}
|
|
454
|
+
});
|
|
434
455
|
}
|
|
435
456
|
case "work_record": {
|
|
436
457
|
const { work_id, type, payload } = input as {
|
|
@@ -454,23 +475,20 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
454
475
|
if (type === "usage.recorded" && (parsed as { kind: string }).kind !== "model_inference") {
|
|
455
476
|
return failure("usage.recorded from a client must have kind model_inference");
|
|
456
477
|
}
|
|
457
|
-
|
|
458
|
-
try {
|
|
478
|
+
return await withWork(id, async (opened) => {
|
|
459
479
|
const status = (await opened.current()).status;
|
|
460
480
|
if (isTerminal(status)) return failure(`work ${id} is already ${status}`);
|
|
461
481
|
const event = await opened.append({ type, payload: parsed } as never);
|
|
462
482
|
return json({ id: event.id, seq: event.seq });
|
|
463
|
-
}
|
|
464
|
-
await opened.close();
|
|
465
|
-
}
|
|
483
|
+
});
|
|
466
484
|
}
|
|
467
485
|
case "context": {
|
|
468
486
|
const now = new Date();
|
|
469
|
-
const timezone =
|
|
487
|
+
const timezone = config.company.timezone;
|
|
470
488
|
const info = {
|
|
471
|
-
now:
|
|
489
|
+
now: companyTime(timezone, now),
|
|
472
490
|
timezone,
|
|
473
|
-
business_date:
|
|
491
|
+
business_date: businessDate(timezone, now),
|
|
474
492
|
workspace: workspaceRoot,
|
|
475
493
|
company: config.company.name,
|
|
476
494
|
principal: { id: config.principal.id, name: config.principal.name },
|
|
@@ -482,8 +500,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
482
500
|
const current = session.current;
|
|
483
501
|
if (current && !isTerminal((await works.get(current)).status)) {
|
|
484
502
|
const callId = newCallId();
|
|
485
|
-
|
|
486
|
-
try {
|
|
503
|
+
await withWork(current, async (opened) => {
|
|
487
504
|
await opened.append({
|
|
488
505
|
type: "tool.called",
|
|
489
506
|
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
|
|
@@ -492,9 +509,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
492
509
|
type: "tool.completed",
|
|
493
510
|
payload: { callId, content: [{ type: "json", value: info }], isError: false },
|
|
494
511
|
});
|
|
495
|
-
}
|
|
496
|
-
await opened.close();
|
|
497
|
-
}
|
|
512
|
+
});
|
|
498
513
|
}
|
|
499
514
|
return result;
|
|
500
515
|
}
|
|
@@ -529,8 +544,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
529
544
|
`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`,
|
|
530
545
|
);
|
|
531
546
|
}
|
|
532
|
-
|
|
533
|
-
try {
|
|
547
|
+
return await withWork(workId, async (opened) => {
|
|
534
548
|
// Under the lock: another connection may have decided this approval in between.
|
|
535
549
|
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
536
550
|
return failure(`approval ${approvalId} was already decided`);
|
|
@@ -564,9 +578,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
564
578
|
work_id: workId,
|
|
565
579
|
result: { content: result.content, isError: result.isError ?? false },
|
|
566
580
|
});
|
|
567
|
-
}
|
|
568
|
-
await opened.close();
|
|
569
|
-
}
|
|
581
|
+
});
|
|
570
582
|
}
|
|
571
583
|
case "review_decide": {
|
|
572
584
|
const {
|
|
@@ -636,8 +648,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
636
648
|
}
|
|
637
649
|
record = parsed.data;
|
|
638
650
|
}
|
|
639
|
-
|
|
640
|
-
try {
|
|
651
|
+
return await withWork(workId, async (opened) => {
|
|
641
652
|
// Under the lock: another connection may have decided this approval in between.
|
|
642
653
|
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
643
654
|
return failure(`approval ${approvalId} was already decided`);
|
|
@@ -691,9 +702,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
691
702
|
decision_file: relative(workspaceRoot, file),
|
|
692
703
|
result: { content: ran.content, isError: ran.isError ?? false },
|
|
693
704
|
});
|
|
694
|
-
}
|
|
695
|
-
await opened.close();
|
|
696
|
-
}
|
|
705
|
+
});
|
|
697
706
|
}
|
|
698
707
|
case "work_list": {
|
|
699
708
|
const { works: all, problems } = await works.list();
|
|
@@ -721,7 +730,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
721
730
|
summary: string;
|
|
722
731
|
artifacts?: { path: string; sha256?: string }[];
|
|
723
732
|
};
|
|
724
|
-
const work = await
|
|
733
|
+
const work = await withWork(gate.id, (opened) =>
|
|
734
|
+
complete(workspaceRoot, opened, summary, artifacts ?? []),
|
|
735
|
+
);
|
|
725
736
|
session.clear();
|
|
726
737
|
return json(work);
|
|
727
738
|
}
|
|
@@ -729,12 +740,9 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
729
740
|
const gate = await openWork();
|
|
730
741
|
if ("refused" in gate) return gate.refused;
|
|
731
742
|
const { reason, detail } = input as { reason: string; detail?: string };
|
|
732
|
-
|
|
733
|
-
try {
|
|
743
|
+
await withWork(gate.id, async (opened) => {
|
|
734
744
|
await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
|
|
735
|
-
}
|
|
736
|
-
await opened.close();
|
|
737
|
-
}
|
|
745
|
+
});
|
|
738
746
|
session.clear();
|
|
739
747
|
return json(await works.get(gate.id));
|
|
740
748
|
}
|
|
@@ -753,8 +761,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
753
761
|
`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`,
|
|
754
762
|
);
|
|
755
763
|
}
|
|
756
|
-
|
|
757
|
-
try {
|
|
764
|
+
return await withWork(gate.id, async (opened) => {
|
|
758
765
|
const limit = config.limits.maxToolCalls;
|
|
759
766
|
if (countToolCalls(await opened.events()) >= limit) {
|
|
760
767
|
const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
|
|
@@ -766,9 +773,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
766
773
|
}
|
|
767
774
|
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
768
775
|
return toMcpResult(result);
|
|
769
|
-
}
|
|
770
|
-
await opened.close();
|
|
771
|
-
}
|
|
776
|
+
});
|
|
772
777
|
}
|
|
773
778
|
}
|
|
774
779
|
}
|
|
@@ -797,38 +802,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
797
802
|
* of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
|
|
798
803
|
*/
|
|
799
804
|
async function complete(
|
|
800
|
-
works: WorkStore,
|
|
801
805
|
workspaceRoot: string,
|
|
802
|
-
|
|
806
|
+
opened: WorkHandle,
|
|
803
807
|
summary: string,
|
|
804
808
|
claimed: { path: string; sha256?: string }[],
|
|
805
809
|
): Promise<Work> {
|
|
806
810
|
for (const { path } of claimed) await resolveWorkspacePath(workspaceRoot, path);
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
for (const
|
|
813
|
-
refs.push(event.id);
|
|
814
|
-
for (const { path, sha256 } of event.payload.after ?? []) byPath.set(path, sha256);
|
|
815
|
-
}
|
|
816
|
-
const written = new Set(byPath.keys());
|
|
817
|
-
for (const { path, sha256 } of claimed) if (!byPath.has(path)) byPath.set(path, sha256 ?? "");
|
|
818
|
-
const artifacts: Artifact[] = [];
|
|
819
|
-
for (const [path, reported] of byPath) {
|
|
820
|
-
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
821
|
-
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
822
|
-
}
|
|
823
|
-
await opened.append({
|
|
824
|
-
type: "evidence.recorded",
|
|
825
|
-
payload: { claim: summary, refs, artifacts },
|
|
826
|
-
});
|
|
827
|
-
await opened.append({ type: "work.completed", payload: { summary } });
|
|
828
|
-
return opened.current();
|
|
829
|
-
} finally {
|
|
830
|
-
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);
|
|
831
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 });
|
|
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();
|
|
832
831
|
}
|
|
833
832
|
|
|
834
833
|
function writesWithAfter(events: AnyEvent[]): Event<"tool.completed">[] {
|
|
@@ -887,16 +886,3 @@ async function findApproval(
|
|
|
887
886
|
}
|
|
888
887
|
return undefined;
|
|
889
888
|
}
|
|
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
|
-
}
|