@skillstate/mcp 2.0.6 → 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.
- package/README.md +45 -23
- package/dist/mcp-server.d.ts +58 -40
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +538 -188
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
37
|
-
protocolVersion =
|
|
38
|
-
/** Advertised server capabilities
|
|
39
|
-
capabilities = {
|
|
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
|
-
|
|
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).
|
|
52
|
-
* stdio transport
|
|
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
|
|
63
|
-
* complete messages it contains.
|
|
64
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
const response = this.processRaw(line);
|
|
224
|
+
const response = await this.processRaw(line);
|
|
100
225
|
if (response !== null) {
|
|
101
|
-
responses.push(
|
|
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.
|
|
109
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
220
|
-
return this.
|
|
221
|
-
case 'state.
|
|
222
|
-
return this.
|
|
223
|
-
case '
|
|
224
|
-
return this.
|
|
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
|
|
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
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
const patch = args
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
257
|
-
this.
|
|
258
|
-
|
|
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
|
-
|
|
537
|
+
stateSummary(args) {
|
|
261
538
|
const filePath = this.resolveStore(args);
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
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(
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
/**
|
|
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
|
|
326
|
-
type: 'string',
|
|
327
|
-
description:
|
|
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
|
|
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: {
|
|
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.
|
|
340
|
-
description: '
|
|
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.
|
|
353
|
-
description:
|
|
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
|
-
|
|
358
|
-
|
|
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.
|
|
366
|
-
description: '
|
|
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: {
|
|
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: '
|
|
374
|
-
description: '
|
|
375
|
-
inputSchema: {
|
|
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: '
|
|
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
|
-
|
|
450
|
-
|
|
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
|
|
469
|
-
*
|
|
470
|
-
*
|
|
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
|
|
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 =
|
|
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({
|