@skillstate/mcp 2.0.7 → 2.1.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,206 @@
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, redactSecrets, resolveHostStateForCwd, resolveStatePath, validatePatchDeep, CURRENT_STATE_VERSION, } from '@skillstate/core';
29
30
  import { INTERCODE_CTF_SPEC } from '@skillstate/core/schemas';
31
+ /** The single MCP protocol revision this server speaks (initialize answer). */
32
+ export const PROTOCOL_VERSION = '2026-07-28';
33
+ /** `notes` is truncated to this many chars in summary projections. */
34
+ const SUMMARY_NOTES_MAX_CHARS = 200;
35
+ /** How many `next_steps` entries the summary/spec.next projections keep. */
36
+ const SUMMARY_NEXT_PREVIEW = 3;
37
+ function isPlainObject(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+ /** JSON-ish kind name used in summary type maps. */
41
+ function kindOf(value) {
42
+ return Array.isArray(value) ? 'array' : typeof value;
43
+ }
44
+ /**
45
+ * Top-level diff between two states: `added` (only in `after`), `deleted`
46
+ * (only in `before`), `updated` (in both, different JSON). Pure.
47
+ */
48
+ function topChanges(before, after) {
49
+ const added = [];
50
+ const updated = [];
51
+ const deleted = [];
52
+ for (const key of Object.keys(after)) {
53
+ if (!(key in before)) {
54
+ added.push(key);
55
+ }
56
+ }
57
+ for (const key of Object.keys(before)) {
58
+ if (!(key in after)) {
59
+ deleted.push(key);
60
+ }
61
+ else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
62
+ updated.push(key);
63
+ }
64
+ }
65
+ return { added, updated, deleted };
66
+ }
67
+ /**
68
+ * Warnings for deep merge conflicts: a patch key whose value is an object
69
+ * merged into an existing object (rather than replacing it).
70
+ */
71
+ function nestedMergeWarnings(before, patch) {
72
+ const warnings = [];
73
+ for (const [key, value] of Object.entries(patch)) {
74
+ if (isPlainObject(value) && isPlainObject(before[key])) {
75
+ warnings.push(`nested merge under '${key}': the patch object was merged into the existing object (set nested keys to null to delete them)`);
76
+ }
77
+ }
78
+ return warnings;
79
+ }
80
+ /**
81
+ * Compact state projection shared by `state.summary` and
82
+ * `skillstate://summary`. Recognizes the generic-procedure fields
83
+ * (goal/progress/next_steps/artifacts/blockers/notes) and degrades to a
84
+ * keys+types+size listing for schemas without them. Never CTF-specific.
85
+ */
86
+ function buildSummary(state) {
87
+ const projection = {};
88
+ const other = {};
89
+ let generic = false;
90
+ for (const [key, value] of Object.entries(state)) {
91
+ if (key === 'goal' && typeof value === 'string') {
92
+ projection['goal'] = value;
93
+ generic = true;
94
+ }
95
+ else if (key === 'notes' && typeof value === 'string') {
96
+ projection['notes'] =
97
+ value.length > SUMMARY_NOTES_MAX_CHARS
98
+ ? `${value.slice(0, SUMMARY_NOTES_MAX_CHARS)}…`
99
+ : value;
100
+ generic = true;
101
+ }
102
+ else if ((key === 'progress' || key === 'next_steps' || key === 'artifacts' || key === 'blockers') &&
103
+ Array.isArray(value)) {
104
+ projection[key] =
105
+ key === 'next_steps'
106
+ ? { count: value.length, first: value.slice(0, SUMMARY_NEXT_PREVIEW) }
107
+ : { count: value.length };
108
+ generic = true;
109
+ }
110
+ else {
111
+ other[key] = kindOf(value);
112
+ }
113
+ }
114
+ if (!generic) {
115
+ return { keys: other, size_bytes: Buffer.byteLength(JSON.stringify(state), 'utf-8') };
116
+ }
117
+ if (Object.keys(other).length > 0) {
118
+ projection['other'] = other;
119
+ }
120
+ projection['size_bytes'] = Buffer.byteLength(JSON.stringify(state), 'utf-8');
121
+ return projection;
122
+ }
123
+ /** Sensible placeholder values for the generic-procedure schema fields. */
124
+ const GENERIC_EXAMPLE_VALUES = {
125
+ goal: 'Describe what the procedure is trying to achieve',
126
+ progress: ['Completed milestone'],
127
+ next_steps: ['Next action to take'],
128
+ artifacts: ['path/to/artifact'],
129
+ blockers: [],
130
+ notes: 'Working notes persisted between steps',
131
+ };
132
+ /** JSON defaults per schema type, so generated examples always validate. */
133
+ const TYPE_DEFAULTS = {
134
+ string: '',
135
+ number: 0,
136
+ boolean: false,
137
+ array: [],
138
+ object: {},
139
+ };
140
+ /**
141
+ * Build a ready-to-use example patch from a schema: every key with a
142
+ * generic placeholder or a type default. The result passes
143
+ * `validatePatchDeep` against the same schema by construction.
144
+ */
145
+ function buildExamplePatch(schema) {
146
+ const example = {};
147
+ for (const [key, field] of Object.entries(schema)) {
148
+ example[key] = key in GENERIC_EXAMPLE_VALUES
149
+ ? GENERIC_EXAMPLE_VALUES[key]
150
+ : TYPE_DEFAULTS[field.type];
151
+ }
152
+ return example;
153
+ }
154
+ /** Filesystem-safe label: weird chars collapse to '-', empty → 'checkpoint'. */
155
+ function sanitizeLabel(raw) {
156
+ const cleaned = raw
157
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
158
+ .replace(/^-+|-+$/g, '')
159
+ .slice(0, 64);
160
+ return cleaned.length > 0 ? cleaned : 'checkpoint';
161
+ }
30
162
  /**
31
163
  * The `skillstate` MCP server: a JSON-RPC 2.0 over stdio server exposing
32
- * the skillstate runtime as MCP tools.
164
+ * the skillstate runtime as MCP tools and resources.
33
165
  */
34
166
  export class McpServer {
35
167
  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 } };
168
+ /** Protocol revision advertised on `initialize` — always exactly this. */
169
+ protocolVersion = PROTOCOL_VERSION;
170
+ /** Advertised server capabilities. */
171
+ capabilities = {
172
+ tools: { listChanged: true },
173
+ resources: {},
174
+ logging: {},
175
+ prompts: { listChanged: true },
176
+ };
40
177
  /** Advertised server identity. */
41
178
  serverInfo = { name: 'skillstate', version: '1.0.0' };
42
179
  buffer = '';
43
180
  running = false;
44
- frameMode = 'jsonl';
181
+ /** Serializes `start()` stream handling so chunk order is preserved. */
182
+ chain = Promise.resolve();
183
+ /**
184
+ * Diff baselines per resolved state path: the state as of the last
185
+ * `state.diff` call (or, before the first look, as of the first write).
186
+ */
187
+ prevStates = new Map();
188
+ /** Writes (patch/rollback) applied per resolved state path this session. */
189
+ writeSeq = new Map();
45
190
  constructor(options) {
46
191
  this.options = options;
47
192
  }
48
193
  /**
49
194
  * Process a single (already-framed) JSON-RPC message line and return the
50
195
  * 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.
196
+ * notification). The stateless unit entry point used by tests and the
197
+ * stdio transport.
53
198
  */
54
199
  handleLine(line) {
55
200
  const text = line.trim();
56
201
  if (text.length === 0) {
57
- return null;
202
+ return Promise.resolve(null);
58
203
  }
59
204
  return this.processRaw(text);
60
205
  }
61
206
  /**
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.
207
+ * Feed a raw chunk of stdin and return every newline-delimited response
208
+ * produced by the complete messages it contains. Partial lines are
209
+ * buffered until the rest arrives. Each response ends with a newline.
67
210
  */
68
- feed(chunk) {
211
+ async feed(chunk) {
69
212
  this.buffer += chunk;
70
213
  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
- }
214
+ for (;;) {
89
215
  const newline = this.buffer.indexOf('\n');
90
216
  if (newline === -1) {
91
217
  break;
@@ -95,19 +221,17 @@ export class McpServer {
95
221
  if (line.length === 0) {
96
222
  continue;
97
223
  }
98
- this.frameMode = 'jsonl';
99
- const response = this.processRaw(line);
224
+ const response = await this.processRaw(line);
100
225
  if (response !== null) {
101
- responses.push(this.encodeResponse(response, this.frameMode));
226
+ responses.push(`${response}\n`);
102
227
  }
103
228
  }
104
229
  return responses;
105
230
  }
106
231
  /**
107
232
  * 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.
233
+ * Resolves once the server is reading; `stop()` detaches it. Chunks are
234
+ * processed strictly in arrival order even though handling is async.
111
235
  */
112
236
  async start(input, output) {
113
237
  const source = input ?? process.stdin;
@@ -115,9 +239,7 @@ export class McpServer {
115
239
  this.running = true;
116
240
  source.on('data', (chunk) => {
117
241
  const text = typeof chunk === 'string' ? chunk : chunk.toString();
118
- for (const response of this.feed(text)) {
119
- sink.write(response);
120
- }
242
+ void this.pump(text, sink);
121
243
  });
122
244
  return this;
123
245
  }
@@ -132,6 +254,15 @@ export class McpServer {
132
254
  /* ------------------------------------------------------------------ */
133
255
  /* JSON-RPC dispatch */
134
256
  /* ------------------------------------------------------------------ */
257
+ /** Append one chunk to the ordered stream pipeline. */
258
+ async pump(text, sink) {
259
+ this.chain = this.chain.then(async () => {
260
+ for (const response of await this.feed(text)) {
261
+ sink.write(response);
262
+ }
263
+ });
264
+ await this.chain;
265
+ }
135
266
  /** Parse raw text into a message and dispatch; `-32700` on parse error. */
136
267
  processRaw(text) {
137
268
  let message;
@@ -139,13 +270,13 @@ export class McpServer {
139
270
  message = JSON.parse(text);
140
271
  }
141
272
  catch {
142
- return this.errorResponse(null, -32700, 'Parse error');
273
+ return Promise.resolve(this.errorResponse(null, -32700, 'Parse error'));
143
274
  }
144
275
  return this.processMessage(message);
145
276
  }
146
277
  processMessage(message) {
147
278
  if (typeof message !== 'object' || message === null || Array.isArray(message)) {
148
- return this.errorResponse(null, -32600, 'Invalid Request');
279
+ return Promise.resolve(this.errorResponse(null, -32600, 'Invalid Request'));
149
280
  }
150
281
  const msg = message;
151
282
  const id = 'id' in msg ? msg.id ?? null : null;
@@ -153,19 +284,19 @@ export class McpServer {
153
284
  const hasId = 'id' in msg;
154
285
  if (typeof method === 'string' && method.startsWith('notifications/')) {
155
286
  if (hasId) {
156
- return this.errorResponse(id, -32600, 'Invalid Request: notifications must not include an id');
287
+ return Promise.resolve(this.errorResponse(id, -32600, 'Invalid Request: notifications must not include an id'));
157
288
  }
158
- return null;
289
+ return Promise.resolve(null);
159
290
  }
160
291
  if (typeof method !== 'string' || method.length === 0) {
161
- return this.errorResponse(id, -32600, 'Invalid Request');
292
+ return Promise.resolve(this.errorResponse(id, -32600, 'Invalid Request'));
162
293
  }
163
294
  if (!hasId) {
164
- return null;
295
+ return Promise.resolve(null);
165
296
  }
166
297
  return this.handleRequest(id, method, msg.params);
167
298
  }
168
- handleRequest(id, method, params) {
299
+ async handleRequest(id, method, params) {
169
300
  switch (method) {
170
301
  case 'initialize':
171
302
  return this.successResponse(id, {
@@ -181,87 +312,251 @@ export class McpServer {
181
312
  return this.handleToolCall(id, params);
182
313
  case 'resources/list':
183
314
  return this.successResponse(id, { resources: this.resourcesList() });
315
+ case 'resources/read':
316
+ return this.handleResourceRead(id, params);
317
+ case 'prompts/list':
318
+ return this.successResponse(id, { prompts: [] });
319
+ case 'logging/setLevel':
320
+ return this.successResponse(id, {});
184
321
  default:
185
322
  return this.errorResponse(id, -32601, `Method not found: ${method}`);
186
323
  }
187
324
  }
188
- handleToolCall(id, params) {
325
+ async handleToolCall(id, params) {
189
326
  if (!isPlainObject(params)) {
190
327
  return this.errorResponse(id, -32602, 'Invalid params: expected an object');
191
328
  }
192
- const name = params.name;
329
+ const name = params['name'];
193
330
  if (typeof name !== 'string' || name.length === 0) {
194
331
  return this.errorResponse(id, -32602, 'Invalid params: name required');
195
332
  }
196
- const args = params.arguments;
333
+ const args = params['arguments'];
197
334
  const argsObj = isPlainObject(args) ? args : {};
198
335
  try {
199
- const result = this.callTool(name, argsObj);
336
+ const result = await this.callTool(name, argsObj);
200
337
  return this.successResponse(id, result);
201
338
  }
202
339
  catch (err) {
203
- const message = String(err);
204
340
  return this.successResponse(id, {
205
- content: [{ type: 'text', text: message }],
341
+ content: [{ type: 'text', text: redactSecrets(String(err)) }],
206
342
  isError: true,
207
343
  });
208
344
  }
209
345
  }
346
+ handleResourceRead(id, params) {
347
+ const uri = isPlainObject(params) && typeof params['uri'] === 'string'
348
+ ? params['uri']
349
+ : undefined;
350
+ if (uri === undefined) {
351
+ return this.errorResponse(id, -32602, 'Invalid params: uri required');
352
+ }
353
+ let text;
354
+ switch (uri) {
355
+ case 'skillstate://state': {
356
+ const state = this.loadState(this.resolveStore({}));
357
+ text = redactSecrets(JSON.stringify({ version: CURRENT_STATE_VERSION, state }));
358
+ break;
359
+ }
360
+ case 'skillstate://spec': {
361
+ const spec = this.options.spec;
362
+ text = redactSecrets(JSON.stringify({
363
+ id: spec.id,
364
+ name: spec.name,
365
+ version: spec.version,
366
+ instructions: spec.instructions,
367
+ schema: spec.schema,
368
+ }));
369
+ break;
370
+ }
371
+ case 'skillstate://summary': {
372
+ text = redactSecrets(JSON.stringify(buildSummary(this.loadState(this.resolveStore({})))));
373
+ break;
374
+ }
375
+ default:
376
+ return this.errorResponse(id, -32602, `Unknown resource: ${uri}`);
377
+ }
378
+ return this.successResponse(id, {
379
+ contents: [{ uri, mimeType: 'application/json', text }],
380
+ });
381
+ }
210
382
  /* ------------------------------------------------------------------ */
211
383
  /* Tool implementations */
212
384
  /* ------------------------------------------------------------------ */
213
- callTool(name, args) {
385
+ async callTool(name, args) {
214
386
  switch (name) {
215
387
  case 'state.get':
216
388
  return this.stateGet(args);
217
389
  case 'state.patch':
218
390
  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();
391
+ case 'state.validate':
392
+ return this.stateValidate(args);
393
+ case 'state.diff':
394
+ return this.stateDiff(args);
395
+ case 'state.checkpoint':
396
+ return this.stateCheckpoint(args);
397
+ case 'state.rollback':
398
+ return this.stateRollback(args);
399
+ case 'state.summary':
400
+ return this.stateSummary(args);
225
401
  case 'state.metrics':
226
402
  return this.stateMetrics();
403
+ case 'spec.get':
404
+ return this.specGet();
405
+ case 'spec.next':
406
+ return this.specNext(args);
227
407
  default:
228
408
  throw new Error(`Unknown tool: ${name}`);
229
409
  }
230
410
  }
231
411
  stateGet(args) {
232
- const filePath = this.resolveStore(args);
233
- const state = this.loadState(filePath);
412
+ const state = this.loadState(this.resolveStore(args));
234
413
  return this.textResult(redactSecrets(JSON.stringify(state)));
235
414
  }
236
415
  statePatch(args) {
237
- const patch = args.patch;
416
+ const patch = args['patch'];
238
417
  if (!isPlainObject(patch)) {
239
418
  throw new Error('patch must be an object');
240
419
  }
420
+ const validation = validatePatchDeep(this.options.spec.schema, patch);
421
+ if (!validation.valid) {
422
+ return {
423
+ content: [
424
+ {
425
+ type: 'text',
426
+ text: redactSecrets(JSON.stringify({ valid: false, error: validation.error, field: validation.field })),
427
+ },
428
+ ],
429
+ isError: true,
430
+ };
431
+ }
241
432
  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)));
433
+ const before = this.loadState(filePath);
434
+ const after = mergeState(before, patch);
435
+ this.writeState(filePath, after);
436
+ this.bumpWriteSeq(filePath);
437
+ if (!this.prevStates.has(filePath)) {
438
+ this.prevStates.set(filePath, before);
439
+ }
440
+ const payload = {
441
+ state: after,
442
+ changes: topChanges(before, after),
443
+ warnings: nestedMergeWarnings(before, patch),
444
+ };
445
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
245
446
  }
246
- stateMerge(args) {
247
- const patch = args.patch;
447
+ stateValidate(args) {
448
+ const patch = args['patch'];
248
449
  if (!isPlainObject(patch)) {
249
450
  throw new Error('patch must be an object');
250
451
  }
251
452
  const validation = validatePatchDeep(this.options.spec.schema, patch);
252
- if (!validation.valid) {
253
- throw new Error(validation.error);
254
- }
453
+ return this.textResult(redactSecrets(JSON.stringify(validation.valid
454
+ ? { valid: true }
455
+ : { valid: false, error: validation.error, field: validation.field })));
456
+ }
457
+ stateDiff(args) {
255
458
  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)));
459
+ const current = this.loadState(filePath);
460
+ let before = this.prevStates.get(filePath);
461
+ if (before === undefined) {
462
+ before = current;
463
+ }
464
+ this.prevStates.set(filePath, current);
465
+ const payload = {
466
+ changes: topChanges(before, current),
467
+ };
468
+ if (args['full'] === true) {
469
+ payload['before'] = before;
470
+ payload['after'] = current;
471
+ }
472
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
473
+ }
474
+ async stateCheckpoint(args) {
475
+ const ref = this.resolveRef(args);
476
+ const dir = checkpointsDir(ref.filePath);
477
+ const state = this.loadState(ref.filePath);
478
+ const seq = nextCheckpointSeq(dir);
479
+ const label = sanitizeLabel(typeof args['label'] === 'string' ? args['label'] : '');
480
+ const checkpointId = `${seq}-${label}`;
481
+ const record = {
482
+ checkpointId,
483
+ seq,
484
+ label,
485
+ createdAt: new Date().toISOString(),
486
+ state,
487
+ };
488
+ // Best-effort `<path>.snapshot` side copy through the paper-exact store,
489
+ // then the named sidecar entry (both atomic writes).
490
+ await new FileStore(ref.root, ref.name).snapshot();
491
+ await atomicWriteFile(path.join(dir, `${checkpointId}.json`), JSON.stringify(record, null, 2));
492
+ const payload = {
493
+ checkpointId,
494
+ seq,
495
+ label,
496
+ checkpoints: listCheckpoints(dir),
497
+ };
498
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
499
+ }
500
+ async stateRollback(args) {
501
+ const ref = this.resolveRef(args);
502
+ const dir = checkpointsDir(ref.filePath);
503
+ const wanted = typeof args['checkpointId'] === 'string' ? args['checkpointId'] : undefined;
504
+ let checkpointId;
505
+ if (wanted === undefined) {
506
+ const list = listCheckpoints(dir);
507
+ if (list.length === 0) {
508
+ throw new Error('No checkpoints found: create one with state.checkpoint first');
509
+ }
510
+ checkpointId = list[list.length - 1].checkpointId;
511
+ }
512
+ else {
513
+ checkpointId = wanted;
514
+ }
515
+ if (!/^[A-Za-z0-9._-]+$/.test(checkpointId)) {
516
+ throw new Error(`Checkpoint not found: ${checkpointId}`);
517
+ }
518
+ let record;
519
+ try {
520
+ record = JSON.parse(fs.readFileSync(path.join(dir, `${checkpointId}.json`), 'utf-8'));
521
+ }
522
+ catch {
523
+ throw new Error(`Checkpoint not found or unreadable: ${checkpointId}`);
524
+ }
525
+ if (!isPlainObject(record.state)) {
526
+ throw new Error(`Checkpoint is corrupted (no state): ${checkpointId}`);
527
+ }
528
+ const before = this.loadState(ref.filePath);
529
+ this.writeState(ref.filePath, record.state);
530
+ this.bumpWriteSeq(ref.filePath);
531
+ if (!this.prevStates.has(ref.filePath)) {
532
+ this.prevStates.set(ref.filePath, before);
533
+ }
534
+ const payload = { checkpointId, state: record.state };
535
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
259
536
  }
260
- stateReset(args) {
537
+ stateSummary(args) {
261
538
  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)));
539
+ const state = this.loadState(filePath);
540
+ const payload = {
541
+ ...buildSummary(state),
542
+ session: {
543
+ statePath: filePath,
544
+ envelopeVersion: CURRENT_STATE_VERSION,
545
+ protocolVersion: this.protocolVersion,
546
+ seq: this.writeSeq.get(filePath) ?? 0,
547
+ },
548
+ };
549
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
550
+ }
551
+ stateMetrics() {
552
+ const tracker = this.options.tracker;
553
+ if (!tracker) {
554
+ throw new Error('No token tracker configured');
555
+ }
556
+ if (tracker.getBookkeeping().stepCount === 0) {
557
+ throw new Error('No steps recorded yet: the token tracker session is empty');
558
+ }
559
+ return this.textResult(redactSecrets(JSON.stringify(tracker.getMetrics())));
265
560
  }
266
561
  specGet() {
267
562
  const spec = this.options.spec;
@@ -271,27 +566,35 @@ export class McpServer {
271
566
  version: spec.version,
272
567
  instructions: spec.instructions,
273
568
  schema: spec.schema,
569
+ example_state_patch: buildExamplePatch(spec.schema),
274
570
  })));
275
571
  }
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(),
572
+ specNext(args) {
573
+ const state = this.loadState(this.resolveStore(args));
574
+ const progress = Array.isArray(state['progress']) ? state['progress'] : [];
575
+ const nextSteps = Array.isArray(state['next_steps']) ? state['next_steps'] : [];
576
+ const blockers = Array.isArray(state['blockers']) ? state['blockers'] : [];
577
+ const payload = {
578
+ goal: typeof state['goal'] === 'string' ? state['goal'] : null,
579
+ completed: progress.length,
580
+ next: nextSteps.slice(0, SUMMARY_NEXT_PREVIEW),
581
+ blockers,
582
+ suggestion: nextSteps.length > 0 ? nextSteps[0] : 'set next_steps via state.patch',
284
583
  };
285
- return this.textResult(redactSecrets(JSON.stringify(metrics)));
584
+ return this.textResult(redactSecrets(JSON.stringify(payload)));
286
585
  }
287
586
  /* ------------------------------------------------------------------ */
288
587
  /* State helpers */
289
588
  /* ------------------------------------------------------------------ */
290
589
  /** Resolve the target state file path (args override the defaults). */
291
590
  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);
591
+ return this.resolveRef(args).filePath;
592
+ }
593
+ /** Resolve `{ root, name }` + the confined file path in one go. */
594
+ resolveRef(args) {
595
+ const root = typeof args['root'] === 'string' ? args['root'] : this.options.root;
596
+ const name = typeof args['name'] === 'string' ? args['name'] : this.options.name;
597
+ return { root, name, filePath: resolveStatePath(root, name) };
295
598
  }
296
599
  /** Read + normalize the state, falling back to schema defaults. */
297
600
  loadState(filePath) {
@@ -303,14 +606,21 @@ export class McpServer {
303
606
  return createInitialState(this.options.spec.schema);
304
607
  }
305
608
  }
306
- /** Crash-safe synchronous write: temp sibling + fsync + rename. */
609
+ /** Advance the per-path session write counter. */
610
+ bumpWriteSeq(filePath) {
611
+ this.writeSeq.set(filePath, (this.writeSeq.get(filePath) ?? 0) + 1);
612
+ }
613
+ /**
614
+ * Crash-safe synchronous write of the versioned envelope
615
+ * `{ version, state }`: temp sibling + fsync + rename.
616
+ */
307
617
  writeState(filePath, state) {
308
618
  const dir = path.dirname(filePath);
309
619
  fs.mkdirSync(dir, { recursive: true });
310
620
  const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
311
621
  const fd = fs.openSync(tmp, 'w');
312
622
  try {
313
- fs.writeSync(fd, JSON.stringify(state, null, 2));
623
+ fs.writeSync(fd, JSON.stringify({ version: CURRENT_STATE_VERSION, state }, null, 2));
314
624
  fs.fsyncSync(fd);
315
625
  }
316
626
  finally {
@@ -322,62 +632,93 @@ export class McpServer {
322
632
  /* Tool / resource schemas */
323
633
  /* ------------------------------------------------------------------ */
324
634
  toolsList() {
325
- const stringProp = (desc) => ({
326
- type: 'string',
327
- description: desc,
328
- });
635
+ const stateTargetProps = {
636
+ root: { type: 'string', description: 'Optional state root directory override.' },
637
+ name: { type: 'string', description: 'Optional state file name override.' },
638
+ };
329
639
  return [
330
640
  {
331
641
  name: 'state.get',
332
- description: 'Read the current skill state (secrets redacted).',
642
+ description: 'Read the FULL execution state as JSON (secrets redacted). Use when you need every field; for a quick orientation use state.summary instead.',
643
+ inputSchema: { type: 'object', properties: { ...stateTargetProps } },
644
+ annotations: { readOnlyHint: true, destructiveHint: false },
645
+ },
646
+ {
647
+ name: 'state.patch',
648
+ 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
649
  inputSchema: {
334
650
  type: 'object',
335
- properties: { name: stringProp('Optional alternate state file name.') },
651
+ properties: { patch: { type: 'object', description: 'Sparse patch; null deletes a key.' }, ...stateTargetProps },
652
+ required: ['patch'],
336
653
  },
654
+ annotations: { readOnlyHint: false, destructiveHint: false },
337
655
  },
338
656
  {
339
- name: 'state.patch',
340
- description: 'Apply a sparse patch (null deletes a key) to the state and persist it.',
657
+ name: 'state.validate',
658
+ 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.',
341
659
  inputSchema: {
342
660
  type: 'object',
343
- properties: {
344
- patch: { type: 'object' },
345
- root: stringProp('Optional state root directory.'),
346
- name: stringProp('Optional state file name.'),
347
- },
661
+ properties: { patch: { type: 'object', description: 'The patch to check.' } },
348
662
  required: ['patch'],
349
663
  },
664
+ annotations: { readOnlyHint: true, destructiveHint: false },
350
665
  },
351
666
  {
352
- name: 'state.merge',
353
- description: 'Schema-validated patch: validate then apply the ⊕ merge and persist.',
667
+ name: 'state.diff',
668
+ 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.",
354
669
  inputSchema: {
355
670
  type: 'object',
356
671
  properties: {
357
- patch: { type: 'object' },
358
- root: stringProp('Optional state root directory.'),
359
- name: stringProp('Optional state file name.'),
672
+ full: { type: 'boolean', description: 'Also return the full before/after states.' },
673
+ ...stateTargetProps,
360
674
  },
361
- required: ['patch'],
362
675
  },
676
+ annotations: { readOnlyHint: true, destructiveHint: false },
363
677
  },
364
678
  {
365
- name: 'state.reset',
366
- description: 'Reset the state to the schema defaults.',
679
+ name: 'state.checkpoint',
680
+ 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"}.',
367
681
  inputSchema: {
368
682
  type: 'object',
369
- properties: { name: stringProp('Optional alternate state file name.') },
683
+ properties: { label: { type: 'string', description: 'Short name for the snapshot.' }, ...stateTargetProps },
370
684
  },
685
+ annotations: { readOnlyHint: false, destructiveHint: false },
371
686
  },
372
687
  {
373
- name: 'spec.get',
374
- description: 'Return the procedural spec (id, name, version, schema).',
375
- inputSchema: { type: 'object' },
688
+ name: 'state.rollback',
689
+ 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 }.',
690
+ inputSchema: {
691
+ type: 'object',
692
+ properties: {
693
+ checkpointId: { type: 'string', description: 'Id from state.checkpoint; omit for the latest.' },
694
+ ...stateTargetProps,
695
+ },
696
+ },
697
+ annotations: { readOnlyHint: false, destructiveHint: true },
698
+ },
699
+ {
700
+ name: 'state.summary',
701
+ 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). Use this instead of state.get for fast context.',
702
+ inputSchema: { type: 'object', properties: { ...stateTargetProps } },
703
+ annotations: { readOnlyHint: true, destructiveHint: false },
376
704
  },
377
705
  {
378
706
  name: 'state.metrics',
379
- description: 'Return the paper §4.3 metrics readout from the token tracker.',
380
- inputSchema: { type: 'object' },
707
+ 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.',
708
+ inputSchema: { type: 'object', properties: {} },
709
+ annotations: { readOnlyHint: true, destructiveHint: false },
710
+ },
711
+ {
712
+ name: 'spec.get',
713
+ 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.',
714
+ inputSchema: { type: 'object', properties: {} },
715
+ annotations: { readOnlyHint: true, destructiveHint: false },
716
+ },
717
+ {
718
+ name: 'spec.next',
719
+ 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.',
720
+ inputSchema: { type: 'object', properties: { ...stateTargetProps } },
721
+ annotations: { readOnlyHint: true, destructiveHint: false },
381
722
  },
382
723
  ];
383
724
  }
@@ -386,50 +727,24 @@ export class McpServer {
386
727
  {
387
728
  uri: 'skillstate://state',
388
729
  name: 'Skill State',
730
+ description: 'The full versioned state envelope ({ version, state }).',
731
+ mimeType: 'application/json',
732
+ },
733
+ {
734
+ uri: 'skillstate://spec',
735
+ name: 'Procedural Spec',
736
+ description: 'The procedural spec: id, name, version, instructions, schema.',
737
+ mimeType: 'application/json',
738
+ },
739
+ {
740
+ uri: 'skillstate://summary',
741
+ name: 'State Summary',
742
+ description: 'Compact summary projection of the current state.',
389
743
  mimeType: 'application/json',
390
744
  },
391
745
  ];
392
746
  }
393
747
  /* ------------------------------------------------------------------ */
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
748
  /* JSON-RPC response builders */
434
749
  /* ------------------------------------------------------------------ */
435
750
  successResponse(id, result) {
@@ -446,8 +761,51 @@ export class McpServer {
446
761
  return { content: [{ type: 'text', text }] };
447
762
  }
448
763
  }
449
- function isPlainObject(value) {
450
- return typeof value === 'object' && value !== null && !Array.isArray(value);
764
+ /** Sidecar catalog directory for a state file: `<stateDir>/checkpoints`. */
765
+ function checkpointsDir(filePath) {
766
+ return path.join(path.dirname(filePath), 'checkpoints');
767
+ }
768
+ /** Next checkpoint sequence number: max existing sidecar seq + 1 (from 1). */
769
+ function nextCheckpointSeq(dir) {
770
+ const checkpoints = listCheckpoints(dir);
771
+ return (checkpoints.length > 0 ? checkpoints[checkpoints.length - 1].seq : 0) + 1;
772
+ }
773
+ /**
774
+ * List the checkpoint sidecars in `dir`, oldest first. Unreadable or
775
+ * malformed entries are skipped; a missing directory yields an empty list.
776
+ */
777
+ function listCheckpoints(dir) {
778
+ let entries;
779
+ try {
780
+ entries = fs.readdirSync(dir);
781
+ }
782
+ catch {
783
+ entries = [];
784
+ }
785
+ const found = [];
786
+ for (const entry of entries) {
787
+ if (!entry.endsWith('.json')) {
788
+ continue;
789
+ }
790
+ try {
791
+ const record = JSON.parse(fs.readFileSync(path.join(dir, entry), 'utf-8'));
792
+ if (typeof record.checkpointId === 'string' &&
793
+ typeof record.seq === 'number' &&
794
+ typeof record.label === 'string' &&
795
+ typeof record.createdAt === 'string') {
796
+ found.push({
797
+ checkpointId: record.checkpointId,
798
+ seq: record.seq,
799
+ label: record.label,
800
+ createdAt: record.createdAt,
801
+ });
802
+ }
803
+ }
804
+ catch {
805
+ // Unreadable sidecar — skip, never fail the listing.
806
+ }
807
+ }
808
+ return found.sort((a, b) => a.seq - b.seq);
451
809
  }
452
810
  /**
453
811
  * Resolve the procedural spec for a launch: explicit `args.spec` wins, then
@@ -465,10 +823,9 @@ function resolveSpec(args, env) {
465
823
  return INTERCODE_CTF_SPEC;
466
824
  }
467
825
  /**
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):
826
+ * Per-project state resolution for an MCP server session — re-exported
827
+ * from `@skillstate/core` (the single source of truth shared with the
828
+ * OpenCode plugin and the generated hook scripts):
472
829
  *
473
830
  * - `cwd === home` — no single project → the global bucket
474
831
  * `<home>/.skillstate/global/skillstate.json`;
@@ -476,14 +833,7 @@ function resolveSpec(args, env) {
476
833
  *
477
834
  * Pure path arithmetic (no filesystem access, `path.resolve` normalization).
478
835
  */
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
- }
836
+ export { resolveHostStateForCwd as resolveStatePathForCwd } from '@skillstate/core';
487
837
  /**
488
838
  * Launch an MCP server from an argument/env config (reads
489
839
  * `SKILLSTATE_SPEC_PATH` when not passed explicitly). State resolution is
@@ -496,7 +846,7 @@ export function resolveStatePathForCwd(cwd, home) {
496
846
  */
497
847
  export async function launch(args) {
498
848
  const spec = resolveSpec(args, process.env);
499
- const statePath = resolveStatePathForCwd(process.cwd(), os.homedir());
849
+ const statePath = resolveHostStateForCwd(process.cwd(), os.homedir());
500
850
  const root = args?.root ?? path.dirname(statePath);
501
851
  const name = args?.name ?? path.basename(statePath);
502
852
  const server = new McpServer({