@openshain/mcp 0.1.1 → 0.3.1
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/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +473 -0
- package/dist/session.d.ts +14 -0
- package/dist/session.js +26 -0
- package/package.json +17 -6
- package/src/server.ts +231 -21
- package/src/session.ts +7 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { type RuntimeProviders } from "@openshain/core";
|
|
3
|
+
export interface McpServerOptions {
|
|
4
|
+
workspaceRoot: string;
|
|
5
|
+
/** Tool providers by the id used in openshain.yaml. */
|
|
6
|
+
tools: RuntimeProviders["tools"];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* An MCP server over one workspace. The agent on the other side thinks; the server keeps the
|
|
10
|
+
* work's state, runs the workspace's tools and records everything. Needs no model provider.
|
|
11
|
+
* Calls are handled one at a time per connection.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createMcpServer(options: McpServerOptions): Promise<Server>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
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 pkg from "../package.json" with { type: "json" };
|
|
5
|
+
import { Session } from "./session.js";
|
|
6
|
+
/** The tools every session has, before the workspace's own. Their names are reserved in the runtime. */
|
|
7
|
+
const WORK_TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
name: "work_create",
|
|
10
|
+
description: 'Start a work for a request from the person you work for, and make it the current work. Tool calls are recorded against the current work. Finish the current work with work_complete or work_fail before starting another. The type "session" records a conversation: no tool can run inside it, so start the actual work with parent set to the session\'s id.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
objective: {
|
|
15
|
+
type: "string",
|
|
16
|
+
minLength: 1,
|
|
17
|
+
maxLength: 10_000,
|
|
18
|
+
description: "The request, in the person's words.",
|
|
19
|
+
},
|
|
20
|
+
type: {
|
|
21
|
+
type: "string",
|
|
22
|
+
maxLength: 50,
|
|
23
|
+
description: "A short label for the kind of work. Defaults to request.",
|
|
24
|
+
},
|
|
25
|
+
parent: {
|
|
26
|
+
type: "string",
|
|
27
|
+
maxLength: 100,
|
|
28
|
+
description: "The id of the work this one was started from, such as the session.",
|
|
29
|
+
},
|
|
30
|
+
agent_name: {
|
|
31
|
+
type: "string",
|
|
32
|
+
maxLength: 100,
|
|
33
|
+
description: "The name the agent goes by in this work. A session picks one; the works under it carry the same.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: ["objective"],
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "work_select",
|
|
42
|
+
description: "Make an existing work the current one. A finished work cannot be selected.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: { id: { type: "string" } },
|
|
46
|
+
required: ["id"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "work_get",
|
|
52
|
+
description: "The state of the current work, or of the work with the given id. With history, also the tool calls so far, the calls that never got a result, and the questions still waiting for an answer, so a stopped work can be picked up.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: { id: { type: "string" }, history: { type: "boolean" } },
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "work_list",
|
|
61
|
+
description: "Every work in this workspace, oldest first, with the ones that cannot be read.",
|
|
62
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "work_complete",
|
|
66
|
+
description: "Finish the current work. Say what was done; name the files you produced with their sha256 if you know it. Paths are relative to the workspace. The runtime checks every file and records its own hash.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
summary: { type: "string", maxLength: 20_000 },
|
|
71
|
+
artifacts: {
|
|
72
|
+
type: "array",
|
|
73
|
+
items: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
path: { type: "string", maxLength: 1000 },
|
|
77
|
+
sha256: { type: "string", maxLength: 64 },
|
|
78
|
+
},
|
|
79
|
+
required: ["path"],
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required: ["summary"],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "work_fail",
|
|
90
|
+
description: "Give up on the current work. Say why in a short reason and, if useful, a detail.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
reason: { type: "string", maxLength: 200 },
|
|
95
|
+
detail: { type: "string", maxLength: 20_000 },
|
|
96
|
+
},
|
|
97
|
+
required: ["reason"],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: ASK_USER.name,
|
|
103
|
+
description: ASK_USER.description,
|
|
104
|
+
inputSchema: ASK_USER.inputSchema,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "work_answer",
|
|
108
|
+
description: "Record the person's answer to a question the current work is waiting on, and let the work continue.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
call_id: { type: "string", maxLength: 100 },
|
|
113
|
+
answer: { type: "string", maxLength: 20_000 },
|
|
114
|
+
},
|
|
115
|
+
required: ["call_id", "answer"],
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "work_record",
|
|
121
|
+
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.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
work_id: { type: "string" },
|
|
126
|
+
type: {
|
|
127
|
+
type: "string",
|
|
128
|
+
enum: [
|
|
129
|
+
"human.message",
|
|
130
|
+
"prompt.expanded",
|
|
131
|
+
"model.requested",
|
|
132
|
+
"model.completed",
|
|
133
|
+
"model.failed",
|
|
134
|
+
"usage.recorded",
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
payload: { type: "object" },
|
|
138
|
+
},
|
|
139
|
+
required: ["work_id", "type", "payload"],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
/** A client event larger than this is refused: the log is for records, not for payloads. */
|
|
145
|
+
const MAX_RECORD_CHARS = 262_144;
|
|
146
|
+
/** The event types a client may record itself. Everything else is the runtime's to write. */
|
|
147
|
+
const RECORDABLE_TYPES = new Set([
|
|
148
|
+
"human.message",
|
|
149
|
+
"prompt.expanded",
|
|
150
|
+
"model.requested",
|
|
151
|
+
"model.completed",
|
|
152
|
+
"model.failed",
|
|
153
|
+
"usage.recorded",
|
|
154
|
+
]);
|
|
155
|
+
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";
|
|
156
|
+
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";
|
|
157
|
+
const validators = new Map(WORK_TOOLS.map((tool) => [tool.name, compileInputValidator(tool.inputSchema)]));
|
|
158
|
+
/**
|
|
159
|
+
* An MCP server over one workspace. The agent on the other side thinks; the server keeps the
|
|
160
|
+
* work's state, runs the workspace's tools and records everything. Needs no model provider.
|
|
161
|
+
* Calls are handled one at a time per connection.
|
|
162
|
+
*/
|
|
163
|
+
export async function createMcpServer(options) {
|
|
164
|
+
const { workspaceRoot } = options;
|
|
165
|
+
const config = await loadConfig(workspaceRoot);
|
|
166
|
+
const registry = await createToolRegistry(workspaceRoot, config, options.tools);
|
|
167
|
+
const callTool = createToolCaller({ registry, config, workspaceRoot });
|
|
168
|
+
const works = new WorkStore(workspaceRoot);
|
|
169
|
+
const session = new Session();
|
|
170
|
+
const server = new Server({ name: "openshain", version: pkg.version }, { capabilities: { tools: {} } });
|
|
171
|
+
/** The current work when it can still take events; otherwise the reason it cannot. */
|
|
172
|
+
async function openWork() {
|
|
173
|
+
const id = session.current;
|
|
174
|
+
if (!id)
|
|
175
|
+
return { refused: failure(NO_WORK) };
|
|
176
|
+
const work = await works.get(id);
|
|
177
|
+
if (isTerminal(work.status)) {
|
|
178
|
+
session.clear();
|
|
179
|
+
return { refused: failure(`work ${id} is already ${work.status}; ${NO_WORK}`) };
|
|
180
|
+
}
|
|
181
|
+
return { id };
|
|
182
|
+
}
|
|
183
|
+
async function handle(name, input) {
|
|
184
|
+
const validate = validators.get(name);
|
|
185
|
+
if (validate) {
|
|
186
|
+
const checked = validate(input);
|
|
187
|
+
if (!checked.ok) {
|
|
188
|
+
return failure(`schema_mismatch: input does not match the schema of ${name}: ${checked.reason}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
switch (name) {
|
|
192
|
+
case "work_create": {
|
|
193
|
+
const current = session.current;
|
|
194
|
+
if (current) {
|
|
195
|
+
const open = await works.get(current);
|
|
196
|
+
// A session is a conversation: starting a work under it is the normal thing to do.
|
|
197
|
+
if (!isTerminal(open.status) && open.type !== SESSION_WORK_TYPE) {
|
|
198
|
+
return failure(`work ${current} is still in progress; finish it with work_complete or work_fail before starting another`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const { objective, type, parent, agent_name: agentName, } = input;
|
|
202
|
+
if (parent !== undefined)
|
|
203
|
+
await works.get(parseWorkId(parent));
|
|
204
|
+
if (type === SESSION_WORK_TYPE && parent !== undefined) {
|
|
205
|
+
return failure("a session is a conversation of its own and cannot have a parent");
|
|
206
|
+
}
|
|
207
|
+
const work = await works.create({
|
|
208
|
+
objective,
|
|
209
|
+
principal: config.principal.id,
|
|
210
|
+
profession: config.profession.id,
|
|
211
|
+
...(type && { type }),
|
|
212
|
+
...(parent !== undefined && { parent }),
|
|
213
|
+
...(agentName !== undefined && { agentName }),
|
|
214
|
+
});
|
|
215
|
+
await works.transition(work.id, "in_progress", "an agent took the work over MCP");
|
|
216
|
+
session.select(work.id);
|
|
217
|
+
return json(await works.get(work.id));
|
|
218
|
+
}
|
|
219
|
+
case "work_select": {
|
|
220
|
+
const id = parseWorkId(input.id);
|
|
221
|
+
const work = await works.get(id);
|
|
222
|
+
if (isTerminal(work.status))
|
|
223
|
+
return failure(`work ${id} is already ${work.status}`);
|
|
224
|
+
session.select(id);
|
|
225
|
+
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
226
|
+
}
|
|
227
|
+
case "work_get": {
|
|
228
|
+
const { id: given, history } = input;
|
|
229
|
+
const id = given ? parseWorkId(given) : session.current;
|
|
230
|
+
if (!id)
|
|
231
|
+
return failure(NO_WORK);
|
|
232
|
+
const work = await works.get(id);
|
|
233
|
+
if (!history)
|
|
234
|
+
return json(work);
|
|
235
|
+
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
236
|
+
}
|
|
237
|
+
case ASK_USER.name: {
|
|
238
|
+
const gate = await openWork();
|
|
239
|
+
if ("refused" in gate)
|
|
240
|
+
return gate.refused;
|
|
241
|
+
const work = await works.get(gate.id);
|
|
242
|
+
if (work.type === SESSION_WORK_TYPE)
|
|
243
|
+
return failure(SESSION_HAS_NO_TOOLS);
|
|
244
|
+
if (work.status === "waiting_input") {
|
|
245
|
+
return failure(`work ${gate.id} is already waiting for an answer; record it with work_answer before asking again`);
|
|
246
|
+
}
|
|
247
|
+
const { question } = input;
|
|
248
|
+
const callId = newCallId();
|
|
249
|
+
const opened = await works.open(gate.id);
|
|
250
|
+
try {
|
|
251
|
+
await opened.append({
|
|
252
|
+
type: "tool.called",
|
|
253
|
+
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
|
|
254
|
+
});
|
|
255
|
+
await opened.append({ type: "human.input_requested", payload: { callId, question } });
|
|
256
|
+
await opened.transition("waiting_input", "the agent asked the person a question");
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await opened.close();
|
|
260
|
+
}
|
|
261
|
+
return json({ pending: true, call_id: callId, question });
|
|
262
|
+
}
|
|
263
|
+
case "work_answer": {
|
|
264
|
+
const gate = await openWork();
|
|
265
|
+
if ("refused" in gate)
|
|
266
|
+
return gate.refused;
|
|
267
|
+
const { call_id: callId, answer } = input;
|
|
268
|
+
const work = await works.get(gate.id);
|
|
269
|
+
if (work.status !== "waiting_input") {
|
|
270
|
+
return failure(`work ${gate.id} is ${work.status}, not waiting for an answer`);
|
|
271
|
+
}
|
|
272
|
+
const pending = pendingQuestions(await works.events(gate.id));
|
|
273
|
+
if (!pending.some((q) => q.callId === callId)) {
|
|
274
|
+
return failure(`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`);
|
|
275
|
+
}
|
|
276
|
+
const opened = await works.open(gate.id);
|
|
277
|
+
try {
|
|
278
|
+
await opened.append({ type: "human.input_provided", payload: { callId, answer } });
|
|
279
|
+
await opened.append({
|
|
280
|
+
type: "tool.completed",
|
|
281
|
+
payload: { callId, content: [{ type: "text", text: answer }], isError: false },
|
|
282
|
+
});
|
|
283
|
+
await opened.transition("in_progress", "the person answered");
|
|
284
|
+
return json(await opened.current());
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await opened.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
case "work_record": {
|
|
291
|
+
const { work_id, type, payload } = input;
|
|
292
|
+
const id = parseWorkId(work_id);
|
|
293
|
+
if (!RECORDABLE_TYPES.has(type) || !isKnownEventType(type)) {
|
|
294
|
+
return failure(`type ${type} cannot be recorded by a client`);
|
|
295
|
+
}
|
|
296
|
+
if (JSON.stringify(payload).length > MAX_RECORD_CHARS) {
|
|
297
|
+
return failure(`payload is larger than ${MAX_RECORD_CHARS} characters`);
|
|
298
|
+
}
|
|
299
|
+
if (!session.knows(id)) {
|
|
300
|
+
return failure(`work ${id} was not created or selected on this connection; a client records only on its own works`);
|
|
301
|
+
}
|
|
302
|
+
const parsed = parsePayloadFile(type, payload);
|
|
303
|
+
if (type === "usage.recorded" && parsed.kind !== "model_inference") {
|
|
304
|
+
return failure("usage.recorded from a client must have kind model_inference");
|
|
305
|
+
}
|
|
306
|
+
const opened = await works.open(id);
|
|
307
|
+
try {
|
|
308
|
+
const status = (await opened.current()).status;
|
|
309
|
+
if (isTerminal(status))
|
|
310
|
+
return failure(`work ${id} is already ${status}`);
|
|
311
|
+
const event = await opened.append({ type, payload: parsed });
|
|
312
|
+
return json({ id: event.id, seq: event.seq });
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
await opened.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
case "work_list": {
|
|
319
|
+
const { works: all, problems } = await works.list();
|
|
320
|
+
return json({
|
|
321
|
+
works: all.map((w) => ({
|
|
322
|
+
id: w.id,
|
|
323
|
+
status: w.status,
|
|
324
|
+
type: w.type,
|
|
325
|
+
objective: w.objective,
|
|
326
|
+
createdAt: w.createdAt,
|
|
327
|
+
...(w.parent !== undefined && { parent: w.parent }),
|
|
328
|
+
...(w.agentName !== undefined && { agentName: w.agentName }),
|
|
329
|
+
})),
|
|
330
|
+
problems: problems.map((p) => ({
|
|
331
|
+
id: p.id,
|
|
332
|
+
code: p.error.code,
|
|
333
|
+
message: p.error.message,
|
|
334
|
+
})),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
case "work_complete": {
|
|
338
|
+
const gate = await openWork();
|
|
339
|
+
if ("refused" in gate)
|
|
340
|
+
return gate.refused;
|
|
341
|
+
const { summary, artifacts } = input;
|
|
342
|
+
const work = await complete(works, workspaceRoot, gate.id, summary, artifacts ?? []);
|
|
343
|
+
session.clear();
|
|
344
|
+
return json(work);
|
|
345
|
+
}
|
|
346
|
+
case "work_fail": {
|
|
347
|
+
const gate = await openWork();
|
|
348
|
+
if ("refused" in gate)
|
|
349
|
+
return gate.refused;
|
|
350
|
+
const { reason, detail } = input;
|
|
351
|
+
const opened = await works.open(gate.id);
|
|
352
|
+
try {
|
|
353
|
+
await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
await opened.close();
|
|
357
|
+
}
|
|
358
|
+
session.clear();
|
|
359
|
+
return json(await works.get(gate.id));
|
|
360
|
+
}
|
|
361
|
+
default: {
|
|
362
|
+
const gate = await openWork();
|
|
363
|
+
if ("refused" in gate)
|
|
364
|
+
return gate.refused;
|
|
365
|
+
const work = await works.get(gate.id);
|
|
366
|
+
if (work.type === SESSION_WORK_TYPE)
|
|
367
|
+
return failure(SESSION_HAS_NO_TOOLS);
|
|
368
|
+
if (work.status === "waiting_input") {
|
|
369
|
+
return failure(`work ${gate.id} is waiting for the person's answer; record it with work_answer before calling tools`);
|
|
370
|
+
}
|
|
371
|
+
const opened = await works.open(gate.id);
|
|
372
|
+
try {
|
|
373
|
+
const limit = config.limits.maxToolCalls;
|
|
374
|
+
if (countToolCalls(await opened.events()) >= limit) {
|
|
375
|
+
const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
|
|
376
|
+
await opened.append({
|
|
377
|
+
type: "tool.rejected",
|
|
378
|
+
payload: { callId: newCallId(), name, code: "limit_reached", reason },
|
|
379
|
+
});
|
|
380
|
+
return failure(`limit_reached: ${reason}`);
|
|
381
|
+
}
|
|
382
|
+
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
383
|
+
return toMcpResult(result);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await opened.close();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
392
|
+
tools: [...WORK_TOOLS, ...registry.list().map((t) => toMcpTool(t.definition))],
|
|
393
|
+
}));
|
|
394
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => session.run(async () => {
|
|
395
|
+
try {
|
|
396
|
+
return await handle(request.params.name, request.params.arguments ?? {});
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
if (isOpenshainError(err))
|
|
400
|
+
return failure(`${err.code}: ${err.message}`);
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
}));
|
|
404
|
+
return server;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Records the evidence and the completion. Artifacts named by the agent join the ones the tools
|
|
408
|
+
* wrote; every path must be inside the workspace, and the runtime hashes them all. A path no tool
|
|
409
|
+
* of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
|
|
410
|
+
*/
|
|
411
|
+
async function complete(works, workspaceRoot, id, summary, claimed) {
|
|
412
|
+
for (const { path } of claimed)
|
|
413
|
+
await resolveWorkspacePath(workspaceRoot, path);
|
|
414
|
+
const opened = await works.open(id);
|
|
415
|
+
try {
|
|
416
|
+
const events = await opened.events();
|
|
417
|
+
const refs = [];
|
|
418
|
+
const byPath = new Map();
|
|
419
|
+
for (const event of writesWithAfter(events)) {
|
|
420
|
+
refs.push(event.id);
|
|
421
|
+
for (const { path, sha256 } of event.payload.after ?? [])
|
|
422
|
+
byPath.set(path, sha256);
|
|
423
|
+
}
|
|
424
|
+
const written = new Set(byPath.keys());
|
|
425
|
+
for (const { path, sha256 } of claimed)
|
|
426
|
+
if (!byPath.has(path))
|
|
427
|
+
byPath.set(path, sha256 ?? "");
|
|
428
|
+
const artifacts = [];
|
|
429
|
+
for (const [path, reported] of byPath) {
|
|
430
|
+
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
431
|
+
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
432
|
+
}
|
|
433
|
+
await opened.append({
|
|
434
|
+
type: "evidence.recorded",
|
|
435
|
+
payload: { claim: summary, refs, artifacts },
|
|
436
|
+
});
|
|
437
|
+
await opened.append({ type: "work.completed", payload: { summary } });
|
|
438
|
+
return opened.current();
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
await opened.close();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function writesWithAfter(events) {
|
|
445
|
+
return events.filter((e) => e.type === "tool.completed" &&
|
|
446
|
+
!e.payload.isError &&
|
|
447
|
+
e.payload.after !== undefined);
|
|
448
|
+
}
|
|
449
|
+
function toMcpTool(definition) {
|
|
450
|
+
return {
|
|
451
|
+
name: definition.name,
|
|
452
|
+
description: definition.description,
|
|
453
|
+
inputSchema: definition.inputSchema,
|
|
454
|
+
annotations: { readOnlyHint: definition.effect === "observe" },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function toMcpResult(result) {
|
|
458
|
+
return {
|
|
459
|
+
content: result.content.map((part) => part.type === "text"
|
|
460
|
+
? { type: "text", text: part.text }
|
|
461
|
+
: { type: "text", text: JSON.stringify(part.value) }),
|
|
462
|
+
...(result.isError && { isError: true }),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function json(value) {
|
|
466
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
467
|
+
}
|
|
468
|
+
function failure(text) {
|
|
469
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
470
|
+
}
|
|
471
|
+
function newCallId() {
|
|
472
|
+
return `call_${uuidv7()}`;
|
|
473
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { WorkId } from "@openshain/core";
|
|
2
|
+
/** What one connection remembers: the work the agent is on, and the order of its calls. */
|
|
3
|
+
export declare class Session {
|
|
4
|
+
private currentId;
|
|
5
|
+
private readonly known;
|
|
6
|
+
private queue;
|
|
7
|
+
get current(): WorkId | undefined;
|
|
8
|
+
select(id: WorkId): void;
|
|
9
|
+
/** Whether this connection created or selected the work, so it may record its own events on it. */
|
|
10
|
+
knows(id: WorkId): boolean;
|
|
11
|
+
clear(): void;
|
|
12
|
+
/** Runs one call after the previous one finished, so calls the agent makes in parallel do not fight over the work's lock. */
|
|
13
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
14
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** What one connection remembers: the work the agent is on, and the order of its calls. */
|
|
2
|
+
export class Session {
|
|
3
|
+
currentId;
|
|
4
|
+
known = new Set();
|
|
5
|
+
queue = Promise.resolve();
|
|
6
|
+
get current() {
|
|
7
|
+
return this.currentId;
|
|
8
|
+
}
|
|
9
|
+
select(id) {
|
|
10
|
+
this.currentId = id;
|
|
11
|
+
this.known.add(id);
|
|
12
|
+
}
|
|
13
|
+
/** Whether this connection created or selected the work, so it may record its own events on it. */
|
|
14
|
+
knows(id) {
|
|
15
|
+
return this.known.has(id);
|
|
16
|
+
}
|
|
17
|
+
clear() {
|
|
18
|
+
this.currentId = undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Runs one call after the previous one finished, so calls the agent makes in parallel do not fight over the work's lock. */
|
|
21
|
+
run(fn) {
|
|
22
|
+
const next = this.queue.then(fn, fn);
|
|
23
|
+
this.queue = next.catch(() => undefined);
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openshain/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server that exposes an openshain workspace to any agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -21,25 +21,36 @@
|
|
|
21
21
|
"bugs": "https://github.com/openshain/openshain/issues",
|
|
22
22
|
"type": "module",
|
|
23
23
|
"engines": {
|
|
24
|
+
"node": ">=22",
|
|
24
25
|
"bun": ">=1.3"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
28
|
+
"dist",
|
|
27
29
|
"src",
|
|
28
30
|
"!src/**/*.test.ts",
|
|
31
|
+
"!src/**/*.test.tsx",
|
|
29
32
|
"README.md",
|
|
30
33
|
"LICENSE"
|
|
31
34
|
],
|
|
32
35
|
"exports": {
|
|
33
|
-
".":
|
|
36
|
+
".": {
|
|
37
|
+
"bun": "./src/index.ts",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
}
|
|
34
41
|
},
|
|
35
|
-
"
|
|
36
|
-
"
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
|
|
44
|
+
"prepublishOnly": "rm -rf dist && ../../node_modules/.bin/tsc -p tsconfig.build.json"
|
|
37
45
|
},
|
|
38
46
|
"dependencies": {
|
|
39
47
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
40
|
-
"@openshain/core": "0.1
|
|
48
|
+
"@openshain/core": "0.3.1"
|
|
41
49
|
},
|
|
42
50
|
"devDependencies": {
|
|
43
|
-
"@openshain/tools": "0.1
|
|
51
|
+
"@openshain/tools": "0.3.1"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
44
55
|
}
|
|
45
56
|
}
|
package/src/server.ts
CHANGED
|
@@ -8,25 +8,35 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
type AnyEvent,
|
|
10
10
|
type Artifact,
|
|
11
|
+
ASK_USER,
|
|
11
12
|
compileInputValidator,
|
|
13
|
+
countToolCalls,
|
|
12
14
|
createToolCaller,
|
|
13
15
|
createToolRegistry,
|
|
14
16
|
type Event,
|
|
17
|
+
type EventType,
|
|
15
18
|
type InputValidation,
|
|
19
|
+
isKnownEventType,
|
|
16
20
|
isOpenshainError,
|
|
17
21
|
isTerminal,
|
|
18
22
|
loadConfig,
|
|
23
|
+
parsePayloadFile,
|
|
19
24
|
parseWorkId,
|
|
25
|
+
pendingQuestions,
|
|
26
|
+
RUNTIME_PROVIDER_ID,
|
|
20
27
|
type RuntimeProviders,
|
|
21
28
|
resolveWorkspacePath,
|
|
22
29
|
SESSION_WORK_TYPE,
|
|
23
30
|
type ToolDefinition,
|
|
24
31
|
type ToolResult,
|
|
32
|
+
uuidv7,
|
|
25
33
|
verifyArtifact,
|
|
26
34
|
type Work,
|
|
27
35
|
type WorkId,
|
|
28
36
|
WorkStore,
|
|
37
|
+
workHistory,
|
|
29
38
|
} from "@openshain/core";
|
|
39
|
+
import pkg from "../package.json" with { type: "json" };
|
|
30
40
|
import { Session } from "./session.ts";
|
|
31
41
|
|
|
32
42
|
export interface McpServerOptions {
|
|
@@ -40,15 +50,32 @@ const WORK_TOOLS: Tool[] = [
|
|
|
40
50
|
{
|
|
41
51
|
name: "work_create",
|
|
42
52
|
description:
|
|
43
|
-
|
|
53
|
+
'Start a work for a request from the person you work for, and make it the current work. Tool calls are recorded against the current work. Finish the current work with work_complete or work_fail before starting another. The type "session" records a conversation: no tool can run inside it, so start the actual work with parent set to the session\'s id.',
|
|
44
54
|
inputSchema: {
|
|
45
55
|
type: "object",
|
|
46
56
|
properties: {
|
|
47
|
-
objective: {
|
|
57
|
+
objective: {
|
|
58
|
+
type: "string",
|
|
59
|
+
minLength: 1,
|
|
60
|
+
maxLength: 10_000,
|
|
61
|
+
description: "The request, in the person's words.",
|
|
62
|
+
},
|
|
48
63
|
type: {
|
|
49
64
|
type: "string",
|
|
65
|
+
maxLength: 50,
|
|
50
66
|
description: "A short label for the kind of work. Defaults to request.",
|
|
51
67
|
},
|
|
68
|
+
parent: {
|
|
69
|
+
type: "string",
|
|
70
|
+
maxLength: 100,
|
|
71
|
+
description: "The id of the work this one was started from, such as the session.",
|
|
72
|
+
},
|
|
73
|
+
agent_name: {
|
|
74
|
+
type: "string",
|
|
75
|
+
maxLength: 100,
|
|
76
|
+
description:
|
|
77
|
+
"The name the agent goes by in this work. A session picks one; the works under it carry the same.",
|
|
78
|
+
},
|
|
52
79
|
},
|
|
53
80
|
required: ["objective"],
|
|
54
81
|
additionalProperties: false,
|
|
@@ -66,10 +93,11 @@ const WORK_TOOLS: Tool[] = [
|
|
|
66
93
|
},
|
|
67
94
|
{
|
|
68
95
|
name: "work_get",
|
|
69
|
-
description:
|
|
96
|
+
description:
|
|
97
|
+
"The state of the current work, or of the work with the given id. With history, also the tool calls so far, the calls that never got a result, and the questions still waiting for an answer, so a stopped work can be picked up.",
|
|
70
98
|
inputSchema: {
|
|
71
99
|
type: "object",
|
|
72
|
-
properties: { id: { type: "string" } },
|
|
100
|
+
properties: { id: { type: "string" }, history: { type: "boolean" } },
|
|
73
101
|
additionalProperties: false,
|
|
74
102
|
},
|
|
75
103
|
},
|
|
@@ -85,12 +113,15 @@ const WORK_TOOLS: Tool[] = [
|
|
|
85
113
|
inputSchema: {
|
|
86
114
|
type: "object",
|
|
87
115
|
properties: {
|
|
88
|
-
summary: { type: "string" },
|
|
116
|
+
summary: { type: "string", maxLength: 20_000 },
|
|
89
117
|
artifacts: {
|
|
90
118
|
type: "array",
|
|
91
119
|
items: {
|
|
92
120
|
type: "object",
|
|
93
|
-
properties: {
|
|
121
|
+
properties: {
|
|
122
|
+
path: { type: "string", maxLength: 1000 },
|
|
123
|
+
sha256: { type: "string", maxLength: 64 },
|
|
124
|
+
},
|
|
94
125
|
required: ["path"],
|
|
95
126
|
additionalProperties: false,
|
|
96
127
|
},
|
|
@@ -105,13 +136,76 @@ const WORK_TOOLS: Tool[] = [
|
|
|
105
136
|
description: "Give up on the current work. Say why in a short reason and, if useful, a detail.",
|
|
106
137
|
inputSchema: {
|
|
107
138
|
type: "object",
|
|
108
|
-
properties: {
|
|
139
|
+
properties: {
|
|
140
|
+
reason: { type: "string", maxLength: 200 },
|
|
141
|
+
detail: { type: "string", maxLength: 20_000 },
|
|
142
|
+
},
|
|
109
143
|
required: ["reason"],
|
|
110
144
|
additionalProperties: false,
|
|
111
145
|
},
|
|
112
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: ASK_USER.name,
|
|
149
|
+
description: ASK_USER.description,
|
|
150
|
+
inputSchema: ASK_USER.inputSchema as Tool["inputSchema"],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "work_answer",
|
|
154
|
+
description:
|
|
155
|
+
"Record the person's answer to a question the current work is waiting on, and let the work continue.",
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: {
|
|
159
|
+
call_id: { type: "string", maxLength: 100 },
|
|
160
|
+
answer: { type: "string", maxLength: 20_000 },
|
|
161
|
+
},
|
|
162
|
+
required: ["call_id", "answer"],
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "work_record",
|
|
168
|
+
description:
|
|
169
|
+
"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.",
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
work_id: { type: "string" },
|
|
174
|
+
type: {
|
|
175
|
+
type: "string",
|
|
176
|
+
enum: [
|
|
177
|
+
"human.message",
|
|
178
|
+
"prompt.expanded",
|
|
179
|
+
"model.requested",
|
|
180
|
+
"model.completed",
|
|
181
|
+
"model.failed",
|
|
182
|
+
"usage.recorded",
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
payload: { type: "object" },
|
|
186
|
+
},
|
|
187
|
+
required: ["work_id", "type", "payload"],
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
113
191
|
];
|
|
114
192
|
|
|
193
|
+
/** A client event larger than this is refused: the log is for records, not for payloads. */
|
|
194
|
+
const MAX_RECORD_CHARS = 262_144;
|
|
195
|
+
|
|
196
|
+
/** The event types a client may record itself. Everything else is the runtime's to write. */
|
|
197
|
+
const RECORDABLE_TYPES: ReadonlySet<string> = new Set([
|
|
198
|
+
"human.message",
|
|
199
|
+
"prompt.expanded",
|
|
200
|
+
"model.requested",
|
|
201
|
+
"model.completed",
|
|
202
|
+
"model.failed",
|
|
203
|
+
"usage.recorded",
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
const SESSION_HAS_NO_TOOLS =
|
|
207
|
+
"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";
|
|
208
|
+
|
|
115
209
|
const NO_WORK =
|
|
116
210
|
"no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
|
|
117
211
|
|
|
@@ -132,7 +226,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
132
226
|
const works = new WorkStore(workspaceRoot);
|
|
133
227
|
const session = new Session();
|
|
134
228
|
const server = new Server(
|
|
135
|
-
{ name: "openshain", version:
|
|
229
|
+
{ name: "openshain", version: pkg.version },
|
|
136
230
|
{ capabilities: { tools: {} } },
|
|
137
231
|
);
|
|
138
232
|
|
|
@@ -161,22 +255,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
161
255
|
switch (name) {
|
|
162
256
|
case "work_create": {
|
|
163
257
|
const current = session.current;
|
|
164
|
-
if (current
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
258
|
+
if (current) {
|
|
259
|
+
const open = await works.get(current);
|
|
260
|
+
// A session is a conversation: starting a work under it is the normal thing to do.
|
|
261
|
+
if (!isTerminal(open.status) && open.type !== SESSION_WORK_TYPE) {
|
|
262
|
+
return failure(
|
|
263
|
+
`work ${current} is still in progress; finish it with work_complete or work_fail before starting another`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
168
266
|
}
|
|
169
|
-
const {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
267
|
+
const {
|
|
268
|
+
objective,
|
|
269
|
+
type,
|
|
270
|
+
parent,
|
|
271
|
+
agent_name: agentName,
|
|
272
|
+
} = input as { objective: string; type?: string; parent?: string; agent_name?: string };
|
|
273
|
+
if (parent !== undefined) await works.get(parseWorkId(parent));
|
|
274
|
+
if (type === SESSION_WORK_TYPE && parent !== undefined) {
|
|
275
|
+
return failure("a session is a conversation of its own and cannot have a parent");
|
|
174
276
|
}
|
|
175
277
|
const work = await works.create({
|
|
176
278
|
objective,
|
|
177
279
|
principal: config.principal.id,
|
|
178
280
|
profession: config.profession.id,
|
|
179
281
|
...(type && { type }),
|
|
282
|
+
...(parent !== undefined && { parent }),
|
|
283
|
+
...(agentName !== undefined && { agentName }),
|
|
180
284
|
});
|
|
181
285
|
await works.transition(work.id, "in_progress", "an agent took the work over MCP");
|
|
182
286
|
session.select(work.id);
|
|
@@ -187,13 +291,99 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
187
291
|
const work = await works.get(id);
|
|
188
292
|
if (isTerminal(work.status)) return failure(`work ${id} is already ${work.status}`);
|
|
189
293
|
session.select(id);
|
|
190
|
-
return json(work);
|
|
294
|
+
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
191
295
|
}
|
|
192
296
|
case "work_get": {
|
|
193
|
-
const given =
|
|
297
|
+
const { id: given, history } = input as { id?: string; history?: boolean };
|
|
194
298
|
const id = given ? parseWorkId(given) : session.current;
|
|
195
299
|
if (!id) return failure(NO_WORK);
|
|
196
|
-
|
|
300
|
+
const work = await works.get(id);
|
|
301
|
+
if (!history) return json(work);
|
|
302
|
+
return json({ ...work, history: workHistory(await works.events(id)) });
|
|
303
|
+
}
|
|
304
|
+
case ASK_USER.name: {
|
|
305
|
+
const gate = await openWork();
|
|
306
|
+
if ("refused" in gate) return gate.refused;
|
|
307
|
+
const work = await works.get(gate.id);
|
|
308
|
+
if (work.type === SESSION_WORK_TYPE) return failure(SESSION_HAS_NO_TOOLS);
|
|
309
|
+
if (work.status === "waiting_input") {
|
|
310
|
+
return failure(
|
|
311
|
+
`work ${gate.id} is already waiting for an answer; record it with work_answer before asking again`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const { question } = input as { question: string };
|
|
315
|
+
const callId = newCallId();
|
|
316
|
+
const opened = await works.open(gate.id);
|
|
317
|
+
try {
|
|
318
|
+
await opened.append({
|
|
319
|
+
type: "tool.called",
|
|
320
|
+
payload: { callId, provider: RUNTIME_PROVIDER_ID, name: ASK_USER.name, input },
|
|
321
|
+
});
|
|
322
|
+
await opened.append({ type: "human.input_requested", payload: { callId, question } });
|
|
323
|
+
await opened.transition("waiting_input", "the agent asked the person a question");
|
|
324
|
+
} finally {
|
|
325
|
+
await opened.close();
|
|
326
|
+
}
|
|
327
|
+
return json({ pending: true, call_id: callId, question });
|
|
328
|
+
}
|
|
329
|
+
case "work_answer": {
|
|
330
|
+
const gate = await openWork();
|
|
331
|
+
if ("refused" in gate) return gate.refused;
|
|
332
|
+
const { call_id: callId, answer } = input as { call_id: string; answer: string };
|
|
333
|
+
const work = await works.get(gate.id);
|
|
334
|
+
if (work.status !== "waiting_input") {
|
|
335
|
+
return failure(`work ${gate.id} is ${work.status}, not waiting for an answer`);
|
|
336
|
+
}
|
|
337
|
+
const pending = pendingQuestions(await works.events(gate.id));
|
|
338
|
+
if (!pending.some((q) => q.callId === callId)) {
|
|
339
|
+
return failure(
|
|
340
|
+
`no unanswered question with call_id ${callId}; pending: ${pending.map((q) => q.callId).join(", ") || "none"}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const opened = await works.open(gate.id);
|
|
344
|
+
try {
|
|
345
|
+
await opened.append({ type: "human.input_provided", payload: { callId, answer } });
|
|
346
|
+
await opened.append({
|
|
347
|
+
type: "tool.completed",
|
|
348
|
+
payload: { callId, content: [{ type: "text", text: answer }], isError: false },
|
|
349
|
+
});
|
|
350
|
+
await opened.transition("in_progress", "the person answered");
|
|
351
|
+
return json(await opened.current());
|
|
352
|
+
} finally {
|
|
353
|
+
await opened.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
case "work_record": {
|
|
357
|
+
const { work_id, type, payload } = input as {
|
|
358
|
+
work_id: string;
|
|
359
|
+
type: string;
|
|
360
|
+
payload: unknown;
|
|
361
|
+
};
|
|
362
|
+
const id = parseWorkId(work_id);
|
|
363
|
+
if (!RECORDABLE_TYPES.has(type) || !isKnownEventType(type)) {
|
|
364
|
+
return failure(`type ${type} cannot be recorded by a client`);
|
|
365
|
+
}
|
|
366
|
+
if (JSON.stringify(payload).length > MAX_RECORD_CHARS) {
|
|
367
|
+
return failure(`payload is larger than ${MAX_RECORD_CHARS} characters`);
|
|
368
|
+
}
|
|
369
|
+
if (!session.knows(id)) {
|
|
370
|
+
return failure(
|
|
371
|
+
`work ${id} was not created or selected on this connection; a client records only on its own works`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const parsed = parsePayloadFile(type as EventType, payload);
|
|
375
|
+
if (type === "usage.recorded" && (parsed as { kind: string }).kind !== "model_inference") {
|
|
376
|
+
return failure("usage.recorded from a client must have kind model_inference");
|
|
377
|
+
}
|
|
378
|
+
const opened = await works.open(id);
|
|
379
|
+
try {
|
|
380
|
+
const status = (await opened.current()).status;
|
|
381
|
+
if (isTerminal(status)) return failure(`work ${id} is already ${status}`);
|
|
382
|
+
const event = await opened.append({ type, payload: parsed } as never);
|
|
383
|
+
return json({ id: event.id, seq: event.seq });
|
|
384
|
+
} finally {
|
|
385
|
+
await opened.close();
|
|
386
|
+
}
|
|
197
387
|
}
|
|
198
388
|
case "work_list": {
|
|
199
389
|
const { works: all, problems } = await works.list();
|
|
@@ -201,8 +391,11 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
201
391
|
works: all.map((w) => ({
|
|
202
392
|
id: w.id,
|
|
203
393
|
status: w.status,
|
|
394
|
+
type: w.type,
|
|
204
395
|
objective: w.objective,
|
|
205
396
|
createdAt: w.createdAt,
|
|
397
|
+
...(w.parent !== undefined && { parent: w.parent }),
|
|
398
|
+
...(w.agentName !== undefined && { agentName: w.agentName }),
|
|
206
399
|
})),
|
|
207
400
|
problems: problems.map((p) => ({
|
|
208
401
|
id: p.id,
|
|
@@ -238,8 +431,24 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
238
431
|
default: {
|
|
239
432
|
const gate = await openWork();
|
|
240
433
|
if ("refused" in gate) return gate.refused;
|
|
434
|
+
const work = await works.get(gate.id);
|
|
435
|
+
if (work.type === SESSION_WORK_TYPE) return failure(SESSION_HAS_NO_TOOLS);
|
|
436
|
+
if (work.status === "waiting_input") {
|
|
437
|
+
return failure(
|
|
438
|
+
`work ${gate.id} is waiting for the person's answer; record it with work_answer before calling tools`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
241
441
|
const opened = await works.open(gate.id);
|
|
242
442
|
try {
|
|
443
|
+
const limit = config.limits.maxToolCalls;
|
|
444
|
+
if (countToolCalls(await opened.events()) >= limit) {
|
|
445
|
+
const reason = `this work has reached its limit of ${limit} tool calls; finish it with work_complete or work_fail`;
|
|
446
|
+
await opened.append({
|
|
447
|
+
type: "tool.rejected",
|
|
448
|
+
payload: { callId: newCallId(), name, code: "limit_reached", reason },
|
|
449
|
+
});
|
|
450
|
+
return failure(`limit_reached: ${reason}`);
|
|
451
|
+
}
|
|
243
452
|
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
244
453
|
return toMcpResult(result);
|
|
245
454
|
} finally {
|
|
@@ -321,6 +530,7 @@ function toMcpTool(definition: ToolDefinition): Tool {
|
|
|
321
530
|
name: definition.name,
|
|
322
531
|
description: definition.description,
|
|
323
532
|
inputSchema: definition.inputSchema as Tool["inputSchema"],
|
|
533
|
+
annotations: { readOnlyHint: definition.effect === "observe" },
|
|
324
534
|
};
|
|
325
535
|
}
|
|
326
536
|
|
|
@@ -344,5 +554,5 @@ function failure(text: string): CallToolResult {
|
|
|
344
554
|
}
|
|
345
555
|
|
|
346
556
|
function newCallId(): string {
|
|
347
|
-
return `call_${
|
|
557
|
+
return `call_${uuidv7()}`;
|
|
348
558
|
}
|
package/src/session.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { WorkId } from "@openshain/core";
|
|
|
3
3
|
/** What one connection remembers: the work the agent is on, and the order of its calls. */
|
|
4
4
|
export class Session {
|
|
5
5
|
private currentId: WorkId | undefined;
|
|
6
|
+
private readonly known = new Set<WorkId>();
|
|
6
7
|
private queue: Promise<unknown> = Promise.resolve();
|
|
7
8
|
|
|
8
9
|
get current(): WorkId | undefined {
|
|
@@ -11,6 +12,12 @@ export class Session {
|
|
|
11
12
|
|
|
12
13
|
select(id: WorkId): void {
|
|
13
14
|
this.currentId = id;
|
|
15
|
+
this.known.add(id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether this connection created or selected the work, so it may record its own events on it. */
|
|
19
|
+
knows(id: WorkId): boolean {
|
|
20
|
+
return this.known.has(id);
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
clear(): void {
|