@openshain/mcp 0.2.0 → 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/server.js CHANGED
@@ -1,21 +1,37 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
- import { compileInputValidator, createToolCaller, createToolRegistry, isOpenshainError, isTerminal, loadConfig, parseWorkId, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, } from "@openshain/core";
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
4
  import pkg from "../package.json" with { type: "json" };
5
5
  import { Session } from "./session.js";
6
6
  /** The tools every session has, before the workspace's own. Their names are reserved in the runtime. */
7
7
  const WORK_TOOLS = [
8
8
  {
9
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.",
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
11
  inputSchema: {
12
12
  type: "object",
13
13
  properties: {
14
- objective: { type: "string", description: "The request, in the person's words." },
14
+ objective: {
15
+ type: "string",
16
+ minLength: 1,
17
+ maxLength: 10_000,
18
+ description: "The request, in the person's words.",
19
+ },
15
20
  type: {
16
21
  type: "string",
22
+ maxLength: 50,
17
23
  description: "A short label for the kind of work. Defaults to request.",
18
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
+ },
19
35
  },
20
36
  required: ["objective"],
21
37
  additionalProperties: false,
@@ -33,10 +49,10 @@ const WORK_TOOLS = [
33
49
  },
34
50
  {
35
51
  name: "work_get",
36
- description: "The state of the current work, or of the work with the given id.",
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.",
37
53
  inputSchema: {
38
54
  type: "object",
39
- properties: { id: { type: "string" } },
55
+ properties: { id: { type: "string" }, history: { type: "boolean" } },
40
56
  additionalProperties: false,
41
57
  },
42
58
  },
@@ -51,12 +67,15 @@ const WORK_TOOLS = [
51
67
  inputSchema: {
52
68
  type: "object",
53
69
  properties: {
54
- summary: { type: "string" },
70
+ summary: { type: "string", maxLength: 20_000 },
55
71
  artifacts: {
56
72
  type: "array",
57
73
  items: {
58
74
  type: "object",
59
- properties: { path: { type: "string" }, sha256: { type: "string" } },
75
+ properties: {
76
+ path: { type: "string", maxLength: 1000 },
77
+ sha256: { type: "string", maxLength: 64 },
78
+ },
60
79
  required: ["path"],
61
80
  additionalProperties: false,
62
81
  },
@@ -71,12 +90,69 @@ const WORK_TOOLS = [
71
90
  description: "Give up on the current work. Say why in a short reason and, if useful, a detail.",
72
91
  inputSchema: {
73
92
  type: "object",
74
- properties: { reason: { type: "string" }, detail: { type: "string" } },
93
+ properties: {
94
+ reason: { type: "string", maxLength: 200 },
95
+ detail: { type: "string", maxLength: 20_000 },
96
+ },
75
97
  required: ["reason"],
76
98
  additionalProperties: false,
77
99
  },
78
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
+ },
79
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";
80
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";
81
157
  const validators = new Map(WORK_TOOLS.map((tool) => [tool.name, compileInputValidator(tool.inputSchema)]));
82
158
  /**
@@ -115,18 +191,26 @@ export async function createMcpServer(options) {
115
191
  switch (name) {
116
192
  case "work_create": {
117
193
  const current = session.current;
118
- if (current && !isTerminal((await works.get(current)).status)) {
119
- return failure(`work ${current} is still in progress; finish it with work_complete or work_fail before starting another`);
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
+ }
120
200
  }
121
- const { objective, type } = input;
122
- if (type === SESSION_WORK_TYPE) {
123
- return failure(`type "${SESSION_WORK_TYPE}" is reserved for conversations; use another label, such as request`);
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");
124
206
  }
125
207
  const work = await works.create({
126
208
  objective,
127
209
  principal: config.principal.id,
128
210
  profession: config.profession.id,
129
211
  ...(type && { type }),
212
+ ...(parent !== undefined && { parent }),
213
+ ...(agentName !== undefined && { agentName }),
130
214
  });
131
215
  await works.transition(work.id, "in_progress", "an agent took the work over MCP");
132
216
  session.select(work.id);
@@ -138,14 +222,98 @@ export async function createMcpServer(options) {
138
222
  if (isTerminal(work.status))
139
223
  return failure(`work ${id} is already ${work.status}`);
140
224
  session.select(id);
141
- return json(work);
225
+ return json({ ...work, history: workHistory(await works.events(id)) });
142
226
  }
143
227
  case "work_get": {
144
- const given = input.id;
228
+ const { id: given, history } = input;
145
229
  const id = given ? parseWorkId(given) : session.current;
146
230
  if (!id)
147
231
  return failure(NO_WORK);
148
- return json(await works.get(id));
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
+ }
149
317
  }
150
318
  case "work_list": {
151
319
  const { works: all, problems } = await works.list();
@@ -153,8 +321,11 @@ export async function createMcpServer(options) {
153
321
  works: all.map((w) => ({
154
322
  id: w.id,
155
323
  status: w.status,
324
+ type: w.type,
156
325
  objective: w.objective,
157
326
  createdAt: w.createdAt,
327
+ ...(w.parent !== undefined && { parent: w.parent }),
328
+ ...(w.agentName !== undefined && { agentName: w.agentName }),
158
329
  })),
159
330
  problems: problems.map((p) => ({
160
331
  id: p.id,
@@ -191,8 +362,23 @@ export async function createMcpServer(options) {
191
362
  const gate = await openWork();
192
363
  if ("refused" in gate)
193
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
+ }
194
371
  const opened = await works.open(gate.id);
195
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
+ }
196
382
  const result = await callTool(opened, { id: newCallId(), name, input });
197
383
  return toMcpResult(result);
198
384
  }
@@ -265,6 +451,7 @@ function toMcpTool(definition) {
265
451
  name: definition.name,
266
452
  description: definition.description,
267
453
  inputSchema: definition.inputSchema,
454
+ annotations: { readOnlyHint: definition.effect === "observe" },
268
455
  };
269
456
  }
270
457
  function toMcpResult(result) {
package/dist/session.d.ts CHANGED
@@ -2,9 +2,12 @@ import type { WorkId } from "@openshain/core";
2
2
  /** What one connection remembers: the work the agent is on, and the order of its calls. */
3
3
  export declare class Session {
4
4
  private currentId;
5
+ private readonly known;
5
6
  private queue;
6
7
  get current(): WorkId | undefined;
7
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;
8
11
  clear(): void;
9
12
  /** Runs one call after the previous one finished, so calls the agent makes in parallel do not fight over the work's lock. */
10
13
  run<T>(fn: () => Promise<T>): Promise<T>;
package/dist/session.js CHANGED
@@ -1,12 +1,18 @@
1
1
  /** What one connection remembers: the work the agent is on, and the order of its calls. */
2
2
  export class Session {
3
3
  currentId;
4
+ known = new Set();
4
5
  queue = Promise.resolve();
5
6
  get current() {
6
7
  return this.currentId;
7
8
  }
8
9
  select(id) {
9
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);
10
16
  }
11
17
  clear() {
12
18
  this.currentId = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openshain/mcp",
3
- "version": "0.2.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",
@@ -45,10 +45,10 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "1.30.0",
48
- "@openshain/core": "0.2.0"
48
+ "@openshain/core": "0.3.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@openshain/tools": "0.2.0"
51
+ "@openshain/tools": "0.3.1"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public"
package/src/server.ts CHANGED
@@ -8,15 +8,22 @@ 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,
@@ -27,6 +34,7 @@ import {
27
34
  type Work,
28
35
  type WorkId,
29
36
  WorkStore,
37
+ workHistory,
30
38
  } from "@openshain/core";
31
39
  import pkg from "../package.json" with { type: "json" };
32
40
  import { Session } from "./session.ts";
@@ -42,15 +50,32 @@ const WORK_TOOLS: Tool[] = [
42
50
  {
43
51
  name: "work_create",
44
52
  description:
45
- "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.",
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.',
46
54
  inputSchema: {
47
55
  type: "object",
48
56
  properties: {
49
- objective: { type: "string", description: "The request, in the person's words." },
57
+ objective: {
58
+ type: "string",
59
+ minLength: 1,
60
+ maxLength: 10_000,
61
+ description: "The request, in the person's words.",
62
+ },
50
63
  type: {
51
64
  type: "string",
65
+ maxLength: 50,
52
66
  description: "A short label for the kind of work. Defaults to request.",
53
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
+ },
54
79
  },
55
80
  required: ["objective"],
56
81
  additionalProperties: false,
@@ -68,10 +93,11 @@ const WORK_TOOLS: Tool[] = [
68
93
  },
69
94
  {
70
95
  name: "work_get",
71
- description: "The state of the current work, or of the work with the given id.",
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.",
72
98
  inputSchema: {
73
99
  type: "object",
74
- properties: { id: { type: "string" } },
100
+ properties: { id: { type: "string" }, history: { type: "boolean" } },
75
101
  additionalProperties: false,
76
102
  },
77
103
  },
@@ -87,12 +113,15 @@ const WORK_TOOLS: Tool[] = [
87
113
  inputSchema: {
88
114
  type: "object",
89
115
  properties: {
90
- summary: { type: "string" },
116
+ summary: { type: "string", maxLength: 20_000 },
91
117
  artifacts: {
92
118
  type: "array",
93
119
  items: {
94
120
  type: "object",
95
- properties: { path: { type: "string" }, sha256: { type: "string" } },
121
+ properties: {
122
+ path: { type: "string", maxLength: 1000 },
123
+ sha256: { type: "string", maxLength: 64 },
124
+ },
96
125
  required: ["path"],
97
126
  additionalProperties: false,
98
127
  },
@@ -107,13 +136,76 @@ const WORK_TOOLS: Tool[] = [
107
136
  description: "Give up on the current work. Say why in a short reason and, if useful, a detail.",
108
137
  inputSchema: {
109
138
  type: "object",
110
- properties: { reason: { type: "string" }, detail: { type: "string" } },
139
+ properties: {
140
+ reason: { type: "string", maxLength: 200 },
141
+ detail: { type: "string", maxLength: 20_000 },
142
+ },
111
143
  required: ["reason"],
112
144
  additionalProperties: false,
113
145
  },
114
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
+ },
115
191
  ];
116
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
+
117
209
  const NO_WORK =
118
210
  "no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
119
211
 
@@ -163,22 +255,32 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
163
255
  switch (name) {
164
256
  case "work_create": {
165
257
  const current = session.current;
166
- if (current && !isTerminal((await works.get(current)).status)) {
167
- return failure(
168
- `work ${current} is still in progress; finish it with work_complete or work_fail before starting another`,
169
- );
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
+ }
170
266
  }
171
- const { objective, type } = input as { objective: string; type?: string };
172
- if (type === SESSION_WORK_TYPE) {
173
- return failure(
174
- `type "${SESSION_WORK_TYPE}" is reserved for conversations; use another label, such as request`,
175
- );
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");
176
276
  }
177
277
  const work = await works.create({
178
278
  objective,
179
279
  principal: config.principal.id,
180
280
  profession: config.profession.id,
181
281
  ...(type && { type }),
282
+ ...(parent !== undefined && { parent }),
283
+ ...(agentName !== undefined && { agentName }),
182
284
  });
183
285
  await works.transition(work.id, "in_progress", "an agent took the work over MCP");
184
286
  session.select(work.id);
@@ -189,13 +291,99 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
189
291
  const work = await works.get(id);
190
292
  if (isTerminal(work.status)) return failure(`work ${id} is already ${work.status}`);
191
293
  session.select(id);
192
- return json(work);
294
+ return json({ ...work, history: workHistory(await works.events(id)) });
193
295
  }
194
296
  case "work_get": {
195
- const given = (input as { id?: string }).id;
297
+ const { id: given, history } = input as { id?: string; history?: boolean };
196
298
  const id = given ? parseWorkId(given) : session.current;
197
299
  if (!id) return failure(NO_WORK);
198
- return json(await works.get(id));
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
+ }
199
387
  }
200
388
  case "work_list": {
201
389
  const { works: all, problems } = await works.list();
@@ -203,8 +391,11 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
203
391
  works: all.map((w) => ({
204
392
  id: w.id,
205
393
  status: w.status,
394
+ type: w.type,
206
395
  objective: w.objective,
207
396
  createdAt: w.createdAt,
397
+ ...(w.parent !== undefined && { parent: w.parent }),
398
+ ...(w.agentName !== undefined && { agentName: w.agentName }),
208
399
  })),
209
400
  problems: problems.map((p) => ({
210
401
  id: p.id,
@@ -240,8 +431,24 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
240
431
  default: {
241
432
  const gate = await openWork();
242
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
+ }
243
441
  const opened = await works.open(gate.id);
244
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
+ }
245
452
  const result = await callTool(opened, { id: newCallId(), name, input });
246
453
  return toMcpResult(result);
247
454
  } finally {
@@ -323,6 +530,7 @@ function toMcpTool(definition: ToolDefinition): Tool {
323
530
  name: definition.name,
324
531
  description: definition.description,
325
532
  inputSchema: definition.inputSchema as Tool["inputSchema"],
533
+ annotations: { readOnlyHint: definition.effect === "observe" },
326
534
  };
327
535
  }
328
536
 
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 {