@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.
- package/README.md +99 -25
- package/dist/mcp-server.d.ts +189 -45
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +1027 -195
- 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,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:
|
|
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 {
|
|
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
|
|
37
|
-
protocolVersion =
|
|
38
|
-
/** Advertised server capabilities
|
|
39
|
-
capabilities = {
|
|
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
|
-
|
|
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).
|
|
52
|
-
* stdio transport
|
|
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
|
|
63
|
-
* complete messages it contains.
|
|
64
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
const response = this.processRaw(line);
|
|
370
|
+
const response = await this.processRaw(line);
|
|
100
371
|
if (response !== null) {
|
|
101
|
-
responses.push(
|
|
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.
|
|
109
|
-
*
|
|
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
|
-
|
|
386
|
+
const handler = (chunk) => {
|
|
117
387
|
const text = typeof chunk === 'string' ? chunk : chunk.toString();
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
220
|
-
return this.
|
|
221
|
-
case 'state.
|
|
222
|
-
return this.
|
|
223
|
-
case '
|
|
224
|
-
return this.
|
|
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
|
|
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
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
const patch = args
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
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(
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
/**
|
|
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
|
|
326
|
-
type: 'string',
|
|
327
|
-
description:
|
|
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
|
|
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: {
|
|
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.
|
|
340
|
-
description: '
|
|
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
|
-
|
|
345
|
-
|
|
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.
|
|
353
|
-
description: '
|
|
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
|
-
|
|
358
|
-
|
|
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.
|
|
366
|
-
description: '
|
|
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: {
|
|
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: '
|
|
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: '
|
|
379
|
-
description: '
|
|
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
|
-
|
|
450
|
-
|
|
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
|
|
469
|
-
*
|
|
470
|
-
*
|
|
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
|
|
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
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
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 =
|
|
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
|