@skillstate/mcp 2.0.7 → 2.2.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.
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * @non-paper MCP server — no MCP exists in arXiv 2608.26263v3.
3
3
  *
4
- * A zero-dependency Model Context Protocol server over stdio (JSON-RPC
5
- * 2.0) that exposes the skillstate runtime as MCP tools. It reuses the
4
+ * A zero-dependency Model Context Protocol server (protocol revision
5
+ * `2026-07-28`) over stdio: newline-delimited JSON-RPC 2.0 in, one
6
+ * newline-terminated JSON-RPC response per message out. It exposes the
7
+ * skillstate runtime as MCP tools and resources and reuses the
6
8
  * paper-exact core directly:
7
9
  *
8
10
  * - `mergeState` (⊕ null-deletion merge, §3.2) and `createInitialState`;
@@ -10,82 +12,352 @@
10
12
  * - `resolveStatePath` (confines `{ root, name }` — traversal throws);
11
13
  * - `migrate` (normalizes bare/v0/v1 persisted state);
12
14
  * - `redactSecrets` (fail-closed scrubber so secrets never leave the
13
- * process via a tool result).
15
+ * process via a tool result or resource read).
14
16
  *
15
- * TRANSPORT: accepts both official MCP stdio newline-delimited JSON-RPC
16
- * AND `Content-Length`-framed messages (LSP-style). Responses echo the
17
- * framing of the message that triggered them. State persistence uses a
18
- * synchronous temp-sibling + fsync + rename so a mid-write crash can never
19
- * produce a truncated state file.
17
+ * TRANSPORT: newline-delimited JSON only (the MCP stdio framing). Partial
18
+ * lines are buffered until complete. State persistence uses a synchronous
19
+ * temp-sibling + fsync + rename so a mid-write crash can never produce a
20
+ * truncated state file. `state.checkpoint` additionally pins the state
21
+ * through `FileStore.snapshot()` (a `<path>.snapshot` side copy) and a
22
+ * named sidecar under `<stateDir>/checkpoints/<seq>-<label>.json`; the
23
+ * write-sequence number `seq` is derived from the sidecar catalog, so it
24
+ * survives server restarts.
20
25
  */
21
26
  import * as fs from 'node:fs';
22
27
  import * as os from 'node:os';
23
28
  import * as path from 'node:path';
24
- import { resolveStatePath } from '@skillstate/core';
25
- import { mergeState, createInitialState } from '@skillstate/core';
26
- import { validatePatchDeep } from '@skillstate/core';
27
- import { migrate } from '@skillstate/core';
28
- import { redactSecrets } from '@skillstate/core';
29
+ import { FileStore, atomicWriteFile, createInitialState, mergeState, migrate, readSessionMeta, redactSecrets, resolveHostStateForCwd, resolveStatePath, sanitizeAgentId, sessionStaleness, validatePatchDeep, withStateLock, writeSessionMeta, CURRENT_STATE_VERSION, SESSION_META_FILE, } from '@skillstate/core';
30
+ import { installShutdown } from '@skillstate/core';
29
31
  import { INTERCODE_CTF_SPEC } from '@skillstate/core/schemas';
32
+ /** The single MCP protocol revision this server speaks (initialize answer). */
33
+ export const PROTOCOL_VERSION = '2026-07-28';
34
+ /** `notes` is truncated to this many chars in summary projections. */
35
+ const SUMMARY_NOTES_MAX_CHARS = 200;
36
+ /** How many `next_steps` entries the summary/spec.next projections keep. */
37
+ const SUMMARY_NEXT_PREVIEW = 3;
38
+ /**
39
+ * Session-activity debounce: the `.session-meta.json` `lastActivityAt`
40
+ * stamp is rewritten at most once per 5s of state writes.
41
+ */
42
+ const ACTIVITY_DEBOUNCE_MS = 5000;
43
+ function isPlainObject(value) {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+ /** JSON-ish kind name used in summary type maps. */
47
+ function kindOf(value) {
48
+ return Array.isArray(value) ? 'array' : typeof value;
49
+ }
50
+ /**
51
+ * Top-level diff between two states: `added` (only in `after`), `deleted`
52
+ * (only in `before`), `updated` (in both, different JSON). Pure.
53
+ */
54
+ function topChanges(before, after) {
55
+ const added = [];
56
+ const updated = [];
57
+ const deleted = [];
58
+ for (const key of Object.keys(after)) {
59
+ if (!(key in before)) {
60
+ added.push(key);
61
+ }
62
+ }
63
+ for (const key of Object.keys(before)) {
64
+ if (!(key in after)) {
65
+ deleted.push(key);
66
+ }
67
+ else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
68
+ updated.push(key);
69
+ }
70
+ }
71
+ return { added, updated, deleted };
72
+ }
73
+ /**
74
+ * Warnings for deep merge conflicts: a patch key whose value is an object
75
+ * merged into an existing object (rather than replacing it).
76
+ */
77
+ function nestedMergeWarnings(before, patch) {
78
+ const warnings = [];
79
+ for (const [key, value] of Object.entries(patch)) {
80
+ if (isPlainObject(value) && isPlainObject(before[key])) {
81
+ warnings.push(`nested merge under '${key}': the patch object was merged into the existing object (set nested keys to null to delete them)`);
82
+ }
83
+ }
84
+ return warnings;
85
+ }
86
+ /**
87
+ * AGENT-MERGE conflict resolution: build the effective sub-state patch
88
+ * against `main` under the `keep` policy. Schema defaults are the shared
89
+ * "unset" baseline: a sub value equal to its schema default means the sub
90
+ * agent never set the key (skip), a main value equal to its default means
91
+ * the main agent never set it (no real conflict — the sub value wins).
92
+ * Everything else: keys only in `sub` are taken, identical keys are
93
+ * skipped, nested plain objects recurse, and remaining scalar conflicts
94
+ * go to the `keep` winner. Deletions stay local to the sub copy — the
95
+ * merge carries set/updated keys only.
96
+ */
97
+ function resolveAgentMergeConflicts(main, sub, keep, defaults) {
98
+ const patch = {};
99
+ for (const [key, subValue] of Object.entries(sub)) {
100
+ if (!Object.prototype.hasOwnProperty.call(main, key)) {
101
+ patch[key] = subValue;
102
+ continue;
103
+ }
104
+ const mainValue = main[key];
105
+ if (JSON.stringify(mainValue) === JSON.stringify(subValue)) {
106
+ continue;
107
+ }
108
+ if (isPlainObject(mainValue) && isPlainObject(subValue)) {
109
+ const nested = resolveAgentMergeConflicts(mainValue, subValue, keep, {});
110
+ if (Object.keys(nested).length > 0) {
111
+ patch[key] = nested;
112
+ }
113
+ continue;
114
+ }
115
+ const isDefault = (value) => Object.prototype.hasOwnProperty.call(defaults, key) &&
116
+ JSON.stringify(value) === JSON.stringify(defaults[key]);
117
+ if (isDefault(subValue)) {
118
+ continue;
119
+ }
120
+ if (isDefault(mainValue) || keep === 'sub') {
121
+ patch[key] = subValue;
122
+ }
123
+ }
124
+ return patch;
125
+ }
126
+ /** Light sub-agent summary: top-level keys + JSON size (no values). */
127
+ function agentSummary(state) {
128
+ return {
129
+ keys: Object.keys(state),
130
+ size_bytes: Buffer.byteLength(JSON.stringify(state), 'utf-8'),
131
+ };
132
+ }
133
+ /**
134
+ * Compact state projection shared by `state.summary` and
135
+ * `skillstate://summary`. Recognizes the generic-procedure fields
136
+ * (goal/progress/next_steps/artifacts/blockers/notes) and degrades to a
137
+ * keys+types+size listing for schemas without them. Never CTF-specific.
138
+ */
139
+ function buildSummary(state) {
140
+ const projection = {};
141
+ const other = {};
142
+ let generic = false;
143
+ for (const [key, value] of Object.entries(state)) {
144
+ if (key === 'goal' && typeof value === 'string') {
145
+ projection['goal'] = value;
146
+ generic = true;
147
+ }
148
+ else if (key === 'notes' && typeof value === 'string') {
149
+ projection['notes'] =
150
+ value.length > SUMMARY_NOTES_MAX_CHARS
151
+ ? `${value.slice(0, SUMMARY_NOTES_MAX_CHARS)}…`
152
+ : value;
153
+ generic = true;
154
+ }
155
+ else if ((key === 'progress' || key === 'next_steps' || key === 'artifacts' || key === 'blockers') &&
156
+ Array.isArray(value)) {
157
+ projection[key] =
158
+ key === 'next_steps'
159
+ ? { count: value.length, first: value.slice(0, SUMMARY_NEXT_PREVIEW) }
160
+ : { count: value.length };
161
+ generic = true;
162
+ }
163
+ else {
164
+ other[key] = kindOf(value);
165
+ }
166
+ }
167
+ if (!generic) {
168
+ return { keys: other, size_bytes: Buffer.byteLength(JSON.stringify(state), 'utf-8') };
169
+ }
170
+ if (Object.keys(other).length > 0) {
171
+ projection['other'] = other;
172
+ }
173
+ projection['size_bytes'] = Buffer.byteLength(JSON.stringify(state), 'utf-8');
174
+ return projection;
175
+ }
176
+ /** Sensible placeholder values for the generic-procedure schema fields. */
177
+ const GENERIC_EXAMPLE_VALUES = {
178
+ goal: 'Describe what the procedure is trying to achieve',
179
+ progress: ['Completed milestone'],
180
+ next_steps: ['Next action to take'],
181
+ artifacts: ['path/to/artifact'],
182
+ blockers: [],
183
+ notes: 'Working notes persisted between steps',
184
+ };
185
+ /** JSON defaults per schema type, so generated examples always validate. */
186
+ const TYPE_DEFAULTS = {
187
+ string: '',
188
+ number: 0,
189
+ boolean: false,
190
+ array: [],
191
+ object: {},
192
+ };
193
+ /**
194
+ * Build a ready-to-use example patch from a schema: every key with a
195
+ * generic placeholder or a type default. The result passes
196
+ * `validatePatchDeep` against the same schema by construction.
197
+ */
198
+ function buildExamplePatch(schema) {
199
+ const example = {};
200
+ for (const [key, field] of Object.entries(schema)) {
201
+ example[key] = key in GENERIC_EXAMPLE_VALUES
202
+ ? GENERIC_EXAMPLE_VALUES[key]
203
+ : TYPE_DEFAULTS[field.type];
204
+ }
205
+ return example;
206
+ }
207
+ /** Filesystem-safe label: weird chars collapse to '-', empty → 'checkpoint'. */
208
+ function sanitizeLabel(raw) {
209
+ const cleaned = raw
210
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
211
+ .replace(/^-+|-+$/g, '')
212
+ .slice(0, 64);
213
+ return cleaned.length > 0 ? cleaned : 'checkpoint';
214
+ }
30
215
  /**
31
216
  * The `skillstate` MCP server: a JSON-RPC 2.0 over stdio server exposing
32
- * the skillstate runtime as MCP tools.
217
+ * the skillstate runtime as MCP tools and resources.
33
218
  */
34
219
  export class McpServer {
35
220
  options;
36
- /** Protocol version advertised by the server on `initialize`. */
37
- protocolVersion = '2024-11-05';
38
- /** Advertised server capabilities (a minimal subset). */
39
- capabilities = { tools: { listChanged: false } };
221
+ /** Protocol revision advertised on `initialize` — always exactly this. */
222
+ protocolVersion = PROTOCOL_VERSION;
223
+ /** Advertised server capabilities. */
224
+ capabilities = {
225
+ tools: { listChanged: true },
226
+ resources: {},
227
+ logging: {},
228
+ prompts: { listChanged: true },
229
+ };
40
230
  /** Advertised server identity. */
41
231
  serverInfo = { name: 'skillstate', version: '1.0.0' };
42
232
  buffer = '';
43
233
  running = false;
44
- frameMode = 'jsonl';
234
+ /** Serializes `start()` stream handling so chunk order is preserved. */
235
+ chain = Promise.resolve();
236
+ /**
237
+ * Diff baselines are persisted to disk (`.diff-baseline.json` next to
238
+ * each state file, under the cross-process lock) — the "since your last
239
+ * look" semantics stays, but is now CONSISTENT BETWEEN PROCESSES: the
240
+ * former in-memory per-server Map made two servers diff against
241
+ * different baselines.
242
+ */
243
+ /** Writes (patch/rollback) applied per resolved state path this session. */
244
+ writeSeq = new Map();
245
+ /** Debounce clock for `.session-meta.json` activity stamps (per meta path). */
246
+ lastActivityWrite = new Map();
247
+ /** Uninstall closure for the SIGINT/SIGTERM interrupt handler (if wired). */
248
+ uninstallShutdown = null;
249
+ /** The `{ source, handler }` pair attached by `start()` (removed by `stop()`). */
250
+ attached = null;
45
251
  constructor(options) {
46
252
  this.options = options;
253
+ if (options.agent !== undefined &&
254
+ options.agent.length > 0 &&
255
+ sanitizeAgentId(options.agent).length === 0) {
256
+ throw new Error(`Invalid agent id: ${options.agent}`);
257
+ }
258
+ }
259
+ /**
260
+ * The state DIRECTORY the server session owns (its sidecars —
261
+ * `.session-meta.json`, the diff baseline — live next to the default
262
+ * state file; agent-scoped calls keep their per-agent directories).
263
+ */
264
+ get sessionDir() {
265
+ return path.dirname(this.resolveRef({}).filePath);
266
+ }
267
+ /**
268
+ * Stamp the session sidecar for `dir` with `lastActivityAt: now`,
269
+ * debounced to one write per {@link ACTIVITY_DEBOUNCE_MS} per directory
270
+ * (state writes stay the hot path). The meta sidecar is best-effort
271
+ * orchestration metadata: a failed write is swallowed — a broken
272
+ * sidecar never fails a state write. The write itself runs under the
273
+ * meta file's own `withStateLock` (never the state lock — no deadlock).
274
+ */
275
+ async touchActivity(dir) {
276
+ const metaPath = path.join(dir, SESSION_META_FILE);
277
+ const now = Date.now();
278
+ if (now - (this.lastActivityWrite.get(metaPath) ?? 0) < ACTIVITY_DEBOUNCE_MS) {
279
+ return;
280
+ }
281
+ this.lastActivityWrite.set(metaPath, now);
282
+ try {
283
+ await writeSessionMeta(dir, { lastActivityAt: new Date(now).toISOString() });
284
+ }
285
+ catch {
286
+ // Best-effort activity stamp (e.g. the sidecar path is unwritable).
287
+ }
288
+ }
289
+ /**
290
+ * Wire the @non-paper shutdown seam: SIGINT/SIGTERM best-effort flush
291
+ * the session sidecar to `status: "interrupted"` + re-pin the diff
292
+ * baseline to the surviving state (the next process starts diffing from
293
+ * the post-crash state, not from a pre-crash baseline), then exit with
294
+ * the conventional 130. Terminal statuses (`completed` / `failed` /
295
+ * `merged`) recorded by the agent itself are never clobbered — hosts
296
+ * SIGTERM their servers after a clean finalize too. Idempotent; returns
297
+ * an uninstall closure for embedders/tests.
298
+ */
299
+ installInterruptHandler() {
300
+ if (this.uninstallShutdown !== null) {
301
+ return this.uninstallShutdown;
302
+ }
303
+ this.uninstallShutdown = installShutdown(async () => {
304
+ try {
305
+ const current = readSessionMeta(this.sessionDir);
306
+ if (current?.status !== 'completed' &&
307
+ current?.status !== 'failed' &&
308
+ current?.status !== 'merged') {
309
+ // Hosts SIGTERM their servers after a clean finalize too — never
310
+ // clobber a terminal status the agent recorded itself.
311
+ await writeSessionMeta(this.sessionDir, {
312
+ status: 'interrupted',
313
+ lastActivityAt: new Date().toISOString(),
314
+ });
315
+ }
316
+ }
317
+ catch {
318
+ // Meta flush is best-effort; the baseline flush below still runs.
319
+ }
320
+ try {
321
+ const ref = this.resolveRef({});
322
+ // Flush the baseline: the surviving state becomes the new "since
323
+ // your last look" point for the next process (never diff against
324
+ // a pre-crash baseline).
325
+ this.writeBaseline(ref.filePath, this.loadState(ref.filePath));
326
+ }
327
+ catch {
328
+ // Baseline flush is best-effort too — teardown must never crash.
329
+ }
330
+ process.exit(130);
331
+ });
332
+ return this.uninstallShutdown;
333
+ }
334
+ /** Detach the SIGINT/SIGTERM handler (embedders/tests owning the process). */
335
+ detachInterruptHandler() {
336
+ this.uninstallShutdown?.();
337
+ this.uninstallShutdown = null;
47
338
  }
48
339
  /**
49
340
  * Process a single (already-framed) JSON-RPC message line and return the
50
341
  * response string, or `null` when the message needs no reply (a
51
- * notification). This is the stateless unit entry point used by the
52
- * stdio transport; `feed` drives it for framed/streamed input.
342
+ * notification). The stateless unit entry point used by tests and the
343
+ * stdio transport.
53
344
  */
54
345
  handleLine(line) {
55
346
  const text = line.trim();
56
347
  if (text.length === 0) {
57
- return null;
348
+ return Promise.resolve(null);
58
349
  }
59
350
  return this.processRaw(text);
60
351
  }
61
352
  /**
62
- * Feed a raw chunk of stdin and return every response produced by the
63
- * complete messages it contains. Handles BOTH newline-delimited JSON-RPC
64
- * and `Content-Length`-framed messages; partial frames are buffered until
65
- * the rest arrives. Responses are framed like the message that triggered
66
- * them.
353
+ * Feed a raw chunk of stdin and return every newline-delimited response
354
+ * produced by the complete messages it contains. Partial lines are
355
+ * buffered until the rest arrives. Each response ends with a newline.
67
356
  */
68
- feed(chunk) {
357
+ async feed(chunk) {
69
358
  this.buffer += chunk;
70
359
  const responses = [];
71
- while (this.buffer.length > 0) {
72
- this.buffer = this.buffer.replace(/^(\r?\n)+/, '');
73
- if (this.buffer.length === 0) {
74
- break;
75
- }
76
- if (/^Content-Length:\s*\d+/i.test(this.buffer)) {
77
- const framed = this.takeContentLengthFrame();
78
- if (framed === 'incomplete') {
79
- break;
80
- }
81
- this.frameMode = 'content-length';
82
- const response = this.processRaw(framed.body);
83
- if (response !== null) {
84
- responses.push(this.encodeResponse(response, this.frameMode));
85
- }
86
- this.buffer = this.buffer.slice(framed.consumed);
87
- continue;
88
- }
360
+ for (;;) {
89
361
  const newline = this.buffer.indexOf('\n');
90
362
  if (newline === -1) {
91
363
  break;
@@ -95,35 +367,38 @@ export class McpServer {
95
367
  if (line.length === 0) {
96
368
  continue;
97
369
  }
98
- this.frameMode = 'jsonl';
99
- const response = this.processRaw(line);
370
+ const response = await this.processRaw(line);
100
371
  if (response !== null) {
101
- responses.push(this.encodeResponse(response, this.frameMode));
372
+ responses.push(`${response}\n`);
102
373
  }
103
374
  }
104
375
  return responses;
105
376
  }
106
377
  /**
107
378
  * Attach the server to a stdin/stdout pair (defaults to `process`).
108
- * Resolves once the server is reading; `stop()` detaches it. The server
109
- * conserves its own buffered state, so real transports may hand over
110
- * chunks that split mid-frame.
379
+ * Resolves once the server is reading; `stop()` detaches it. Chunks are
380
+ * processed strictly in arrival order even though handling is async.
111
381
  */
112
382
  async start(input, output) {
113
383
  const source = input ?? process.stdin;
114
384
  const sink = output ?? process.stdout;
115
385
  this.running = true;
116
- source.on('data', (chunk) => {
386
+ const handler = (chunk) => {
117
387
  const text = typeof chunk === 'string' ? chunk : chunk.toString();
118
- for (const response of this.feed(text)) {
119
- sink.write(response);
120
- }
121
- });
388
+ void this.pump(text, sink);
389
+ };
390
+ source.on('data', handler);
391
+ this.attached = { source, handler };
122
392
  return this;
123
393
  }
124
- /** Mark the server stopped (idempotent). */
394
+ /**
395
+ * Mark the server stopped (idempotent): detaches the input listener
396
+ * attached by `start()` so embedded servers release their streams.
397
+ */
125
398
  stop() {
126
399
  this.running = false;
400
+ this.attached?.source.removeListener('data', this.attached.handler);
401
+ this.attached = null;
127
402
  }
128
403
  /** Whether the server is currently reading from its input stream. */
129
404
  get isRunning() {
@@ -132,6 +407,15 @@ export class McpServer {
132
407
  /* ------------------------------------------------------------------ */
133
408
  /* JSON-RPC dispatch */
134
409
  /* ------------------------------------------------------------------ */
410
+ /** Append one chunk to the ordered stream pipeline. */
411
+ async pump(text, sink) {
412
+ this.chain = this.chain.then(async () => {
413
+ for (const response of await this.feed(text)) {
414
+ sink.write(response);
415
+ }
416
+ });
417
+ await this.chain;
418
+ }
135
419
  /** Parse raw text into a message and dispatch; `-32700` on parse error. */
136
420
  processRaw(text) {
137
421
  let message;
@@ -139,13 +423,13 @@ export class McpServer {
139
423
  message = JSON.parse(text);
140
424
  }
141
425
  catch {
142
- return this.errorResponse(null, -32700, 'Parse error');
426
+ return Promise.resolve(this.errorResponse(null, -32700, 'Parse error'));
143
427
  }
144
428
  return this.processMessage(message);
145
429
  }
146
430
  processMessage(message) {
147
431
  if (typeof message !== 'object' || message === null || Array.isArray(message)) {
148
- return this.errorResponse(null, -32600, 'Invalid Request');
432
+ return Promise.resolve(this.errorResponse(null, -32600, 'Invalid Request'));
149
433
  }
150
434
  const msg = message;
151
435
  const id = 'id' in msg ? msg.id ?? null : null;
@@ -153,19 +437,19 @@ export class McpServer {
153
437
  const hasId = 'id' in msg;
154
438
  if (typeof method === 'string' && method.startsWith('notifications/')) {
155
439
  if (hasId) {
156
- return this.errorResponse(id, -32600, 'Invalid Request: notifications must not include an id');
440
+ return Promise.resolve(this.errorResponse(id, -32600, 'Invalid Request: notifications must not include an id'));
157
441
  }
158
- return null;
442
+ return Promise.resolve(null);
159
443
  }
160
444
  if (typeof method !== 'string' || method.length === 0) {
161
- return this.errorResponse(id, -32600, 'Invalid Request');
445
+ return Promise.resolve(this.errorResponse(id, -32600, 'Invalid Request'));
162
446
  }
163
447
  if (!hasId) {
164
- return null;
448
+ return Promise.resolve(null);
165
449
  }
166
450
  return this.handleRequest(id, method, msg.params);
167
451
  }
168
- handleRequest(id, method, params) {
452
+ async handleRequest(id, method, params) {
169
453
  switch (method) {
170
454
  case 'initialize':
171
455
  return this.successResponse(id, {
@@ -181,87 +465,302 @@ export class McpServer {
181
465
  return this.handleToolCall(id, params);
182
466
  case 'resources/list':
183
467
  return this.successResponse(id, { resources: this.resourcesList() });
468
+ case 'resources/read':
469
+ return this.handleResourceRead(id, params);
470
+ case 'prompts/list':
471
+ return this.successResponse(id, { prompts: [] });
472
+ case 'logging/setLevel':
473
+ return this.successResponse(id, {});
184
474
  default:
185
475
  return this.errorResponse(id, -32601, `Method not found: ${method}`);
186
476
  }
187
477
  }
188
- handleToolCall(id, params) {
478
+ async handleToolCall(id, params) {
189
479
  if (!isPlainObject(params)) {
190
480
  return this.errorResponse(id, -32602, 'Invalid params: expected an object');
191
481
  }
192
- const name = params.name;
482
+ const name = params['name'];
193
483
  if (typeof name !== 'string' || name.length === 0) {
194
484
  return this.errorResponse(id, -32602, 'Invalid params: name required');
195
485
  }
196
- const args = params.arguments;
486
+ const args = params['arguments'];
197
487
  const argsObj = isPlainObject(args) ? args : {};
198
488
  try {
199
- const result = this.callTool(name, argsObj);
489
+ const result = await this.callTool(name, argsObj);
200
490
  return this.successResponse(id, result);
201
491
  }
202
492
  catch (err) {
203
- const message = String(err);
204
493
  return this.successResponse(id, {
205
- content: [{ type: 'text', text: message }],
494
+ content: [{ type: 'text', text: redactSecrets(String(err)) }],
206
495
  isError: true,
207
496
  });
208
497
  }
209
498
  }
499
+ handleResourceRead(id, params) {
500
+ const uri = isPlainObject(params) && typeof params['uri'] === 'string'
501
+ ? params['uri']
502
+ : undefined;
503
+ if (uri === undefined) {
504
+ return this.errorResponse(id, -32602, 'Invalid params: uri required');
505
+ }
506
+ let text;
507
+ switch (uri) {
508
+ case 'skillstate://state': {
509
+ const state = this.loadState(this.resolveStore({}));
510
+ text = redactSecrets(JSON.stringify({ version: CURRENT_STATE_VERSION, state }));
511
+ break;
512
+ }
513
+ case 'skillstate://spec': {
514
+ const spec = this.options.spec;
515
+ text = redactSecrets(JSON.stringify({
516
+ id: spec.id,
517
+ name: spec.name,
518
+ version: spec.version,
519
+ instructions: spec.instructions,
520
+ schema: spec.schema,
521
+ }));
522
+ break;
523
+ }
524
+ case 'skillstate://summary': {
525
+ text = redactSecrets(JSON.stringify(buildSummary(this.loadState(this.resolveStore({})))));
526
+ break;
527
+ }
528
+ default:
529
+ return this.errorResponse(id, -32602, `Unknown resource: ${uri}`);
530
+ }
531
+ return this.successResponse(id, {
532
+ contents: [{ uri, mimeType: 'application/json', text }],
533
+ });
534
+ }
210
535
  /* ------------------------------------------------------------------ */
211
536
  /* Tool implementations */
212
537
  /* ------------------------------------------------------------------ */
213
- callTool(name, args) {
538
+ async callTool(name, args) {
214
539
  switch (name) {
215
540
  case 'state.get':
216
541
  return this.stateGet(args);
217
542
  case 'state.patch':
218
543
  return this.statePatch(args);
219
- case 'state.merge':
220
- return this.stateMerge(args);
221
- case 'state.reset':
222
- return this.stateReset(args);
223
- case 'spec.get':
224
- return this.specGet();
544
+ case 'state.validate':
545
+ return this.stateValidate(args);
546
+ case 'state.diff':
547
+ return this.stateDiff(args);
548
+ case 'state.checkpoint':
549
+ return this.stateCheckpoint(args);
550
+ case 'state.rollback':
551
+ return this.stateRollback(args);
552
+ case 'state.summary':
553
+ return this.stateSummary(args);
225
554
  case 'state.metrics':
226
555
  return this.stateMetrics();
556
+ case 'state.finalize':
557
+ return this.stateFinalize(args);
558
+ case 'spec.get':
559
+ return this.specGet();
560
+ case 'spec.next':
561
+ return this.specNext(args);
562
+ case 'agent.list':
563
+ return this.agentList();
564
+ case 'agent.read':
565
+ return this.agentRead(args);
566
+ case 'agent.merge':
567
+ return this.agentMerge(args);
227
568
  default:
228
569
  throw new Error(`Unknown tool: ${name}`);
229
570
  }
230
571
  }
231
572
  stateGet(args) {
232
- const filePath = this.resolveStore(args);
233
- const state = this.loadState(filePath);
573
+ const state = this.loadState(this.resolveStore(args));
234
574
  return this.textResult(redactSecrets(JSON.stringify(state)));
235
575
  }
236
- statePatch(args) {
237
- const patch = args.patch;
576
+ async statePatch(args) {
577
+ const patch = args['patch'];
238
578
  if (!isPlainObject(patch)) {
239
579
  throw new Error('patch must be an object');
240
580
  }
581
+ const validation = validatePatchDeep(this.options.spec.schema, patch);
582
+ if (!validation.valid) {
583
+ return {
584
+ content: [
585
+ {
586
+ type: 'text',
587
+ text: redactSecrets(JSON.stringify({ valid: false, error: validation.error, field: validation.field })),
588
+ },
589
+ ],
590
+ isError: true,
591
+ };
592
+ }
241
593
  const filePath = this.resolveStore(args);
242
- const merged = mergeState(this.loadState(filePath), patch);
243
- this.writeState(filePath, merged);
244
- return this.textResult(redactSecrets(JSON.stringify(merged)));
594
+ const { before, after } = await withStateLock(filePath, () => {
595
+ const before = this.loadState(filePath);
596
+ const after = mergeState(before, patch);
597
+ this.writeState(filePath, after);
598
+ this.bumpWriteSeq(filePath);
599
+ if (this.readBaseline(filePath) === null) {
600
+ this.writeBaseline(filePath, before);
601
+ }
602
+ return { before, after };
603
+ });
604
+ await this.touchActivity(path.dirname(filePath));
605
+ const payload = {
606
+ state: after,
607
+ changes: topChanges(before, after),
608
+ warnings: nestedMergeWarnings(before, patch),
609
+ };
610
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
245
611
  }
246
- stateMerge(args) {
247
- const patch = args.patch;
612
+ stateValidate(args) {
613
+ const patch = args['patch'];
248
614
  if (!isPlainObject(patch)) {
249
615
  throw new Error('patch must be an object');
250
616
  }
251
617
  const validation = validatePatchDeep(this.options.spec.schema, patch);
252
- if (!validation.valid) {
253
- throw new Error(validation.error);
254
- }
618
+ return this.textResult(redactSecrets(JSON.stringify(validation.valid
619
+ ? { valid: true }
620
+ : { valid: false, error: validation.error, field: validation.field })));
621
+ }
622
+ async stateDiff(args) {
255
623
  const filePath = this.resolveStore(args);
256
- const merged = mergeState(this.loadState(filePath), patch);
257
- this.writeState(filePath, merged);
258
- return this.textResult(redactSecrets(JSON.stringify(merged)));
624
+ const { before, current } = await withStateLock(filePath, () => {
625
+ const current = this.loadState(filePath);
626
+ const stored = this.readBaseline(filePath);
627
+ this.writeBaseline(filePath, current);
628
+ return { before: stored ?? current, current };
629
+ });
630
+ const payload = {
631
+ changes: topChanges(before, current),
632
+ };
633
+ if (args['full'] === true) {
634
+ payload['before'] = before;
635
+ payload['after'] = current;
636
+ }
637
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
638
+ }
639
+ async stateCheckpoint(args) {
640
+ const ref = this.resolveRef(args);
641
+ const dir = checkpointsDir(ref.filePath);
642
+ const payload = await withStateLock(ref.filePath, async () => {
643
+ const state = this.loadState(ref.filePath);
644
+ const seq = nextCheckpointSeq(dir);
645
+ const label = sanitizeLabel(typeof args['label'] === 'string' ? args['label'] : '');
646
+ const checkpointId = `${seq}-${label}`;
647
+ const record = {
648
+ checkpointId,
649
+ seq,
650
+ label,
651
+ createdAt: new Date().toISOString(),
652
+ state,
653
+ };
654
+ // Best-effort `<path>.snapshot` side copy through the paper-exact
655
+ // store, then the named sidecar entry (both atomic writes).
656
+ await new FileStore(ref.root, ref.name).snapshot();
657
+ await atomicWriteFile(path.join(dir, `${checkpointId}.json`), JSON.stringify(record, null, 2));
658
+ return {
659
+ checkpointId,
660
+ seq,
661
+ label,
662
+ checkpoints: listCheckpoints(dir),
663
+ };
664
+ });
665
+ await this.touchActivity(path.dirname(ref.filePath));
666
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
259
667
  }
260
- stateReset(args) {
668
+ async stateRollback(args) {
669
+ const ref = this.resolveRef(args);
670
+ const dir = checkpointsDir(ref.filePath);
671
+ const wanted = typeof args['checkpointId'] === 'string' ? args['checkpointId'] : undefined;
672
+ let checkpointId;
673
+ if (wanted === undefined) {
674
+ const list = listCheckpoints(dir);
675
+ if (list.length === 0) {
676
+ throw new Error('No checkpoints found: create one with state.checkpoint first');
677
+ }
678
+ checkpointId = list[list.length - 1].checkpointId;
679
+ }
680
+ else {
681
+ checkpointId = wanted;
682
+ }
683
+ if (!/^[A-Za-z0-9._-]+$/.test(checkpointId)) {
684
+ throw new Error(`Checkpoint not found: ${checkpointId}`);
685
+ }
686
+ let record;
687
+ try {
688
+ record = JSON.parse(fs.readFileSync(path.join(dir, `${checkpointId}.json`), 'utf-8'));
689
+ }
690
+ catch {
691
+ throw new Error(`Checkpoint not found or unreadable: ${checkpointId}`);
692
+ }
693
+ if (!isPlainObject(record.state)) {
694
+ throw new Error(`Checkpoint is corrupted (no state): ${checkpointId}`);
695
+ }
696
+ const payload = await withStateLock(ref.filePath, () => {
697
+ const before = this.loadState(ref.filePath);
698
+ this.writeState(ref.filePath, record.state);
699
+ this.bumpWriteSeq(ref.filePath);
700
+ if (this.readBaseline(ref.filePath) === null) {
701
+ this.writeBaseline(ref.filePath, before);
702
+ }
703
+ return { checkpointId, state: record.state };
704
+ });
705
+ await this.touchActivity(path.dirname(ref.filePath));
706
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
707
+ }
708
+ stateSummary(args) {
261
709
  const filePath = this.resolveStore(args);
262
- const initial = createInitialState(this.options.spec.schema);
263
- this.writeState(filePath, initial);
264
- return this.textResult(redactSecrets(JSON.stringify(initial)));
710
+ const state = this.loadState(filePath);
711
+ const meta = readSessionMeta(path.dirname(filePath));
712
+ const payload = {
713
+ ...buildSummary(state),
714
+ session: {
715
+ statePath: filePath,
716
+ envelopeVersion: CURRENT_STATE_VERSION,
717
+ protocolVersion: this.protocolVersion,
718
+ seq: this.writeSeq.get(filePath) ?? 0,
719
+ status: meta?.status ?? null,
720
+ lastActivityAt: meta?.lastActivityAt ?? null,
721
+ staleness: sessionStaleness(meta),
722
+ },
723
+ };
724
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
725
+ }
726
+ stateMetrics() {
727
+ const tracker = this.options.tracker;
728
+ if (!tracker) {
729
+ throw new Error('No token tracker configured');
730
+ }
731
+ if (tracker.getBookkeeping().stepCount === 0) {
732
+ throw new Error('No steps recorded yet: the token tracker session is empty');
733
+ }
734
+ return this.textResult(redactSecrets(JSON.stringify(tracker.getMetrics())));
735
+ }
736
+ /**
737
+ * `state.finalize` — the agent's own "I am done" signal. Writes the
738
+ * session sidecar `{ status, finishedAt, result }` under the meta lock
739
+ * and returns the recorded lifecycle so the orchestrator (or a later
740
+ * `agent.read`/`agent.list`) sees it. `result` is an optional free-text
741
+ * outcome; invalid statuses are rejected before anything is written.
742
+ */
743
+ async stateFinalize(args) {
744
+ const status = args['status'];
745
+ if (status !== 'completed' && status !== 'failed') {
746
+ throw new Error(`status must be "completed" or "failed", got: ${JSON.stringify(status)}`);
747
+ }
748
+ const result = typeof args['result'] === 'string' ? args['result'] : undefined;
749
+ const ref = this.resolveRef(args);
750
+ const meta = await writeSessionMeta(path.dirname(ref.filePath), {
751
+ status,
752
+ finishedAt: new Date().toISOString(),
753
+ ...(result === undefined ? {} : { result }),
754
+ });
755
+ const payload = {
756
+ status: meta.status,
757
+ finishedAt: meta.finishedAt,
758
+ sessionMetaPath: path.join(path.dirname(ref.filePath), SESSION_META_FILE),
759
+ };
760
+ if (result !== undefined) {
761
+ payload['result'] = result;
762
+ }
763
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
265
764
  }
266
765
  specGet() {
267
766
  const spec = this.options.spec;
@@ -271,27 +770,182 @@ export class McpServer {
271
770
  version: spec.version,
272
771
  instructions: spec.instructions,
273
772
  schema: spec.schema,
773
+ example_state_patch: buildExamplePatch(spec.schema),
274
774
  })));
275
775
  }
276
- stateMetrics() {
277
- const tracker = this.options.tracker;
278
- if (!tracker) {
279
- throw new Error('No token tracker configured');
280
- }
281
- const metrics = {
282
- ...tracker.getMetrics(),
283
- ...tracker.getBookkeeping(),
776
+ specNext(args) {
777
+ const state = this.loadState(this.resolveStore(args));
778
+ const progress = Array.isArray(state['progress']) ? state['progress'] : [];
779
+ const nextSteps = Array.isArray(state['next_steps']) ? state['next_steps'] : [];
780
+ const blockers = Array.isArray(state['blockers']) ? state['blockers'] : [];
781
+ const payload = {
782
+ goal: typeof state['goal'] === 'string' ? state['goal'] : null,
783
+ completed: progress.length,
784
+ next: nextSteps.slice(0, SUMMARY_NEXT_PREVIEW),
785
+ blockers,
786
+ suggestion: nextSteps.length > 0 ? nextSteps[0] : 'set next_steps via state.patch',
284
787
  };
285
- return this.textResult(redactSecrets(JSON.stringify(metrics)));
788
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
286
789
  }
287
790
  /* ------------------------------------------------------------------ */
288
791
  /* State helpers */
289
792
  /* ------------------------------------------------------------------ */
290
793
  /** Resolve the target state file path (args override the defaults). */
291
794
  resolveStore(args) {
292
- const root = typeof args.root === 'string' ? args.root : this.options.root;
293
- const name = typeof args.name === 'string' ? args.name : this.options.name;
294
- return resolveStatePath(root, name);
795
+ return this.resolveRef(args).filePath;
796
+ }
797
+ /**
798
+ * The effective agent scope for a call: `{ agent }` wins, then the
799
+ * server default ({@link McpServerOptions.agent} — set from the
800
+ * `SKILLSTATE_AGENT_ID` env by {@link launch}). `''` = main agent.
801
+ */
802
+ effectiveAgent(args) {
803
+ const raw = typeof args['agent'] === 'string' && args['agent'].length > 0
804
+ ? args['agent']
805
+ : this.options.agent ?? '';
806
+ if (raw.length === 0)
807
+ return '';
808
+ const sanitized = sanitizeAgentId(raw);
809
+ if (sanitized.length === 0) {
810
+ throw new Error(`Invalid agent id: ${raw}`);
811
+ }
812
+ return sanitized;
813
+ }
814
+ /** Resolve `{ root, name, agent }` + the confined file path in one go. */
815
+ resolveRef(args) {
816
+ const root = typeof args['root'] === 'string' ? args['root'] : this.options.root;
817
+ const name = typeof args['name'] === 'string' ? args['name'] : this.options.name;
818
+ const agent = this.effectiveAgent(args);
819
+ const filePath = agent.length > 0
820
+ ? resolveStatePath(root, path.join('agents', agent, name))
821
+ : resolveStatePath(root, name);
822
+ return { root, name, agent, filePath };
823
+ }
824
+ /** Required + sanitized `{ agent }` for the agent.* tools. */
825
+ requireAgent(args) {
826
+ const raw = args['agent'];
827
+ if (typeof raw !== 'string' || raw.length === 0) {
828
+ throw new Error('agent is required: pass the sub-agent id from agent.list');
829
+ }
830
+ const sanitized = sanitizeAgentId(raw);
831
+ if (sanitized.length === 0) {
832
+ throw new Error(`Invalid agent id: ${raw}`);
833
+ }
834
+ return sanitized;
835
+ }
836
+ /** State file path for a REQUIRED sub-agent id (read-only views). */
837
+ agentStore(args) {
838
+ const root = typeof args['root'] === 'string' ? args['root'] : this.options.root;
839
+ const name = typeof args['name'] === 'string' ? args['name'] : this.options.name;
840
+ return resolveStatePath(root, path.join('agents', this.requireAgent(args), name));
841
+ }
842
+ /**
843
+ * `agent.list`: scan `<root>/agents/` and project each sub-agent state
844
+ * copy — id, statePath, exists, lastModified, lifecycle (status,
845
+ * lastActivityAt, staleness, ageMs) and a LIGHT summary (top-level keys
846
+ * + size only, no values). Non-directory entries and ids outside
847
+ * `[A-Za-z0-9_-]{1,64}` are skipped; a missing agents directory yields
848
+ * an empty list. The lifecycle comes from the agent dir's
849
+ * `.session-meta.json` sidecar: `orphan` when it is missing/corrupt,
850
+ * `stale` when a `running` session has not written anything for the
851
+ * core `STALE_MS` threshold (5 min — the provider died without a
852
+ * signal), `active` otherwise. A `running` agent reports its `ageMs`
853
+ * since the last
854
+ * activity so the main agent can tell "finished" from "died mid-run"
855
+ * at a glance.
856
+ */
857
+ agentList() {
858
+ const agentsDir = path.join(this.options.root, 'agents');
859
+ let entries;
860
+ try {
861
+ entries = fs.readdirSync(agentsDir, { withFileTypes: true });
862
+ }
863
+ catch {
864
+ entries = [];
865
+ }
866
+ const agents = [];
867
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
868
+ if (!entry.isDirectory() || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.name)) {
869
+ continue;
870
+ }
871
+ const statePath = path.join(agentsDir, entry.name, this.options.name);
872
+ const meta = readSessionMeta(path.join(agentsDir, entry.name));
873
+ let exists = false;
874
+ let lastModified = null;
875
+ let summary;
876
+ try {
877
+ const stat = fs.statSync(statePath);
878
+ exists = true;
879
+ lastModified = new Date(stat.mtimeMs).toISOString();
880
+ summary = agentSummary(this.loadState(statePath));
881
+ }
882
+ catch {
883
+ // No state file for this agent yet — listed with exists: false.
884
+ }
885
+ const agent = {
886
+ id: entry.name,
887
+ statePath,
888
+ exists,
889
+ lastModified,
890
+ status: meta?.status ?? null,
891
+ lastActivityAt: meta?.lastActivityAt ?? null,
892
+ staleness: sessionStaleness(meta),
893
+ };
894
+ if (meta?.status === 'running' && typeof meta.lastActivityAt === 'string') {
895
+ agent['ageMs'] = Date.now() - Date.parse(meta.lastActivityAt);
896
+ }
897
+ if (summary !== undefined) {
898
+ agent['summary'] = summary;
899
+ }
900
+ agents.push(agent);
901
+ }
902
+ return this.textResult(redactSecrets(JSON.stringify({ agents })));
903
+ }
904
+ /** `agent.read`: a sub-agent's state, READ-ONLY (the main agent peeks). */
905
+ agentRead(args) {
906
+ const statePath = this.agentStore(args);
907
+ return this.textResult(redactSecrets(JSON.stringify({ agent: this.requireAgent(args), statePath, state: this.loadState(statePath) })));
908
+ }
909
+ /**
910
+ * `agent.merge`: fold a sub-agent's state into the MAIN state under the
911
+ * cross-process lock (conflicting scalars resolved by `keep: 'main'` —
912
+ * the default — or `'sub'`; nested objects recurse; `null` deletes).
913
+ * The sub state is NOT deleted (history): it is marked with `mergedAt`.
914
+ * Returns `{ agent, keep, state, changes }`.
915
+ */
916
+ async agentMerge(args) {
917
+ const agent = this.requireAgent(args);
918
+ const keep = args['keep'] === 'sub' ? 'sub' : 'main';
919
+ const ref = this.resolveRef({});
920
+ const subPath = resolveStatePath(ref.root, path.join('agents', agent, ref.name));
921
+ const { merged, changes } = await withStateLock(ref.filePath, () => {
922
+ const main = this.loadState(ref.filePath);
923
+ if (this.readBaseline(ref.filePath) === null) {
924
+ this.writeBaseline(ref.filePath, main);
925
+ }
926
+ const sub = this.loadState(subPath);
927
+ const after = mergeState(main, resolveAgentMergeConflicts(main, sub, keep, Object.fromEntries(Object.entries(this.options.spec.schema).map(([key, field]) => [key, field.default]))));
928
+ this.writeState(ref.filePath, after);
929
+ this.bumpWriteSeq(ref.filePath);
930
+ return { merged: after, changes: topChanges(main, after) };
931
+ });
932
+ await withStateLock(subPath, () => {
933
+ const sub = this.loadState(subPath);
934
+ sub['mergedAt'] = new Date().toISOString();
935
+ this.writeState(subPath, sub);
936
+ });
937
+ // Lifecycle: the sub-agent copy is folded — mark its sidecar `merged`
938
+ // (best-effort; the merge result above stays authoritative).
939
+ try {
940
+ await writeSessionMeta(path.dirname(subPath), {
941
+ status: 'merged',
942
+ mergedAt: new Date().toISOString(),
943
+ });
944
+ }
945
+ catch {
946
+ // A broken sidecar never fails a completed merge.
947
+ }
948
+ return this.textResult(redactSecrets(JSON.stringify({ agent, keep, state: merged, changes })));
295
949
  }
296
950
  /** Read + normalize the state, falling back to schema defaults. */
297
951
  loadState(filePath) {
@@ -303,14 +957,21 @@ export class McpServer {
303
957
  return createInitialState(this.options.spec.schema);
304
958
  }
305
959
  }
306
- /** Crash-safe synchronous write: temp sibling + fsync + rename. */
960
+ /** Advance the per-path session write counter. */
961
+ bumpWriteSeq(filePath) {
962
+ this.writeSeq.set(filePath, (this.writeSeq.get(filePath) ?? 0) + 1);
963
+ }
964
+ /**
965
+ * Crash-safe synchronous write of the versioned envelope
966
+ * `{ version, state }`: temp sibling + fsync + rename.
967
+ */
307
968
  writeState(filePath, state) {
308
969
  const dir = path.dirname(filePath);
309
970
  fs.mkdirSync(dir, { recursive: true });
310
971
  const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
311
972
  const fd = fs.openSync(tmp, 'w');
312
973
  try {
313
- fs.writeSync(fd, JSON.stringify(state, null, 2));
974
+ fs.writeSync(fd, JSON.stringify({ version: CURRENT_STATE_VERSION, state }, null, 2));
314
975
  fs.fsyncSync(fd);
315
976
  }
316
977
  finally {
@@ -318,66 +979,187 @@ export class McpServer {
318
979
  }
319
980
  fs.renameSync(tmp, filePath);
320
981
  }
982
+ /**
983
+ * The DIFF BASELINE for a state file, persisted at
984
+ * `<stateDir>/.diff-baseline.json` (stateDir = the state file's
985
+ * directory — per state file, since agent scopes live in their own
986
+ * `agents/<id>/` directories). `null` = no baseline yet.
987
+ */
988
+ readBaseline(filePath) {
989
+ try {
990
+ const parsed = JSON.parse(fs.readFileSync(this.baselinePathFor(filePath), 'utf-8'));
991
+ return isPlainObject(parsed) ? parsed : null;
992
+ }
993
+ catch {
994
+ return null;
995
+ }
996
+ }
997
+ /** Crash-safe synchronous write of the diff baseline (atomic rename). */
998
+ writeBaseline(filePath, state) {
999
+ const target = this.baselinePathFor(filePath);
1000
+ const dir = path.dirname(target);
1001
+ fs.mkdirSync(dir, { recursive: true });
1002
+ const tmp = `${target}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
1003
+ const fd = fs.openSync(tmp, 'w');
1004
+ try {
1005
+ fs.writeSync(fd, JSON.stringify(state, null, 2));
1006
+ fs.fsyncSync(fd);
1007
+ }
1008
+ finally {
1009
+ fs.closeSync(fd);
1010
+ }
1011
+ fs.renameSync(tmp, target);
1012
+ }
1013
+ /** `.diff-baseline.json` lives next to its state file. */
1014
+ baselinePathFor(filePath) {
1015
+ return path.join(path.dirname(filePath), '.diff-baseline.json');
1016
+ }
321
1017
  /* ------------------------------------------------------------------ */
322
1018
  /* Tool / resource schemas */
323
1019
  /* ------------------------------------------------------------------ */
324
1020
  toolsList() {
325
- const stringProp = (desc) => ({
326
- type: 'string',
327
- description: desc,
328
- });
1021
+ const stateTargetProps = {
1022
+ root: { type: 'string', description: 'Optional state root directory override.' },
1023
+ name: { type: 'string', description: 'Optional state file name override.' },
1024
+ agent: {
1025
+ type: 'string',
1026
+ description: "Optional agent scope: targets agents/<id>/skillstate.json (sanitized [A-Za-z0-9_-], <=64). Omit for the main agent; the server default comes from SKILLSTATE_AGENT_ID.",
1027
+ },
1028
+ };
329
1029
  return [
330
1030
  {
331
1031
  name: 'state.get',
332
- description: 'Read the current skill state (secrets redacted).',
1032
+ description: 'Read the FULL execution state as JSON (secrets redacted). Use when you need every field; for a quick orientation use state.summary instead.',
1033
+ inputSchema: { type: 'object', properties: { ...stateTargetProps } },
1034
+ annotations: { readOnlyHint: true, destructiveHint: false },
1035
+ },
1036
+ {
1037
+ name: 'state.patch',
1038
+ description: 'THE write operation: apply a sparse patch to the execution state and persist it. Always validates against the spec schema first (invalid patches are rejected with the offending field, nothing is written); null deletes a key, nested objects merge recursively. Returns { state, changes: { added, updated, deleted }, warnings } — e.g. {"patch":{"working_dir":"/tmp"}} → changes.updated=["working_dir"]. Dry-run risky patches with state.validate first.',
333
1039
  inputSchema: {
334
1040
  type: 'object',
335
- properties: { name: stringProp('Optional alternate state file name.') },
1041
+ properties: { patch: { type: 'object', description: 'Sparse patch; null deletes a key.' }, ...stateTargetProps },
1042
+ required: ['patch'],
336
1043
  },
1044
+ annotations: { readOnlyHint: false, destructiveHint: false },
337
1045
  },
338
1046
  {
339
- name: 'state.patch',
340
- description: 'Apply a sparse patch (null deletes a key) to the state and persist it.',
1047
+ name: 'state.validate',
1048
+ description: 'Dry-run a patch WITHOUT writing: validates it against the spec schema. Returns { valid: true } or { valid: false, error, field }. Use before state.patch to check a complex or risky patch.',
1049
+ inputSchema: {
1050
+ type: 'object',
1051
+ properties: { patch: { type: 'object', description: 'The patch to check.' } },
1052
+ required: ['patch'],
1053
+ },
1054
+ annotations: { readOnlyHint: true, destructiveHint: false },
1055
+ },
1056
+ {
1057
+ name: 'state.diff',
1058
+ description: "Show what changed in the state since your last state.diff call: top-level { added, updated, deleted }; pass { full: true } to also get the complete before/after states. Use after an action to review your own progress; empty arrays mean no change.",
341
1059
  inputSchema: {
342
1060
  type: 'object',
343
1061
  properties: {
344
- patch: { type: 'object' },
345
- root: stringProp('Optional state root directory.'),
346
- name: stringProp('Optional state file name.'),
1062
+ full: { type: 'boolean', description: 'Also return the full before/after states.' },
1063
+ ...stateTargetProps,
347
1064
  },
348
- required: ['patch'],
349
1065
  },
1066
+ annotations: { readOnlyHint: true, destructiveHint: false },
1067
+ },
1068
+ {
1069
+ name: 'state.checkpoint',
1070
+ description: 'Save a named snapshot of the current state that state.rollback can restore. Returns { checkpointId, seq, label, checkpoints } with the full list of existing checkpoints. Use before risky operations. Example: {"label":"before-refactor"}.',
1071
+ inputSchema: {
1072
+ type: 'object',
1073
+ properties: { label: { type: 'string', description: 'Short name for the snapshot.' }, ...stateTargetProps },
1074
+ },
1075
+ annotations: { readOnlyHint: false, destructiveHint: false },
350
1076
  },
351
1077
  {
352
- name: 'state.merge',
353
- description: 'Schema-validated patch: validate then apply the ⊕ merge and persist.',
1078
+ name: 'state.rollback',
1079
+ description: 'Restore the state from a checkpoint sidecar (omit checkpointId to roll back to the most recent one). DESTRUCTIVE: overwrites the current state with the snapshot. Returns { state, checkpointId }.',
354
1080
  inputSchema: {
355
1081
  type: 'object',
356
1082
  properties: {
357
- patch: { type: 'object' },
358
- root: stringProp('Optional state root directory.'),
359
- name: stringProp('Optional state file name.'),
1083
+ checkpointId: { type: 'string', description: 'Id from state.checkpoint; omit for the latest.' },
1084
+ ...stateTargetProps,
360
1085
  },
361
- required: ['patch'],
362
1086
  },
1087
+ annotations: { readOnlyHint: false, destructiveHint: true },
1088
+ },
1089
+ {
1090
+ name: 'state.summary',
1091
+ description: 'Compact orientation over the state: goal, progress/next_steps/artifacts/blockers counts, the first 3 next steps, notes (truncated to 200 chars), state size, and session info (statePath, envelope version, protocolVersion, seq = writes applied through this session, plus the lifecycle status/staleness from the .session-meta.json sidecar). Use this instead of state.get for fast context.',
1092
+ inputSchema: { type: 'object', properties: { ...stateTargetProps } },
1093
+ annotations: { readOnlyHint: true, destructiveHint: false },
363
1094
  },
364
1095
  {
365
- name: 'state.reset',
366
- description: 'Reset the state to the schema defaults.',
1096
+ name: 'state.metrics',
1097
+ description: 'Session metrics: accuracy (accepted patches / actionable steps), averagePromptSize (mean prompt chars per call), and totalTokens (cumulative prompt+response chars). Errors when no steps have been recorded.',
1098
+ inputSchema: { type: 'object', properties: {} },
1099
+ annotations: { readOnlyHint: true, destructiveHint: false },
1100
+ },
1101
+ {
1102
+ name: 'state.finalize',
1103
+ description: "Signal that YOUR task is done: writes the session lifecycle status ('completed' or 'failed', optional free-text result) to the .session-meta.json sidecar so the orchestrator/agent.list sees a finished session instead of a running/interrupted one. Call it once at the very end of the procedure — not mid-run.",
367
1104
  inputSchema: {
368
1105
  type: 'object',
369
- properties: { name: stringProp('Optional alternate state file name.') },
1106
+ properties: {
1107
+ status: {
1108
+ type: 'string',
1109
+ enum: ['completed', 'failed'],
1110
+ description: "'completed' when the procedure finished, 'failed' when it gave up.",
1111
+ },
1112
+ result: { type: 'string', description: 'Optional one-line outcome summary.' },
1113
+ ...stateTargetProps,
1114
+ },
1115
+ required: ['status'],
370
1116
  },
1117
+ annotations: { readOnlyHint: false, destructiveHint: false },
371
1118
  },
372
1119
  {
373
1120
  name: 'spec.get',
374
- description: 'Return the procedural spec (id, name, version, schema).',
375
- inputSchema: { type: 'object' },
1121
+ description: 'The procedural spec: id, name, version, instructions, the state schema, and a ready-made example_state_patch that passes validation. Use it to learn which keys and types the state accepts.',
1122
+ inputSchema: { type: 'object', properties: {} },
1123
+ annotations: { readOnlyHint: true, destructiveHint: false },
376
1124
  },
377
1125
  {
378
- name: 'state.metrics',
379
- description: 'Return the paper §4.3 metrics readout from the token tracker.',
380
- inputSchema: { type: 'object' },
1126
+ name: 'spec.next',
1127
+ description: 'What to do next, derived from the state: { goal, completed (progress count), next (first 3 next_steps), blockers, suggestion }. Use at the start of a step; when next_steps is empty the suggestion tells you to set it via state.patch.',
1128
+ inputSchema: { type: 'object', properties: { ...stateTargetProps } },
1129
+ annotations: { readOnlyHint: true, destructiveHint: false },
1130
+ },
1131
+ {
1132
+ name: 'agent.list',
1133
+ description: "List sub-agent state copies: scans <stateDir>/agents/ and returns { agents: [{ id, statePath, exists, status, lastActivityAt, staleness ('active' | 'stale' = running with no writes for 5min | 'orphan' = no sidecar), ageMs (running sessions), summary (top-level keys + size, no values), lastModified }] }. Use it to discover parallel sub-agent sessions (hook session ids), what they touched, and whether they finished (status completed/merged), died (stale/interrupted), or are still alive.",
1134
+ inputSchema: { type: 'object', properties: {} },
1135
+ annotations: { readOnlyHint: true, destructiveHint: false },
1136
+ },
1137
+ {
1138
+ name: 'agent.read',
1139
+ description: "Read a sub-agent's state (READ-ONLY): { agent, statePath, state }. Pass { agent: \"<id from agent.list>\" }. Use it to see what a parallel sub-agent is doing before merging its work.",
1140
+ inputSchema: {
1141
+ type: 'object',
1142
+ properties: { agent: { type: 'string', description: 'Sub-agent id from agent.list.' } },
1143
+ required: ['agent'],
1144
+ },
1145
+ annotations: { readOnlyHint: true, destructiveHint: false },
1146
+ },
1147
+ {
1148
+ name: 'agent.merge',
1149
+ description: "Merge a sub-agent's state copy into the MAIN state (under the cross-process lock): keys only in the sub state are taken, nested plain objects merge recursively, conflicting scalars follow keep: 'main' (default — the main value wins) or 'sub'. Returns { agent, keep, state, changes }. The sub state is NOT deleted — it is marked mergedAt (history) and its session sidecar flips to status 'merged'.",
1150
+ inputSchema: {
1151
+ type: 'object',
1152
+ properties: {
1153
+ agent: { type: 'string', description: 'Sub-agent id from agent.list.' },
1154
+ keep: {
1155
+ type: 'string',
1156
+ enum: ['main', 'sub'],
1157
+ description: "Conflict policy for scalars (default 'main').",
1158
+ },
1159
+ },
1160
+ required: ['agent'],
1161
+ },
1162
+ annotations: { readOnlyHint: false, destructiveHint: false },
381
1163
  },
382
1164
  ];
383
1165
  }
@@ -386,50 +1168,24 @@ export class McpServer {
386
1168
  {
387
1169
  uri: 'skillstate://state',
388
1170
  name: 'Skill State',
1171
+ description: 'The full versioned state envelope ({ version, state }).',
1172
+ mimeType: 'application/json',
1173
+ },
1174
+ {
1175
+ uri: 'skillstate://spec',
1176
+ name: 'Procedural Spec',
1177
+ description: 'The procedural spec: id, name, version, instructions, schema.',
1178
+ mimeType: 'application/json',
1179
+ },
1180
+ {
1181
+ uri: 'skillstate://summary',
1182
+ name: 'State Summary',
1183
+ description: 'Compact summary projection of the current state.',
389
1184
  mimeType: 'application/json',
390
1185
  },
391
1186
  ];
392
1187
  }
393
1188
  /* ------------------------------------------------------------------ */
394
- /* Frame codec */
395
- /* ------------------------------------------------------------------ */
396
- /**
397
- * Parse a `Content-Length`-framed message from the front of the buffer
398
- * (the caller has already asserted the buffer begins with a valid
399
- * `Content-Length:` header). Returns the body + consumed length, or
400
- * `'incomplete'` if the payload has not all arrived.
401
- */
402
- takeContentLengthFrame() {
403
- const match = this.buffer.match(/^Content-Length:\s*(\d+)/i);
404
- const crlf = this.buffer.indexOf('\r\n\r\n');
405
- let headerEnd;
406
- if (crlf !== -1) {
407
- headerEnd = crlf + 4;
408
- }
409
- else {
410
- const lf = this.buffer.indexOf('\n\n');
411
- if (lf === -1) {
412
- return 'incomplete';
413
- }
414
- headerEnd = lf + 2;
415
- }
416
- const length = Number(match[1]);
417
- if (this.buffer.length - headerEnd < length) {
418
- return 'incomplete';
419
- }
420
- return {
421
- body: this.buffer.slice(headerEnd, headerEnd + length),
422
- consumed: headerEnd + length,
423
- };
424
- }
425
- /** Encode a response in the given framing mode. */
426
- encodeResponse(text, mode) {
427
- if (mode === 'content-length') {
428
- return `Content-Length: ${Buffer.byteLength(text, 'utf-8')}\r\n\r\n${text}`;
429
- }
430
- return `${text}\n`;
431
- }
432
- /* ------------------------------------------------------------------ */
433
1189
  /* JSON-RPC response builders */
434
1190
  /* ------------------------------------------------------------------ */
435
1191
  successResponse(id, result) {
@@ -446,8 +1202,51 @@ export class McpServer {
446
1202
  return { content: [{ type: 'text', text }] };
447
1203
  }
448
1204
  }
449
- function isPlainObject(value) {
450
- return typeof value === 'object' && value !== null && !Array.isArray(value);
1205
+ /** Sidecar catalog directory for a state file: `<stateDir>/checkpoints`. */
1206
+ function checkpointsDir(filePath) {
1207
+ return path.join(path.dirname(filePath), 'checkpoints');
1208
+ }
1209
+ /** Next checkpoint sequence number: max existing sidecar seq + 1 (from 1). */
1210
+ function nextCheckpointSeq(dir) {
1211
+ const checkpoints = listCheckpoints(dir);
1212
+ return (checkpoints.length > 0 ? checkpoints[checkpoints.length - 1].seq : 0) + 1;
1213
+ }
1214
+ /**
1215
+ * List the checkpoint sidecars in `dir`, oldest first. Unreadable or
1216
+ * malformed entries are skipped; a missing directory yields an empty list.
1217
+ */
1218
+ function listCheckpoints(dir) {
1219
+ let entries;
1220
+ try {
1221
+ entries = fs.readdirSync(dir);
1222
+ }
1223
+ catch {
1224
+ entries = [];
1225
+ }
1226
+ const found = [];
1227
+ for (const entry of entries) {
1228
+ if (!entry.endsWith('.json')) {
1229
+ continue;
1230
+ }
1231
+ try {
1232
+ const record = JSON.parse(fs.readFileSync(path.join(dir, entry), 'utf-8'));
1233
+ if (typeof record.checkpointId === 'string' &&
1234
+ typeof record.seq === 'number' &&
1235
+ typeof record.label === 'string' &&
1236
+ typeof record.createdAt === 'string') {
1237
+ found.push({
1238
+ checkpointId: record.checkpointId,
1239
+ seq: record.seq,
1240
+ label: record.label,
1241
+ createdAt: record.createdAt,
1242
+ });
1243
+ }
1244
+ }
1245
+ catch {
1246
+ // Unreadable sidecar — skip, never fail the listing.
1247
+ }
1248
+ }
1249
+ return found.sort((a, b) => a.seq - b.seq);
451
1250
  }
452
1251
  /**
453
1252
  * Resolve the procedural spec for a launch: explicit `args.spec` wins, then
@@ -465,10 +1264,9 @@ function resolveSpec(args, env) {
465
1264
  return INTERCODE_CTF_SPEC;
466
1265
  }
467
1266
  /**
468
- * Per-project state resolution for an MCP server session. Semantics are a
469
- * zero-dep mirror of `resolveStatePathForCwd` in `@skillstate/opencode`
470
- * (kept local: `@skillstate/mcp` depends only on `@skillstate/core` —
471
- * keep the two in sync):
1267
+ * Per-project state resolution for an MCP server session — re-exported
1268
+ * from `@skillstate/core` (the single source of truth shared with the
1269
+ * OpenCode plugin and the generated hook scripts):
472
1270
  *
473
1271
  * - `cwd === home` — no single project → the global bucket
474
1272
  * `<home>/.skillstate/global/skillstate.json`;
@@ -476,35 +1274,69 @@ function resolveSpec(args, env) {
476
1274
  *
477
1275
  * Pure path arithmetic (no filesystem access, `path.resolve` normalization).
478
1276
  */
479
- export function resolveStatePathForCwd(cwd, home) {
480
- const resolvedCwd = path.resolve(cwd);
481
- const resolvedHome = path.resolve(home);
482
- if (resolvedCwd === resolvedHome) {
483
- return path.join(resolvedHome, '.skillstate', 'global', 'skillstate.json');
484
- }
485
- return path.join(resolvedCwd, '.skillstate', 'skillstate.json');
486
- }
1277
+ export { resolveHostStateForCwd as resolveStatePathForCwd } from '@skillstate/core';
487
1278
  /**
488
1279
  * Launch an MCP server from an argument/env config (reads
489
1280
  * `SKILLSTATE_SPEC_PATH` when not passed explicitly). State resolution is
490
1281
  * ALWAYS per-project from the server's `process.cwd()`:
491
1282
  * `<cwd>/.skillstate/skillstate.json` (the global bucket when cwd === home).
492
- * Hosts that launch local MCP servers with the project as cwd therefore get
493
- * per-project state without any baked path. Explicit `args.root`/`args.name`
494
- * remain available for in-process embedding. Defaults to the canonical
495
- * InterCode CTF spec.
1283
+ * AGENT SCOPE: a non-empty `SKILLSTATE_AGENT_ID` env (or `args.agent`)
1284
+ * scopes the default state file to `agents/<id>/skillstate.json` — host
1285
+ * configs set the env per server instance when a sub-agent needs an
1286
+ * isolated copy; the default is `''` (the main agent) and every tool call
1287
+ * can still override via `{ agent }`. Hosts that launch local MCP servers
1288
+ * with the project as cwd therefore get per-project state without any
1289
+ * baked path. Explicit `args.root`/`args.name` remain available for
1290
+ * in-process embedding. Defaults to the canonical InterCode CTF spec.
1291
+ *
1292
+ * SESSION LIFECYCLE (release 2.3.0): launch stamps the session sidecar
1293
+ * `<stateDir>/.session-meta.json` with `{ status: 'running', startedAt,
1294
+ * agentId, protocolVersion }` — overwriting any previous
1295
+ * `interrupted`/`completed` marker (a new launch means a fresh run) — and
1296
+ * wires the SIGINT/SIGTERM handler that flushes
1297
+ * `{ status: 'interrupted' }` + the diff baseline before exiting. The
1298
+ * agent is expected to call `state.finalize` at the end of its procedure.
1299
+ * In-process embedders that do not own the process can pass
1300
+ * `installInterruptHandler: false` (tests) or call
1301
+ * `server.detachInterruptHandler()` afterwards.
496
1302
  */
497
1303
  export async function launch(args) {
498
1304
  const spec = resolveSpec(args, process.env);
499
- const statePath = resolveStatePathForCwd(process.cwd(), os.homedir());
1305
+ const statePath = resolveHostStateForCwd(process.cwd(), os.homedir());
500
1306
  const root = args?.root ?? path.dirname(statePath);
501
1307
  const name = args?.name ?? path.basename(statePath);
1308
+ const agentEnv = process.env['SKILLSTATE_AGENT_ID'];
1309
+ const agent = args?.agent ??
1310
+ (typeof agentEnv === 'string' && agentEnv.length > 0 ? agentEnv : '');
1311
+ const sanitizedAgent = agent.length > 0 ? sanitizeAgentId(agent) : '';
502
1312
  const server = new McpServer({
503
1313
  spec,
504
1314
  root,
505
1315
  name,
1316
+ agent,
506
1317
  tracker: args?.tracker,
507
1318
  });
1319
+ // Lifecycle: a live launch means this session is running NOW. The
1320
+ // sidecar lives next to the DEFAULT state file (the agent's own
1321
+ // directory when the launch is agent-scoped). Written through the core
1322
+ // meta API (merge under the meta lock, atomic write).
1323
+ try {
1324
+ const defaultFilePath = sanitizedAgent.length > 0
1325
+ ? resolveStatePath(root, path.join('agents', sanitizedAgent, name))
1326
+ : resolveStatePath(root, name);
1327
+ await writeSessionMeta(path.dirname(defaultFilePath), {
1328
+ status: 'running',
1329
+ startedAt: new Date().toISOString(),
1330
+ agentId: sanitizedAgent,
1331
+ protocolVersion: PROTOCOL_VERSION,
1332
+ });
1333
+ }
1334
+ catch {
1335
+ // Best-effort stamp: an unwritable sidecar must not block the server.
1336
+ }
1337
+ if (args?.installInterruptHandler !== false) {
1338
+ server.installInterruptHandler();
1339
+ }
508
1340
  return server.start(args?.input, args?.output);
509
1341
  }
510
1342
  //# sourceMappingURL=mcp-server.js.map