@nanobpm/nano-workforce 0.176.1 → 0.178.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/CHANGELOG.md +12 -0
- package/app/agentic/claim-registry.test.ts +155 -0
- package/app/agentic/claim-registry.ts +194 -0
- package/app/agentic/families/claim.family.test.ts +196 -0
- package/app/agentic/families/claim.family.ts +148 -0
- package/app/mcpToolSurface.test.ts +63 -0
- package/app/mcpToolSurface.ts +112 -0
- package/docs/mcp-runbook.md +76 -0
- package/e2e/mcp-session-selfheal.e2e.ts +113 -0
- package/e2e/mcp-tractability.e2e.ts +114 -0
- package/e2e/support/mcp-harness.ts +81 -28
- package/operations/getAgenticSupply.test.ts +32 -3
- package/operations/getAgenticSupply.ts +24 -15
- package/package.json +4 -2
- package/pages/mcp.page.json +26 -0
- package/scripts/sync-mcp-curated.test.ts +19 -0
- package/scripts/sync-mcp-curated.ts +77 -0
- package/test/agentic-e2e.test.ts +11 -3
|
@@ -121,13 +121,31 @@ export interface McpHarness {
|
|
|
121
121
|
/** The underlying booted app — exposed for a slice that needs to seed/inspect the app DB or drive
|
|
122
122
|
* an operator-only (`x-mcp`-excluded) cleanup route the MCP surface does not expose. */
|
|
123
123
|
readonly app: TestApp;
|
|
124
|
-
/** The negotiated MCP session id (the captured `Mcp-Session-Id`).
|
|
124
|
+
/** The CURRENT negotiated MCP session id (the captured `Mcp-Session-Id`). Tracks the live session,
|
|
125
|
+
* so after {@link McpHarness.reinitialize} it reflects the NEW id, not the original. */
|
|
125
126
|
readonly sessionId: string;
|
|
126
127
|
/** `tools/list` — the projected tool catalogue (app operations + framework debug tools). */
|
|
127
128
|
listTools(): Promise<McpTool[]>;
|
|
128
129
|
/** `tools/call` — invoke a tool by name with its argument object. Optional `extraHeaders` are
|
|
129
130
|
* overlaid on the POST (e.g. an `x-hook-secret` shared-secret credential for a gated mutation). */
|
|
130
131
|
callTool(name: string, args?: Record<string, unknown>, extraHeaders?: Record<string, string>): Promise<McpToolResult>;
|
|
132
|
+
/** `tools/call` against an EXPLICIT session id (not the harness's live one) — used to exercise the
|
|
133
|
+
* session-loss path: a call carrying a stale/unknown/deleted `mcp-session-id` must be refused with
|
|
134
|
+
* the runtime's `-32000` "no valid session id" error (issue #715, gap 1 self-heal regression). */
|
|
135
|
+
callToolAs(sessionId: string, name: string, args?: Record<string, unknown>): Promise<McpToolResult>;
|
|
136
|
+
/** Re-run the full client handshake (`initialize` → `notifications/initialized`), minting a FRESH
|
|
137
|
+
* session and adopting it as the harness's live session. This is the client-side SELF-HEAL a real
|
|
138
|
+
* MCP client performs after its session is lost (timeout, idle drop, proxy reset, LRU eviction):
|
|
139
|
+
* the pinned runtime is session-stateful (a stateless/resumable transport is tracked upstream in
|
|
140
|
+
* nano-ide#488), so re-initialising is how a client recovers the surface. Returns the new id. */
|
|
141
|
+
reinitialize(): Promise<string>;
|
|
142
|
+
/** End a session server-side via the transport's `DELETE` (the spec session-termination verb). With
|
|
143
|
+
* no argument, ends the harness's current session; pass an id to end a specific one. After this the
|
|
144
|
+
* ended id is unknown to the server, so a subsequent {@link McpHarness.callToolAs} with it is
|
|
145
|
+
* refused `-32000`. Optional `extraHeaders` are overlaid on the DELETE (e.g. an `x-hook-secret`
|
|
146
|
+
* shared-secret credential) exactly as {@link McpHarness.callTool} does, so this helper stays
|
|
147
|
+
* usable on a shared-secret-guarded surface. Returns the DELETE's transport status. */
|
|
148
|
+
deleteSession(sessionId?: string, extraHeaders?: Record<string, string>): Promise<number>;
|
|
131
149
|
/** A raw JSON-RPC request against `/app/mcp` (escape hatch for a bespoke case). `params` omitted →
|
|
132
150
|
* no `params` field; a `notifications/*` method is sent as a notification (no `id`, no response). */
|
|
133
151
|
rpc(method: string, params?: unknown): Promise<McpRpcResult>;
|
|
@@ -221,9 +239,11 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
|
|
|
221
239
|
rmSync(dbDir, { recursive: true, force: true });
|
|
222
240
|
};
|
|
223
241
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
242
|
+
/** Run the full client handshake against the live surface and return the freshly-minted session id:
|
|
243
|
+
* `initialize` (capture the `Mcp-Session-Id`) → `notifications/initialized`. Reused by the initial
|
|
244
|
+
* boot AND by {@link McpHarness.reinitialize} so the self-heal path exercises the SAME real
|
|
245
|
+
* handshake, never a shortcut. */
|
|
246
|
+
const doInitialize = async (): Promise<string> => {
|
|
227
247
|
const initRes = await rpc("initialize", {
|
|
228
248
|
protocolVersion: PROTOCOL_VERSION,
|
|
229
249
|
capabilities: {},
|
|
@@ -240,10 +260,36 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
|
|
|
240
260
|
`MCP initialize returned no ${SESSION_HEADER} header — headers: ${JSON.stringify(initRes.headers)}`,
|
|
241
261
|
);
|
|
242
262
|
}
|
|
243
|
-
|
|
263
|
+
// notifications/initialized — the client's post-init notification (no response expected).
|
|
264
|
+
await rpc("notifications/initialized", undefined, mintedId);
|
|
265
|
+
return mintedId;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Parse a `tools/call` JSON-RPC envelope into the client-visible {@link McpToolResult}. Shared by
|
|
269
|
+
* `callTool` and `callToolAs` so both surface a JSON-RPC-level error (e.g. `-32000` no-session) and
|
|
270
|
+
* a tool-level `isError` identically. */
|
|
271
|
+
const parseCallResult = (res: McpRpcResult): McpToolResult => {
|
|
272
|
+
const body = res.body as
|
|
273
|
+
| { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
|
|
274
|
+
| undefined;
|
|
275
|
+
if (body?.error) {
|
|
276
|
+
const text = body.error.message ?? JSON.stringify(body.error);
|
|
277
|
+
return { isError: true, text, json: safeParse(text), httpStatus: res.httpStatus, raw: body };
|
|
278
|
+
}
|
|
279
|
+
const first = body?.result?.content?.find((c) => c.type === "text");
|
|
280
|
+
const text = first?.text ?? "";
|
|
281
|
+
return {
|
|
282
|
+
isError: body?.result?.isError === true,
|
|
283
|
+
text,
|
|
284
|
+
json: safeParse(text),
|
|
285
|
+
httpStatus: res.httpStatus,
|
|
286
|
+
raw: body,
|
|
287
|
+
};
|
|
288
|
+
};
|
|
244
289
|
|
|
245
|
-
|
|
246
|
-
|
|
290
|
+
let currentSessionId: string;
|
|
291
|
+
try {
|
|
292
|
+
currentSessionId = await doInitialize();
|
|
247
293
|
} catch (err) {
|
|
248
294
|
await teardown();
|
|
249
295
|
throw err;
|
|
@@ -252,10 +298,12 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
|
|
|
252
298
|
let stopped = false;
|
|
253
299
|
const harness: McpHarness = {
|
|
254
300
|
app,
|
|
255
|
-
sessionId
|
|
256
|
-
|
|
301
|
+
get sessionId(): string {
|
|
302
|
+
return currentSessionId;
|
|
303
|
+
},
|
|
304
|
+
rpc: (method, params) => rpc(method, params, currentSessionId),
|
|
257
305
|
async listTools(): Promise<McpTool[]> {
|
|
258
|
-
const res = await rpc("tools/list", {},
|
|
306
|
+
const res = await rpc("tools/list", {}, currentSessionId);
|
|
259
307
|
const body = res.body as { result?: { tools?: McpTool[] }; error?: unknown } | undefined;
|
|
260
308
|
if (!body?.result?.tools) {
|
|
261
309
|
throw new Error(`tools/list returned no result.tools: ${JSON.stringify(body)}`);
|
|
@@ -263,25 +311,30 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
|
|
|
263
311
|
return body.result.tools;
|
|
264
312
|
},
|
|
265
313
|
async callTool(name, args = {}, extraHeaders): Promise<McpToolResult> {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
return {
|
|
279
|
-
isError: body?.result?.isError === true,
|
|
280
|
-
text,
|
|
281
|
-
json: safeParse(text),
|
|
282
|
-
httpStatus: res.httpStatus,
|
|
283
|
-
raw: body,
|
|
314
|
+
return parseCallResult(await rpc("tools/call", { name, arguments: args }, currentSessionId, extraHeaders));
|
|
315
|
+
},
|
|
316
|
+
async callToolAs(sessionId, name, args = {}): Promise<McpToolResult> {
|
|
317
|
+
return parseCallResult(await rpc("tools/call", { name, arguments: args }, sessionId));
|
|
318
|
+
},
|
|
319
|
+
async reinitialize(): Promise<string> {
|
|
320
|
+
currentSessionId = await doInitialize();
|
|
321
|
+
return currentSessionId;
|
|
322
|
+
},
|
|
323
|
+
async deleteSession(sessionId = currentSessionId, extraHeaders): Promise<number> {
|
|
324
|
+
const headers: Record<string, string> = {
|
|
325
|
+
accept: "application/json, text/event-stream",
|
|
284
326
|
};
|
|
327
|
+
// Overlay caller headers FIRST, then set the session header authoritatively — a caller passing
|
|
328
|
+
// auth headers (e.g. `x-hook-secret`) must not clobber the `mcp-session-id` being terminated.
|
|
329
|
+
if (extraHeaders) Object.assign(headers, extraHeaders);
|
|
330
|
+
headers[SESSION_HEADER] = sessionId;
|
|
331
|
+
const res = await app.ui.call({
|
|
332
|
+
method: "DELETE",
|
|
333
|
+
path: MCP_PATH,
|
|
334
|
+
headers,
|
|
335
|
+
body: "",
|
|
336
|
+
});
|
|
337
|
+
return res.status ?? 200;
|
|
285
338
|
},
|
|
286
339
|
async stop(): Promise<void> {
|
|
287
340
|
if (stopped) return;
|
|
@@ -12,7 +12,9 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
|
|
|
12
12
|
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
13
13
|
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
14
14
|
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { currentClaimRegistry } from "../app/agentic/claim-registry.ts";
|
|
15
16
|
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
17
|
+
import { family as claimFamily } from "../app/agentic/families/claim.family.ts";
|
|
16
18
|
import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
|
|
17
19
|
import { family } from "../app/agentic/families/presence.family.ts";
|
|
18
20
|
import type { AgenticContext } from "../app/agentic/registry.ts";
|
|
@@ -154,7 +156,32 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
|
|
|
154
156
|
}
|
|
155
157
|
});
|
|
156
158
|
|
|
157
|
-
test("
|
|
159
|
+
test("#713: a claim populates jobKeys and repoints the drill stream with ZERO transcript (claim is the visibility source)", async () => {
|
|
160
|
+
const hub = await mountPresence(memSqlite());
|
|
161
|
+
const ctx: AgenticContext = { hub, registry: hub.registry, transport: undefined as never, data: undefined, log: noopLog() };
|
|
162
|
+
claimFamily.mount(ctx);
|
|
163
|
+
const claims = currentClaimRegistry();
|
|
164
|
+
assert(claims !== undefined, "the claim family installs the singleton");
|
|
165
|
+
// An explicit claim — no relay produce frame, no correlation link, zero transcript.
|
|
166
|
+
claims.claim("wk-a", "8420");
|
|
167
|
+
try {
|
|
168
|
+
const res = (await handler(input(), app)) as {
|
|
169
|
+
status: number;
|
|
170
|
+
body: { workers: Array<Record<string, unknown>>; correlations: unknown[] };
|
|
171
|
+
};
|
|
172
|
+
assertEquals(res.status, 200);
|
|
173
|
+
const w = res.body.workers[0];
|
|
174
|
+
assertEquals(w.jobKeys, ["8420"], "the claim registry feeds the jobKeys seam");
|
|
175
|
+
assertEquals(w.stream, "job:8420", "the drill stream repoints at the claimed job, keyed by the claim");
|
|
176
|
+
assertEquals(res.body.correlations.length, 0, "no correlation context until a terminal lands (drill-in only)");
|
|
177
|
+
} finally {
|
|
178
|
+
claimFamily.teardown?.();
|
|
179
|
+
family.teardown?.();
|
|
180
|
+
await hub.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("#713: the correlation registry is demoted to drill-in context — a link alone no longer feeds jobKeys", async () => {
|
|
158
185
|
const hub = await mountPresence(memSqlite());
|
|
159
186
|
correlationFamily.mount({
|
|
160
187
|
hub,
|
|
@@ -165,6 +192,7 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
|
|
|
165
192
|
});
|
|
166
193
|
const correlation = currentCorrelation();
|
|
167
194
|
assert(correlation !== undefined, "the correlation family installs the singleton");
|
|
195
|
+
// A correlation link (drill-in context) WITHOUT a claim: visibility must NOT light up from it.
|
|
168
196
|
correlation.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
|
|
169
197
|
try {
|
|
170
198
|
const res = (await handler(input(), app)) as {
|
|
@@ -176,8 +204,9 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
|
|
|
176
204
|
};
|
|
177
205
|
assertEquals(res.status, 200);
|
|
178
206
|
const w = res.body.workers[0];
|
|
179
|
-
assertEquals(w.jobKeys, [
|
|
180
|
-
assertEquals(w.stream, "
|
|
207
|
+
assertEquals(w.jobKeys, [], "correlation alone no longer feeds jobKeys (relay demoted)");
|
|
208
|
+
assertEquals(w.stream, "wk-a", "the drill stream stays instance-keyed without a claim");
|
|
209
|
+
// The correlation context is still reported for drill-in.
|
|
181
210
|
assertEquals(res.body.correlations.length, 1);
|
|
182
211
|
const c = res.body.correlations[0];
|
|
183
212
|
assertEquals(c.jobKey, "6494");
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
|
|
4
4
|
// presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
|
|
5
5
|
//
|
|
6
|
-
// H6
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// H6/#713 closes the loop with an EXPLICIT claim registry (`app/agentic/claim-registry.ts`): it is the
|
|
7
|
+
// AUTHORITATIVE source the presence snapshot's `jobKeysFor` seam resolves against, so each worker's
|
|
8
|
+
// current jobKeys light up from `claim` frames — not inferred from the relay terminal — and appear even
|
|
9
|
+
// with ZERO transcript. Each worker's drill `stream` is repointed at its claimed jobKey-scoped relay
|
|
10
|
+
// stream (`job:<jobKey>`), keyed by the CLAIM (explicit instance+jobKey), not by a connection. The
|
|
11
|
+
// relay correlation registry is DEMOTED to drill-in context only: it still supplies the `correlations`
|
|
12
|
+
// — the process-instance / plan context for a job's terminal — so the cockpit lines a worker's terminal
|
|
13
|
+
// up with "that process instance / this plan", but it is no longer the visibility source.
|
|
11
14
|
//
|
|
12
15
|
// This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
|
|
13
16
|
// reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
|
|
@@ -16,7 +19,8 @@
|
|
|
16
19
|
// The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
|
|
17
20
|
// NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
|
|
18
21
|
|
|
19
|
-
import { type
|
|
22
|
+
import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
|
|
23
|
+
import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
|
|
20
24
|
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
21
25
|
import { envVar } from "../app/version.ts";
|
|
22
26
|
import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
@@ -27,13 +31,14 @@ import { defineOperation } from "../nano-generated/operations.ts";
|
|
|
27
31
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
28
32
|
|
|
29
33
|
// Project a presence-registry row to the wire worker. The drill `stream` defaults to the worker
|
|
30
|
-
// instance (H5) but is repointed at the worker's jobKey-scoped relay stream (`job:<jobKey>`)
|
|
31
|
-
//
|
|
32
|
-
|
|
34
|
+
// instance (H5) but is repointed at the worker's claimed jobKey-scoped relay stream (`job:<jobKey>`)
|
|
35
|
+
// when the claim registry knows a current claim for it (#713) — keyed by the CLAIM, not by the
|
|
36
|
+
// connection — so drilling in opens the LIVE job's terminal even before any transcript lands.
|
|
37
|
+
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
|
|
33
38
|
const out: AgenticSupplyWorker = {
|
|
34
39
|
instance: w.instance,
|
|
35
40
|
identity: w.identity,
|
|
36
|
-
stream:
|
|
41
|
+
stream: claims?.primaryStreamFor(w.instance) ?? w.instance,
|
|
37
42
|
jobKeys: [...w.jobKeys],
|
|
38
43
|
live: w.live,
|
|
39
44
|
staleMs: w.staleMs,
|
|
@@ -67,15 +72,19 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
|
67
72
|
return { status: 200, body: empty };
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
//
|
|
71
|
-
// worker's current jobKeys
|
|
75
|
+
// #713: the CLAIM registry (if mounted) is the authoritative `jobKeysFor` source the presence
|
|
76
|
+
// snapshot resolves against — a worker's current jobKeys come from explicit `claim` frames, not the
|
|
77
|
+
// relay terminal, so they populate with zero transcript. Absent → jobKeys stay empty (advisory).
|
|
78
|
+
const claims = currentClaimRegistry();
|
|
79
|
+
// The correlation registry is demoted to drill-in context only: it still carries the per-job
|
|
80
|
+
// process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
|
|
72
81
|
const correlation = currentCorrelation();
|
|
73
|
-
const snapshot = registry.snapshot(
|
|
82
|
+
const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
|
|
74
83
|
const report: AgenticSupplyReport = {
|
|
75
84
|
count: snapshot.count,
|
|
76
85
|
generatedAt: new Date().toISOString(),
|
|
77
|
-
workers: snapshot.workers.map((w) => toWorker(w,
|
|
78
|
-
leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w,
|
|
86
|
+
workers: snapshot.workers.map((w) => toWorker(w, claims)),
|
|
87
|
+
leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, claims)) })),
|
|
79
88
|
correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
|
|
80
89
|
};
|
|
81
90
|
return { status: 200, body: report };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.178.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
|
|
52
52
|
"gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
|
|
53
53
|
"check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
|
|
54
|
+
"sync:mcp-curated": "node --experimental-strip-types scripts/sync-mcp-curated.ts",
|
|
55
|
+
"sync:mcp-curated:check": "node --experimental-strip-types scripts/sync-mcp-curated.ts --check",
|
|
54
56
|
"gen:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts",
|
|
55
57
|
"check:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts --check",
|
|
56
58
|
"dev": "urban dev",
|
|
@@ -62,7 +64,7 @@
|
|
|
62
64
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
63
65
|
},
|
|
64
66
|
"dependencies": {
|
|
65
|
-
"@nanobpm/agentic": "^0.
|
|
67
|
+
"@nanobpm/agentic": "^0.11.0",
|
|
66
68
|
"@nanobpm/urban": "^0.90.0",
|
|
67
69
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
68
70
|
},
|
package/pages/mcp.page.json
CHANGED
|
@@ -106,6 +106,32 @@
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"type": "text",
|
|
111
|
+
"id": "tractable-heading",
|
|
112
|
+
"props": { "text": "Import a curated tool set (tractable surface)", "variant": "heading" }
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"type": "text",
|
|
116
|
+
"id": "tractable-body",
|
|
117
|
+
"props": {
|
|
118
|
+
"text": "The full projected surface is ~56 tools / ~79 KB of tools/list (app operations plus the framework urban_debug_* engine family) \u2014 large enough that a coding-agent harness may defer the whole set behind a tool-search gate, and \"tools\": [\"*\"] imports every one eagerly. Prefer a curated allowlist of the tools you actually drive and read with: set the server entry's \"tools\" to the curated subset instead of [\"*\"]. The curated list is maintained as the single source of truth in the repo (app/mcpToolSurface.ts, CURATED_MCP_TOOLS) and rendered as a copyable JSON block in the runbook \u2014 see docs/mcp-runbook.md \u00a7\"Import a curated subset\". [\"*\"] still works where the client does not defer; the full surface stays reachable either way.",
|
|
119
|
+
"variant": "sub"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "text",
|
|
124
|
+
"id": "session-recovery-heading",
|
|
125
|
+
"props": { "text": "Recover a lost session (re-initialize on -32000)", "variant": "heading" }
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"type": "text",
|
|
129
|
+
"id": "session-recovery-body",
|
|
130
|
+
"props": {
|
|
131
|
+
"text": "The /app/mcp transport is stateful streamable-HTTP: every tool call carries an mcp-session-id, and a call with a missing / stale / idle-dropped / proxy-reset / evicted session id is refused with -32000 \"no valid session id, and not an initialize request.\" A client that does not re-handshake then sees the whole surface report \"tool does not exist\" until it re-initializes \u2014 a single hiccup (notably a heavy-tool timeout) can brick every tool. The self-heal is a fresh initialize handshake, which mints a new session and restores the entire catalogue in one round trip; a well-behaved MCP client does this automatically on a -32000. A stateless/resumable transport that removes the session dependency is tracked upstream (nano-ide#488).",
|
|
132
|
+
"variant": "sub"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
109
135
|
{
|
|
110
136
|
"type": "text",
|
|
111
137
|
"id": "secret-heading",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Drift guard for the curated MCP tool block in the runbook (issue #715).
|
|
2
|
+
//
|
|
3
|
+
// The curated `"tools"` allowlist is authored once in `app/mcpToolSurface.ts` and rendered into
|
|
4
|
+
// `docs/mcp-runbook.md` by `scripts/sync-mcp-curated.ts`. This test — run under `npm test`, which CI
|
|
5
|
+
// already gates — fails if the runbook block drifts from the source of truth, so the enforcement does
|
|
6
|
+
// not depend on a separate workflow step (AGENTS.md: "Derivation over duplication: no drift
|
|
7
|
+
// surfaces"). Run `npm run sync:mcp-curated` to reconcile. Mirrors `scripts/sync-nav.test.ts`.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert } from "#test-assert";
|
|
10
|
+
import { reconciledRunbook } from "./sync-mcp-curated.ts";
|
|
11
|
+
|
|
12
|
+
test("docs/mcp-runbook.md curated-tools block is in sync with app/mcpToolSurface.ts", () => {
|
|
13
|
+
const { current, next } = reconciledRunbook();
|
|
14
|
+
assert(
|
|
15
|
+
current === next,
|
|
16
|
+
"docs/mcp-runbook.md curated-tools block is STALE vs app/mcpToolSurface.ts — " +
|
|
17
|
+
"run `npm run sync:mcp-curated` and commit the result.",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Render the curated MCP tool allowlist (`CURATED_MCP_TOOLS`, app/mcpToolSurface.ts) into the runbook
|
|
2
|
+
// (docs/mcp-runbook.md) between its `curated-tools` sentinels (issue #715).
|
|
3
|
+
//
|
|
4
|
+
// The curated `"tools"` allowlist an MCP client imports instead of `["*"]` (the tractable-surface
|
|
5
|
+
// subset) is authored ONCE in `app/mcpToolSurface.ts`. The runbook shows it as a copyable JSON block;
|
|
6
|
+
// this script derives that block from the source so the two can never drift (AGENTS.md "no drift
|
|
7
|
+
// surfaces"), mirroring the repo's other derive/verify pairs (sync-nav, inline-mcp-bodies, layout-bpmn).
|
|
8
|
+
//
|
|
9
|
+
// node --experimental-strip-types scripts/sync-mcp-curated.ts # write the runbook block
|
|
10
|
+
// node --experimental-strip-types scripts/sync-mcp-curated.ts --check # verify (CI) — non-zero on drift
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { CURATED_MCP_TOOLS } from "../app/mcpToolSurface.ts";
|
|
15
|
+
|
|
16
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
17
|
+
export const RUNBOOK_PATH = `${ROOT}docs/mcp-runbook.md`;
|
|
18
|
+
|
|
19
|
+
const BEGIN = "<!-- BEGIN GENERATED: curated-tools (npm run sync:mcp-curated) -->";
|
|
20
|
+
const END = "<!-- END GENERATED: curated-tools -->";
|
|
21
|
+
|
|
22
|
+
/** The generated block: a fenced JSON array of curated tool names, one per line, in authored order. */
|
|
23
|
+
export function renderBlock(): string {
|
|
24
|
+
const lines = CURATED_MCP_TOOLS.map((name, i) => {
|
|
25
|
+
const comma = i === CURATED_MCP_TOOLS.length - 1 ? "" : ",";
|
|
26
|
+
return ` ${JSON.stringify(name)}${comma}`;
|
|
27
|
+
});
|
|
28
|
+
return ["```json", "[", ...lines, "]", "```"].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function replaceBetweenSentinels(source: string, block: string): string {
|
|
32
|
+
const begin = source.indexOf(BEGIN);
|
|
33
|
+
const end = source.indexOf(END);
|
|
34
|
+
if (begin === -1 || end === -1 || end < begin) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`docs/mcp-runbook.md is missing the curated-tools sentinels (${BEGIN} … ${END}). ` +
|
|
37
|
+
"Restore them so the generated block has a home.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const before = source.slice(0, begin + BEGIN.length);
|
|
41
|
+
const after = source.slice(end);
|
|
42
|
+
return `${before}\n${block}\n${after}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The runbook content the generator WOULD write given its current on-disk state. */
|
|
46
|
+
export function reconciledRunbook(): { current: string; next: string } {
|
|
47
|
+
const current = readFileSync(RUNBOOK_PATH, "utf8");
|
|
48
|
+
return { current, next: replaceBetweenSentinels(current, renderBlock()) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function main(): void {
|
|
52
|
+
const check = process.argv.includes("--check");
|
|
53
|
+
const { current, next } = reconciledRunbook();
|
|
54
|
+
|
|
55
|
+
if (check) {
|
|
56
|
+
if (current !== next) {
|
|
57
|
+
console.error(
|
|
58
|
+
"docs/mcp-runbook.md curated-tools block is STALE vs app/mcpToolSurface.ts. " +
|
|
59
|
+
"Run `npm run sync:mcp-curated` and commit the result.",
|
|
60
|
+
);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
console.log("sync:mcp-curated: runbook curated-tools block is up to date.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (current !== next) {
|
|
67
|
+
writeFileSync(RUNBOOK_PATH, next);
|
|
68
|
+
console.log("sync:mcp-curated: wrote curated-tools block into docs/mcp-runbook.md.");
|
|
69
|
+
} else {
|
|
70
|
+
console.log("sync:mcp-curated: runbook curated-tools block already up to date.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Run as a CLI only when invoked directly (not when imported by the drift test).
|
|
75
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
76
|
+
main();
|
|
77
|
+
}
|
package/test/agentic-e2e.test.ts
CHANGED
|
@@ -154,6 +154,7 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
|
|
|
154
154
|
assert(names.includes("presence"), "H1 presence family is discovered by the H0 seam");
|
|
155
155
|
assert(names.includes("relay"), "H3 relay family is discovered by the H0 seam");
|
|
156
156
|
assert(names.includes("correlation"), "H6 correlation family is discovered by the H0 seam");
|
|
157
|
+
assert(names.includes("claim"), "#713 claim family is discovered by the H0 seam");
|
|
157
158
|
|
|
158
159
|
// ── H1: a worker connects and REGISTERs; a live presence row appears with its family/host. ──
|
|
159
160
|
const worker = conn("wk-conn-1", "leafA");
|
|
@@ -162,7 +163,14 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
|
|
|
162
163
|
worker.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
|
|
163
164
|
await flush();
|
|
164
165
|
|
|
165
|
-
// ──
|
|
166
|
+
// ── #713: the worker CLAIMs its active jobKey — the authoritative visibility source (explicit
|
|
167
|
+
// instance, no relay/connection inference). This is what lights up jobKeys, even before any
|
|
168
|
+
// transcript lands. ──
|
|
169
|
+
worker.feed({ lane: "control", family: "claim", seq: 2, payload: { instance: "wk-a", jobKey: JOB } });
|
|
170
|
+
await flush();
|
|
171
|
+
|
|
172
|
+
// ── H6: the orchestrator links the worker's active jobKey to its process instance / plan (drill-in
|
|
173
|
+
// context only, since #713 — no longer the visibility source). ──
|
|
166
174
|
const correlation = currentCorrelation();
|
|
167
175
|
assert(correlation !== undefined, "the correlation family installed the singleton");
|
|
168
176
|
correlation.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "nanobpm/nano-workforce#142" });
|
|
@@ -179,8 +187,8 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
|
|
|
179
187
|
assertEquals(w.instance, "wk-a");
|
|
180
188
|
assertEquals(w.family, "opus");
|
|
181
189
|
assertEquals(w.host, "boxA");
|
|
182
|
-
assertEquals(w.jobKeys, [JOB], "
|
|
183
|
-
assertEquals(w.stream, STREAM, "
|
|
190
|
+
assertEquals(w.jobKeys, [JOB], "#713: the claim registry feeds the presence jobKeys seam");
|
|
191
|
+
assertEquals(w.stream, STREAM, "#713: the drill stream repoints at the claimed job's stream");
|
|
184
192
|
assertEquals(res.body.correlations.length, 1);
|
|
185
193
|
const c = res.body.correlations[0];
|
|
186
194
|
assertEquals(c.jobKey, JOB);
|