@openshain/mcp 0.3.1 → 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 +290 -2
- package/package.json +5 -4
- package/src/server.ts +345 -1
package/NOTICE
ADDED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
1
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import { ASK_USER, compileInputValidator, countToolCalls, createToolCaller, createToolRegistry, isKnownEventType, isOpenshainError, isTerminal, loadConfig, parsePayloadFile, parseWorkId, pendingQuestions, RUNTIME_PROVIDER_ID, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, workHistory, } from "@openshain/core";
|
|
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
5
|
import pkg from "../package.json" with { type: "json" };
|
|
5
6
|
import { Session } from "./session.js";
|
|
6
7
|
/** The tools every session has, before the workspace's own. Their names are reserved in the runtime. */
|
|
@@ -116,6 +117,67 @@ const WORK_TOOLS = [
|
|
|
116
117
|
additionalProperties: false,
|
|
117
118
|
},
|
|
118
119
|
},
|
|
120
|
+
{
|
|
121
|
+
name: "context",
|
|
122
|
+
description: "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.",
|
|
123
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
124
|
+
annotations: { readOnlyHint: true },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "approval_list",
|
|
128
|
+
description: "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.",
|
|
129
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
130
|
+
annotations: { readOnlyHint: true },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "approval_decide",
|
|
134
|
+
description: "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.",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: {
|
|
138
|
+
approval_id: { type: "string", maxLength: 100 },
|
|
139
|
+
decision: { type: "string", enum: ["approve", "reject"] },
|
|
140
|
+
comment: { type: "string", maxLength: 2000 },
|
|
141
|
+
},
|
|
142
|
+
required: ["approval_id", "decision"],
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "review_decide",
|
|
148
|
+
description: "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.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: {
|
|
152
|
+
approval_id: { type: "string", maxLength: 100 },
|
|
153
|
+
decision: { type: "string", enum: ["approve", "reject", "modify"] },
|
|
154
|
+
reviewer: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
name: { type: "string", maxLength: 200 },
|
|
158
|
+
role: { type: "string", maxLength: 100 },
|
|
159
|
+
qualification: { type: "string", maxLength: 500 },
|
|
160
|
+
},
|
|
161
|
+
required: ["name", "role"],
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
},
|
|
164
|
+
interpretation: { type: "string", maxLength: 20_000 },
|
|
165
|
+
modified_input: { type: "object" },
|
|
166
|
+
effective_from: { type: "string", maxLength: 10 },
|
|
167
|
+
effective_until: { type: "string", maxLength: 10 },
|
|
168
|
+
applies_to: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: {
|
|
171
|
+
action: { type: "string", maxLength: 200 },
|
|
172
|
+
path: { type: "string", maxLength: 1000 },
|
|
173
|
+
},
|
|
174
|
+
additionalProperties: false,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
required: ["approval_id", "decision", "reviewer"],
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
119
181
|
{
|
|
120
182
|
name: "work_record",
|
|
121
183
|
description: "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.",
|
|
@@ -164,7 +226,14 @@ export async function createMcpServer(options) {
|
|
|
164
226
|
const { workspaceRoot } = options;
|
|
165
227
|
const config = await loadConfig(workspaceRoot);
|
|
166
228
|
const registry = await createToolRegistry(workspaceRoot, config, options.tools);
|
|
167
|
-
|
|
229
|
+
// Reloaded when a reviewer writes a decision, so the next call can cite it.
|
|
230
|
+
let authority = await loadAuthority(workspaceRoot);
|
|
231
|
+
const callTool = createToolCaller({
|
|
232
|
+
registry,
|
|
233
|
+
config,
|
|
234
|
+
workspaceRoot,
|
|
235
|
+
authority: () => authority,
|
|
236
|
+
});
|
|
168
237
|
const works = new WorkStore(workspaceRoot);
|
|
169
238
|
const session = new Session();
|
|
170
239
|
const server = new Server({ name: "openshain", version: pkg.version }, { capabilities: { tools: {} } });
|
|
@@ -315,6 +384,200 @@ export async function createMcpServer(options) {
|
|
|
315
384
|
await opened.close();
|
|
316
385
|
}
|
|
317
386
|
}
|
|
387
|
+
case "context": {
|
|
388
|
+
const now = new Date();
|
|
389
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
390
|
+
const info = {
|
|
391
|
+
now: localIso(now),
|
|
392
|
+
timezone,
|
|
393
|
+
business_date: localIso(now).slice(0, 10),
|
|
394
|
+
workspace: workspaceRoot,
|
|
395
|
+
company: config.company.name,
|
|
396
|
+
principal: { id: config.principal.id, name: config.principal.name },
|
|
397
|
+
profession: config.profession.id,
|
|
398
|
+
work: session.current ?? null,
|
|
399
|
+
};
|
|
400
|
+
const result = json(info);
|
|
401
|
+
// Recorded on the current work when there is one, even a session: it touches no file.
|
|
402
|
+
const current = session.current;
|
|
403
|
+
if (current && !isTerminal((await works.get(current)).status)) {
|
|
404
|
+
const callId = newCallId();
|
|
405
|
+
const opened = await works.open(current);
|
|
406
|
+
try {
|
|
407
|
+
await opened.append({
|
|
408
|
+
type: "tool.called",
|
|
409
|
+
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: "context", input: {} },
|
|
410
|
+
});
|
|
411
|
+
await opened.append({
|
|
412
|
+
type: "tool.completed",
|
|
413
|
+
payload: { callId, content: [{ type: "json", value: info }], isError: false },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
await opened.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
case "approval_list": {
|
|
423
|
+
const held = [];
|
|
424
|
+
const { works: all } = await works.list();
|
|
425
|
+
for (const w of all) {
|
|
426
|
+
if (w.status !== "waiting_approval")
|
|
427
|
+
continue;
|
|
428
|
+
for (const a of pendingApprovals(await works.events(w.id))) {
|
|
429
|
+
held.push({ ...a, work_id: w.id, objective: w.objective });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return json({ approvals: held });
|
|
433
|
+
}
|
|
434
|
+
case "approval_decide": {
|
|
435
|
+
const { approval_id: approvalId, decision, comment, } = input;
|
|
436
|
+
const found = await findApproval(works, approvalId);
|
|
437
|
+
if (!found)
|
|
438
|
+
return failure(`no pending approval ${approvalId}`);
|
|
439
|
+
const { workId, approval } = found;
|
|
440
|
+
const by = config.principal.id;
|
|
441
|
+
if (approval.kind === "review") {
|
|
442
|
+
return failure(`${approvalId} waits for a qualified reviewer, not a person's approval; use review_decide`);
|
|
443
|
+
}
|
|
444
|
+
if (approval.approvers && !approval.approvers.includes(by)) {
|
|
445
|
+
return failure(`${by} may not decide ${approvalId}; approvers: ${approval.approvers.join(", ")}`);
|
|
446
|
+
}
|
|
447
|
+
const opened = await works.open(workId);
|
|
448
|
+
try {
|
|
449
|
+
// Under the lock: another connection may have decided this approval in between.
|
|
450
|
+
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
451
|
+
return failure(`approval ${approvalId} was already decided`);
|
|
452
|
+
}
|
|
453
|
+
await opened.append({
|
|
454
|
+
type: "approval.decided",
|
|
455
|
+
payload: { approvalId, decision, by, ...(comment !== undefined && { comment }) },
|
|
456
|
+
});
|
|
457
|
+
if (decision === "reject") {
|
|
458
|
+
await opened.append({
|
|
459
|
+
type: "tool.rejected",
|
|
460
|
+
payload: {
|
|
461
|
+
callId: approval.call.callId,
|
|
462
|
+
name: approval.call.name,
|
|
463
|
+
code: "rejected_by_person",
|
|
464
|
+
reason: comment ?? `${by} rejected ${approvalId}`,
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
await opened.transition("in_progress", `${by} rejected ${approvalId}`);
|
|
468
|
+
return json({ approval_id: approvalId, decision, work_id: workId });
|
|
469
|
+
}
|
|
470
|
+
await opened.transition("in_progress", `${by} approved ${approvalId}`);
|
|
471
|
+
const result = await callTool(opened, { id: approval.call.callId, name: approval.call.name, input: approval.call.input }, { approvedBy: approvalId });
|
|
472
|
+
return json({
|
|
473
|
+
approval_id: approvalId,
|
|
474
|
+
decision,
|
|
475
|
+
work_id: workId,
|
|
476
|
+
result: { content: result.content, isError: result.isError ?? false },
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
await opened.close();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
case "review_decide": {
|
|
484
|
+
const { approval_id: approvalId, decision, reviewer, interpretation, modified_input: modifiedInput, effective_from: effectiveFrom, effective_until: effectiveUntil, applies_to: appliesTo, } = input;
|
|
485
|
+
const found = await findApproval(works, approvalId);
|
|
486
|
+
if (!found)
|
|
487
|
+
return failure(`no pending approval ${approvalId}`);
|
|
488
|
+
const { workId, approval } = found;
|
|
489
|
+
if (approval.kind !== "review") {
|
|
490
|
+
return failure(`${approvalId} waits for a person's approval, not a review; use approval_decide`);
|
|
491
|
+
}
|
|
492
|
+
if (decision !== "reject" && (interpretation ?? "") === "") {
|
|
493
|
+
return failure("a decision needs the reviewer's interpretation in their own words");
|
|
494
|
+
}
|
|
495
|
+
if (approval.reviewer && approval.reviewer.role !== reviewer.role) {
|
|
496
|
+
return failure(`rule ${approval.ruleId} asks for a ${approval.reviewer.role}; the decision names a ${reviewer.role}`);
|
|
497
|
+
}
|
|
498
|
+
if (decision === "modify" && modifiedInput) {
|
|
499
|
+
// Checked before anything is recorded: a refused input leaves the approval pending.
|
|
500
|
+
const before = (approval.call.input ?? {});
|
|
501
|
+
const after = modifiedInput;
|
|
502
|
+
if (before.path !== after.path) {
|
|
503
|
+
return failure(`a modified call must touch the same path: ${String(before.path)} was held, ${String(after.path)} was given`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Built and checked before anything is recorded: an input the decision refuses must not
|
|
507
|
+
// consume the approval and leave the work waiting with nobody able to move it.
|
|
508
|
+
const today = new Date();
|
|
509
|
+
let record;
|
|
510
|
+
if (decision !== "reject") {
|
|
511
|
+
const parsed = DecisionFileSchema.safeParse({
|
|
512
|
+
id: `dec_${uuidv7()}`,
|
|
513
|
+
reviewer,
|
|
514
|
+
approval_id: approvalId,
|
|
515
|
+
decided_at: today.toISOString(),
|
|
516
|
+
effective_from: effectiveFrom ?? today.toISOString().slice(0, 10),
|
|
517
|
+
effective_until: effectiveUntil ?? null,
|
|
518
|
+
interpretation,
|
|
519
|
+
applies_to: appliesTo ?? {},
|
|
520
|
+
});
|
|
521
|
+
if (!parsed.success) {
|
|
522
|
+
return failure(`the decision is not well formed: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
523
|
+
}
|
|
524
|
+
record = parsed.data;
|
|
525
|
+
}
|
|
526
|
+
const opened = await works.open(workId);
|
|
527
|
+
try {
|
|
528
|
+
// Under the lock: another connection may have decided this approval in between.
|
|
529
|
+
if (!pendingApprovals(await opened.events()).some((a) => a.approvalId === approvalId)) {
|
|
530
|
+
return failure(`approval ${approvalId} was already decided`);
|
|
531
|
+
}
|
|
532
|
+
await opened.append({
|
|
533
|
+
type: "approval.decided",
|
|
534
|
+
payload: {
|
|
535
|
+
approvalId,
|
|
536
|
+
decision,
|
|
537
|
+
by: reviewer.name,
|
|
538
|
+
...(interpretation !== undefined && { comment: interpretation }),
|
|
539
|
+
...(decision === "modify" && modifiedInput && { modifiedInput }),
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
if (decision === "reject") {
|
|
543
|
+
await opened.append({ type: "review.decided", payload: { approvalId } });
|
|
544
|
+
await opened.append({
|
|
545
|
+
type: "tool.rejected",
|
|
546
|
+
payload: {
|
|
547
|
+
callId: approval.call.callId,
|
|
548
|
+
name: approval.call.name,
|
|
549
|
+
code: "rejected_by_person",
|
|
550
|
+
reason: interpretation ?? `${reviewer.name} did not approve ${approvalId}`,
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
await opened.transition("in_progress", `${reviewer.name} rejected ${approvalId}`);
|
|
554
|
+
return json({ approval_id: approvalId, decision, work_id: workId });
|
|
555
|
+
}
|
|
556
|
+
const written = record;
|
|
557
|
+
const file = await writeDecision(workspaceRoot, written);
|
|
558
|
+
await opened.append({
|
|
559
|
+
type: "review.decided",
|
|
560
|
+
payload: { approvalId, decisionId: written.id },
|
|
561
|
+
});
|
|
562
|
+
await opened.transition("in_progress", `${reviewer.name} decided ${approvalId}`);
|
|
563
|
+
const ranWith = decision === "modify" && modifiedInput ? modifiedInput : approval.call.input;
|
|
564
|
+
const ran = await callTool(opened, { id: approval.call.callId, name: approval.call.name, input: ranWith }, { approvedBy: approvalId });
|
|
565
|
+
// The decision is on disk; a rule that cites its id can use it from here on. Reloaded
|
|
566
|
+
// so that a rule already written for it takes effect without a restart.
|
|
567
|
+
authority = await loadAuthority(workspaceRoot);
|
|
568
|
+
return json({
|
|
569
|
+
approval_id: approvalId,
|
|
570
|
+
decision,
|
|
571
|
+
work_id: workId,
|
|
572
|
+
decision_id: written.id,
|
|
573
|
+
decision_file: relative(workspaceRoot, file),
|
|
574
|
+
result: { content: ran.content, isError: ran.isError ?? false },
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
await opened.close();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
318
581
|
case "work_list": {
|
|
319
582
|
const { works: all, problems } = await works.list();
|
|
320
583
|
return json({
|
|
@@ -368,6 +631,9 @@ export async function createMcpServer(options) {
|
|
|
368
631
|
if (work.status === "waiting_input") {
|
|
369
632
|
return failure(`work ${gate.id} is waiting for the person's answer; record it with work_answer before calling tools`);
|
|
370
633
|
}
|
|
634
|
+
if (work.status === "waiting_approval") {
|
|
635
|
+
return failure(`work ${gate.id} is waiting for an approval; decide it with approval_decide (see approval_list) before calling tools`);
|
|
636
|
+
}
|
|
371
637
|
const opened = await works.open(gate.id);
|
|
372
638
|
try {
|
|
373
639
|
const limit = config.limits.maxToolCalls;
|
|
@@ -471,3 +737,25 @@ function failure(text) {
|
|
|
471
737
|
function newCallId() {
|
|
472
738
|
return `call_${uuidv7()}`;
|
|
473
739
|
}
|
|
740
|
+
/** The work holding a pending approval, found by scanning the works that wait for one. */
|
|
741
|
+
async function findApproval(works, approvalId) {
|
|
742
|
+
const { works: all } = await works.list();
|
|
743
|
+
for (const w of all) {
|
|
744
|
+
if (w.status !== "waiting_approval")
|
|
745
|
+
continue;
|
|
746
|
+
const approval = pendingApprovals(await works.events(w.id)).find((a) => a.approvalId === approvalId);
|
|
747
|
+
if (approval)
|
|
748
|
+
return { workId: w.id, approval };
|
|
749
|
+
}
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
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.4.0",
|
|
4
4
|
"description": "MCP server that exposes an openshain workspace to any agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"!src/**/*.test.ts",
|
|
31
31
|
"!src/**/*.test.tsx",
|
|
32
32
|
"README.md",
|
|
33
|
-
"LICENSE"
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"NOTICE"
|
|
34
35
|
],
|
|
35
36
|
"exports": {
|
|
36
37
|
".": {
|
|
@@ -45,10 +46,10 @@
|
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
48
|
-
"@openshain/core": "0.
|
|
49
|
+
"@openshain/core": "0.4.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
|
-
"@openshain/tools": "0.
|
|
52
|
+
"@openshain/tools": "0.4.0"
|
|
52
53
|
},
|
|
53
54
|
"publishConfig": {
|
|
54
55
|
"access": "public"
|
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,
|
|
@@ -13,15 +14,20 @@ import {
|
|
|
13
14
|
countToolCalls,
|
|
14
15
|
createToolCaller,
|
|
15
16
|
createToolRegistry,
|
|
17
|
+
DecisionFileSchema,
|
|
18
|
+
type DecisionRecord,
|
|
16
19
|
type Event,
|
|
17
20
|
type EventType,
|
|
18
21
|
type InputValidation,
|
|
19
22
|
isKnownEventType,
|
|
20
23
|
isOpenshainError,
|
|
21
24
|
isTerminal,
|
|
25
|
+
loadAuthority,
|
|
22
26
|
loadConfig,
|
|
27
|
+
type PendingApproval,
|
|
23
28
|
parsePayloadFile,
|
|
24
29
|
parseWorkId,
|
|
30
|
+
pendingApprovals,
|
|
25
31
|
pendingQuestions,
|
|
26
32
|
RUNTIME_PROVIDER_ID,
|
|
27
33
|
type RuntimeProviders,
|
|
@@ -35,6 +41,7 @@ import {
|
|
|
35
41
|
type WorkId,
|
|
36
42
|
WorkStore,
|
|
37
43
|
workHistory,
|
|
44
|
+
writeDecision,
|
|
38
45
|
} from "@openshain/core";
|
|
39
46
|
import pkg from "../package.json" with { type: "json" };
|
|
40
47
|
import { Session } from "./session.ts";
|
|
@@ -163,6 +170,71 @@ const WORK_TOOLS: Tool[] = [
|
|
|
163
170
|
additionalProperties: false,
|
|
164
171
|
},
|
|
165
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
|
+
},
|
|
166
238
|
{
|
|
167
239
|
name: "work_record",
|
|
168
240
|
description:
|
|
@@ -222,7 +294,14 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
222
294
|
const { workspaceRoot } = options;
|
|
223
295
|
const config = await loadConfig(workspaceRoot);
|
|
224
296
|
const registry = await createToolRegistry(workspaceRoot, config, options.tools);
|
|
225
|
-
|
|
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
|
+
});
|
|
226
305
|
const works = new WorkStore(workspaceRoot);
|
|
227
306
|
const session = new Session();
|
|
228
307
|
const server = new Server(
|
|
@@ -385,6 +464,237 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
385
464
|
await opened.close();
|
|
386
465
|
}
|
|
387
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
|
+
}
|
|
697
|
+
}
|
|
388
698
|
case "work_list": {
|
|
389
699
|
const { works: all, problems } = await works.list();
|
|
390
700
|
return json({
|
|
@@ -438,6 +748,11 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
438
748
|
`work ${gate.id} is waiting for the person's answer; record it with work_answer before calling tools`,
|
|
439
749
|
);
|
|
440
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
|
+
}
|
|
441
756
|
const opened = await works.open(gate.id);
|
|
442
757
|
try {
|
|
443
758
|
const limit = config.limits.maxToolCalls;
|
|
@@ -556,3 +871,32 @@ function failure(text: string): CallToolResult {
|
|
|
556
871
|
function newCallId(): string {
|
|
557
872
|
return `call_${uuidv7()}`;
|
|
558
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
|
+
}
|