@nanobpm/nano-workforce 0.177.0 → 0.178.1

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.
@@ -69,6 +69,82 @@ copilot mcp add --transport http workforce-merlin http://merlin.local:3000/app/m
69
69
  Tool calls are namespaced per server entry, so the instance you name is the instance
70
70
  you drive.
71
71
 
72
+ ### Import a curated subset, not `["*"]` (tractable surface — issue #715)
73
+
74
+ The full projected surface is **~56 tools / ~79 KB** of `tools/list` (app operations + the
75
+ framework `urban_debug_*` engine family). That is large enough that a coding-agent harness
76
+ **defers the whole set behind a tool-search gate**, and `"tools": ["*"]` imports every one of
77
+ them eagerly — so an agent may not see nwf's tools until it searches. Until the runtime shrinks
78
+ the framework tool family behind a mode (tracked upstream, nano-ide#488), the workforce-side lever
79
+ is to import a **curated allowlist** of the tools you actually drive/read with, instead of `["*"]`:
80
+
81
+ ```json
82
+ "workforce-local": {
83
+ "type": "http",
84
+ "url": "http://localhost:3000/app/mcp",
85
+ "tools": <curated list below>
86
+ }
87
+ ```
88
+
89
+ The curated list is the single source of truth in **`app/mcpToolSurface.ts`**
90
+ (`CURATED_MCP_TOOLS`) — `e2e/mcp-tractability.e2e.ts` asserts every entry projects onto the live
91
+ surface, and `npm run sync:mcp-curated:check` (also asserted by a drift test under `npm test`, which
92
+ CI runs) fails if the block below drifts from the source. Never hand-edit it; edit
93
+ `app/mcpToolSurface.ts` and run `npm run sync:mcp-curated`.
94
+
95
+ <!-- BEGIN GENERATED: curated-tools (npm run sync:mcp-curated) -->
96
+ ```json
97
+ [
98
+ "startConvergenceLoop",
99
+ "startPlanFanout",
100
+ "startEpicSet",
101
+ "startFeature",
102
+ "compileDeliveryGraph",
103
+ "previewDeliveryGraph",
104
+ "sequenceIssues",
105
+ "agentCompleteEscalation",
106
+ "completeUserTask",
107
+ "cancelInstance",
108
+ "appendBlackboard",
109
+ "readBlackboard",
110
+ "getVersion",
111
+ "getAgentInstructions",
112
+ "getAgentGuide",
113
+ "listActivePrs",
114
+ "listStagedProposals",
115
+ "listEscalations",
116
+ "getLineage",
117
+ "getPrHistory",
118
+ "urban_debug_search_process_instances",
119
+ "urban_debug_search_element_instance_wait_states",
120
+ "urban_debug_search_incidents",
121
+ "urban_debug_search_variables",
122
+ "urban_debug_search_jobs",
123
+ "urban_debug_instance_state",
124
+ "urban_debug_open_user_tasks"
125
+ ]
126
+ ```
127
+ <!-- END GENERATED: curated-tools -->
128
+
129
+ Keeping `["*"]` still works and is fine on a client that does not defer — the curated import is the
130
+ recommended default for a harness that gates on tool count. The full surface (including any
131
+ `urban_debug_*` tool not in the curated set) stays reachable; drop back to `["*"]`, or add the
132
+ specific extra tool name to the entry, whenever you need one outside the curated set.
133
+
134
+ ### Recover a lost session — re-`initialize` on `-32000` (issue #715, gap 1)
135
+
136
+ The `/app/mcp` transport is **stateful streamable-HTTP**: every `tools/call` carries an
137
+ `mcp-session-id`, and a call with a missing / stale / idle-dropped / proxy-reset / LRU-evicted
138
+ session id is refused with `-32000 "Bad Request: no valid session id, and not an initialize
139
+ request."` A client that does not re-handshake then sees the **whole surface** report "tool does not
140
+ exist" until it re-`initialize`s — a single hiccup (notably a heavy-tool timeout, gap 4 below) can
141
+ brick every tool. The **self-heal** is a fresh `initialize` handshake, which mints a new session and
142
+ restores the entire catalogue in one round trip; a well-behaved MCP client does this automatically on
143
+ a `-32000`. The stateless/resumable transport that would remove the session dependency entirely is
144
+ tracked upstream (nano-ide#488); `e2e/mcp-session-selfheal.e2e.ts` pins the workforce-visible
145
+ requirement — a killed session (stale id **and** a server-side `DELETE`) bricks a call `-32000`, and
146
+ one re-`initialize` brings the full surface back.
147
+
72
148
  ### Instance behind Basic Auth? You need *both* headers
73
149
 
74
150
  Two different layers. `x-hook-secret` is the **app's own** guard (checked by nwf in
@@ -214,6 +290,21 @@ got string`. That retired the nwf-local stringified-body reject mitigation: the
214
290
  `e2e/mcp-surface.e2e.ts` now asserts the door faithfully parses a stringified body (the
215
291
  `assertObjectBodyAccepted` detector's teeth stay pinned synthetically).
216
292
 
293
+ **Heavy compile/stage tools stay under the client timeout (issue #716).** Compiling a large
294
+ delivery graph to laid-out BPMN (`layoutBpmn` / `bpmn-auto-layout`) is CPU-bound and superlinear —
295
+ minutes on a 256-node / 1024-edge graph — so running it inline once tripped a cold
296
+ `sequenceIssues` / `compileDeliveryGraph` call past the client's per-call MCP timeout (`-32001
297
+ Request timed out`, which then poisoned the stateful session, #715). The compile+STAGE hot path
298
+ (`compileAndStageDeliveryGraph`) therefore runs the **layout-free** `compileDeliveryGraphSemantic`:
299
+ staging needs only the content **digest**, the mermaid `diagram`, and the resolved model, so it
300
+ returns in milliseconds. The digest is taken over the deterministic **semantic** BPMN (the diagram
301
+ interchange is derived from it, so it is the canonical content of a graph) — one content address
302
+ shared across staging, `previewProposalBpmn`, `dispatchDeliveryGraph`, and the deploy id, so they
303
+ never drift. The expensive `layoutBpmn` is deferred to the **operator's** preview/dispatch
304
+ (`previewProposalBpmn` recompiles the laid-out BPMN with DI on demand) — a cockpit action, not a
305
+ timeout-bound MCP call. `app/deliveryGraphStage.test.ts` and `e2e/heavy-tool-progress.e2e.ts` pin
306
+ that a large/dense graph stages fast rather than timing out.
307
+
217
308
  ## 5. Fallback
218
309
 
219
310
  Agents without MCP are unchanged — resolve the instance, then
@@ -0,0 +1,86 @@
1
+ // Heavy-tool timeout regression over the REAL runtime-served `/app/mcp` surface (issue #716, split
2
+ // from #715 gap 4).
3
+ //
4
+ // The agent-facing `compileDeliveryGraph` tool used to run the CPU-bound `layoutBpmn` pass inline, so
5
+ // a cold call on a large/dense graph (the issue cites up to 256 nodes / 1024 edges) blew past the
6
+ // client's per-call MCP timeout and returned `-32001 Request timed out` — poisoning the session (#715).
7
+ // The fix stages on a layout-free fast path (the digest is taken over the deterministic semantic BPMN;
8
+ // the expensive layout is deferred to the operator's preview/dispatch).
9
+ //
10
+ // This drives the exact client handshake an agent uses (via the S1 harness) and asserts a large graph
11
+ // gets an IMMEDIATE accepted `status:"ready"` staged response — not a timeout — and that the staged
12
+ // digest is immediately visible over the same surface. It does NOT re-implement the transport (see the
13
+ // harness header's EXTENSION SEAM).
14
+ //
15
+ // Run with `npm run e2e`.
16
+ import assert from "node:assert/strict";
17
+ import { after, before, describe, test } from "node:test";
18
+ import { assertObjectBodyAccepted, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
19
+
20
+ const TOOL = "compileDeliveryGraph";
21
+
22
+ /** A large, DENSE delivery graph — `n` agent nodes, each edged from its `fan` predecessors. At
23
+ * `n=256, fan=4` that is ~1020 edges: the exact class the issue names, and one `layoutBpmn` takes
24
+ * MINUTES on. The layout-free stage path compiles it in tens of milliseconds. */
25
+ function denseGraph(n: number, fan: number): { name: string; nodes: unknown[]; edges: unknown[] } {
26
+ const nodes: unknown[] = [];
27
+ const edges: unknown[] = [];
28
+ for (let i = 0; i < n; i++) {
29
+ nodes.push({ id: `n${i}`, kind: "agent", agent: { jobType: "senior:feature", prompt: `task ${i}` } });
30
+ }
31
+ for (let i = 1; i < n; i++) {
32
+ for (let f = 1; f <= fan && i - f >= 0; f++) edges.push({ from: `n${i - f}`, to: `n${i}` });
33
+ }
34
+ return { name: "dense mcp bench", nodes, edges };
35
+ }
36
+
37
+ interface ListBody {
38
+ count: number;
39
+ proposals: Array<{ digest: string; title: string | null }>;
40
+ }
41
+
42
+ describe("#716 — a heavy compile/stage tool returns an accepted response (not a timeout) over MCP", () => {
43
+ let h: McpHarness;
44
+ before(async () => {
45
+ h = await bootMcpHarness();
46
+ });
47
+ after(async () => {
48
+ await h.stop();
49
+ });
50
+
51
+ test("a cold, large/dense compileDeliveryGraph call stages FAST rather than timing out", async () => {
52
+ const graph = denseGraph(256, 4);
53
+ assert.ok(graph.edges.length > 1000, "the fixture really is dense (the layout-heavy class)");
54
+
55
+ const started = performance.now();
56
+ const res = await h.callTool(TOOL, { body: graph });
57
+ const elapsedMs = performance.now() - started;
58
+
59
+ // The object body arrived as an object (S0 invariant) and the call SUCCEEDED (a real client would
60
+ // have timed out with -32001 on the old layout-inline path).
61
+ assertObjectBodyAccepted(res, TOOL);
62
+ assert.ok(!res.isError, `${TOOL} on a large graph must stage, not error/time out: ${res.text}`);
63
+
64
+ const json = res.json as { status?: string; digest?: string; reviewUrl?: string } | undefined;
65
+ assert.equal(json?.status, "ready", `${TOOL} must return an accepted staged response: ${res.text}`);
66
+ assert.ok(typeof json?.digest === "string" && json.digest.length > 0, "the staged response carries a digest");
67
+
68
+ // The whole point of #716: the tool must not pay the layout tax (minutes on this graph). The
69
+ // layout-free path is tens of ms; a 20s bound is a huge margin over the passing path and well under
70
+ // the failing (layout) path, so it separates them without flaking.
71
+ assert.ok(
72
+ elapsedMs < 20_000,
73
+ `a heavy tool must return promptly — took ${elapsedMs.toFixed(0)}ms (an inline layout would take minutes)`,
74
+ );
75
+
76
+ // The staged proposal is immediately observable over the SAME surface — the operator can find it
77
+ // and drive the (deferred) layout at preview/dispatch time.
78
+ const list = await h.callTool("listStagedProposals", {});
79
+ assert.ok(!list.isError, `listStagedProposals must not error: ${list.text}`);
80
+ const listed = list.json as ListBody | undefined;
81
+ assert.ok(
82
+ listed?.proposals?.some((p) => p.digest === json?.digest),
83
+ `the staged digest ${json?.digest} must be listed: ${list.text}`,
84
+ );
85
+ });
86
+ });
@@ -0,0 +1,113 @@
1
+ // Session self-heal regression — the dominant #715 gap (gap 1).
2
+ //
3
+ // WHAT THIS PINS
4
+ // ==============
5
+ // The runtime-served MCP surface (`/app/mcp`, ADR 0067) is a **stateful streamable-HTTP** transport:
6
+ // every `tools/call` MUST carry a valid `mcp-session-id`, and a call with a missing / stale / evicted
7
+ // / deleted session id is refused with JSON-RPC `-32000 "Bad Request: no valid session id, and not an
8
+ // initialize request."` (mcp.ts). In the field (issue #715) a single hiccup — a heavy-tool timeout,
9
+ // an idle drop, a proxy reset, or LRU eviction (`MAX_SESSIONS`) — loses the session and then bricks
10
+ // the ENTIRE surface for a client that does not re-`initialize`: every subsequent tool reads as
11
+ // "tool does not exist". The stateless/resumable transport that would remove the session dependency
12
+ // lives in the urban runtime and is tracked upstream (nano-ide#488); until it lands, the
13
+ // **workforce-visible requirement** (this issue) is that the surface is RECOVERABLE — a client that
14
+ // re-`initialize`s after a `-32000` gets the WHOLE surface back in one round trip, not a degraded one.
15
+ //
16
+ // This is the acceptance regression: "kill the session mid-flight and assert the next call still
17
+ // works." It kills the session two faithful ways — an unknown/stale id, and a server-side `DELETE`
18
+ // (the spec session-termination verb) of a live id — asserts each bricks a call with the exact
19
+ // `-32000` signature, then asserts a single client `reinitialize()` fully restores the surface
20
+ // (a working `tools/call` AND the complete `tools/list`). If a future runtime makes the transport
21
+ // stateless/resumable (nano-ide#488), the stale-id call simply stops erroring — this test then
22
+ // tightens to that stronger contract with a one-line change, never silently passing on a regression.
23
+ //
24
+ // Run with `npm run e2e`.
25
+ import assert from "node:assert/strict";
26
+ import { randomUUID } from "node:crypto";
27
+ import { after, before, describe, test } from "node:test";
28
+ import { bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
29
+
30
+ /** The exact runtime signature of a lost/absent session (mcp.ts). A recovered surface must NOT
31
+ * answer with this after a re-`initialize`. */
32
+ const NO_SESSION_SIGNATURE = "no valid session id";
33
+
34
+ /** Any "the session is gone" refusal: the runtime's own `-32000` "no valid session id" (unknown id)
35
+ * OR the SDK transport's "Session not found" (a terminated/DELETEd id). Either proves the call was
36
+ * refused because the session no longer exists — the field failure #715 gap 1 is about. */
37
+ const SESSION_GONE = /no valid session id|session not found/i;
38
+
39
+ /** A safe, side-effect-free read used as the "does the surface answer?" probe. */
40
+ const PROBE_TOOL = "getVersion";
41
+
42
+ describe("#715 gap 1 — a lost MCP session self-heals on client re-initialize", () => {
43
+ let h: McpHarness;
44
+ before(async () => {
45
+ h = await bootMcpHarness();
46
+ });
47
+ after(async () => {
48
+ await h.stop();
49
+ });
50
+
51
+ test("baseline: a call on the live session works", async () => {
52
+ const res = await h.callTool(PROBE_TOOL);
53
+ assert(!res.isError, `baseline ${PROBE_TOOL} should succeed on a live session: ${res.text}`);
54
+ });
55
+
56
+ test("an unknown/stale session id bricks a call with -32000, and re-initialize restores the surface", async () => {
57
+ // A stale id models an evicted (LRU / idle-dropped) or proxy-reset session the client still holds.
58
+ const staleId = `stale-${randomUUID()}`;
59
+ const bricked = await h.callToolAs(staleId, PROBE_TOOL);
60
+ assert(bricked.isError, "a call carrying a stale session id must be refused, not answered");
61
+ assert(
62
+ bricked.text.includes(NO_SESSION_SIGNATURE),
63
+ `a stale-session call must fail with the "${NO_SESSION_SIGNATURE}" signature, got: ${bricked.text}`,
64
+ );
65
+
66
+ // Client self-heal: re-run the handshake. The pinned runtime is stateful, so this is how a real
67
+ // client recovers (nano-ide#488 would make it unnecessary).
68
+ const newId = await h.reinitialize();
69
+ assert(newId && newId !== staleId, "reinitialize must mint a fresh session id");
70
+
71
+ // The NEXT call works — the whole surface is back, not a degraded subset.
72
+ const healed = await h.callTool(PROBE_TOOL);
73
+ assert(!healed.isError, `after reinitialize the surface must answer again: ${healed.text}`);
74
+ assert(
75
+ !healed.text.includes(NO_SESSION_SIGNATURE),
76
+ "a healed call must not still report a missing session",
77
+ );
78
+
79
+ // And the FULL projected catalogue is restored — the field failure was "every tool vanished".
80
+ const tools = await h.listTools();
81
+ assert(tools.length > 1, `the full tools/list must be restored after self-heal, got ${tools.length}`);
82
+ assert(
83
+ tools.some((t) => t.name === PROBE_TOOL),
84
+ `the restored catalogue must still project ${PROBE_TOOL}`,
85
+ );
86
+ });
87
+
88
+ test("a server-side DELETE ends the session mid-flight; the old id then bricks and re-initialize heals it", async () => {
89
+ // Prove the harness's current session is live first.
90
+ const before = await h.callTool(PROBE_TOOL);
91
+ assert(!before.isError, `session should be live before DELETE: ${before.text}`);
92
+ const killedId = h.sessionId;
93
+
94
+ // Kill it the spec way — DELETE terminates the session server-side (mcp.ts: "POST, DELETE").
95
+ const status = await h.deleteSession(killedId);
96
+ assert(status < 500, `DELETE session should not server-error, got ${status}`);
97
+
98
+ // The now-terminated id bricks a call — the mid-flight hiccup. A terminated session surfaces the
99
+ // SDK transport's "Session not found"; an unknown id surfaces the runtime's "no valid session id"
100
+ // — both mean the session is gone and the call was refused, not answered.
101
+ const bricked = await h.callToolAs(killedId, PROBE_TOOL);
102
+ assert(bricked.isError, "a call on a DELETEd session id must be refused");
103
+ assert(
104
+ SESSION_GONE.test(bricked.text),
105
+ `a DELETEd-session call must be refused as a gone session, got: ${bricked.text}`,
106
+ );
107
+
108
+ // Client re-initialize → the very next call works again.
109
+ await h.reinitialize();
110
+ const healed = await h.callTool(PROBE_TOOL);
111
+ assert(!healed.isError, `after reinitialize the surface must answer again: ${healed.text}`);
112
+ });
113
+ });
@@ -0,0 +1,114 @@
1
+ // Tractable-surface + heavy-tool budget guards (#715 gaps 2 & 4).
2
+ //
3
+ // WHAT THIS PINS
4
+ // ==============
5
+ // Gap 2 — the projected `/app/mcp` `tools/list` surface measures 56 tools / ~79 KB against the
6
+ // deployed instance (issue #715): large enough that an agent harness DEFERS the whole set behind a
7
+ // tool-search gate, and a client `"tools": ["*"]` imports all of it eagerly. Two workforce-visible
8
+ // guards keep it tractable, both derived from the ONE source of truth (`app/mcpToolSurface.ts`):
9
+ //
10
+ // • a BUDGET on the full surface (count + serialized bytes) so it can only shrink below the pinned
11
+ // ceilings — a fat new door that re-inflates it fails the build here;
12
+ // • a CURATED subset (`CURATED_MCP_TOOLS`) a client imports instead of `["*"]` — asserted to be
13
+ // materially smaller than the full surface AND to consist only of names that actually project,
14
+ // so the recommended allowlist can never point at a dead/renamed tool.
15
+ //
16
+ // Gap 4 — a cold heavy tool (synchronous BPMN layout: `previewDeliveryGraph` / `compileDeliveryGraph`)
17
+ // must complete well within a typical MCP client's per-call timeout, so a cold `tools/call` no longer
18
+ // `-32001`s (which then trips gap 1 by poisoning the session). Asserted against `HEAVY_TOOL_BUDGET_MS`.
19
+ //
20
+ // The full object-body / schema-self-containment contract (gap 3, nano-ide#501/#503) is pinned by the
21
+ // sibling `e2e/mcp-surface.e2e.ts`; this file adds only the tractability + latency dimensions.
22
+ //
23
+ // Run with `npm run e2e`.
24
+ import assert from "node:assert/strict";
25
+ import { after, before, describe, test } from "node:test";
26
+ import {
27
+ CURATED_MCP_TOOLS,
28
+ CURATED_MCP_TOOLS_BUDGET,
29
+ HEAVY_TOOL_BUDGET_MS,
30
+ MCP_SURFACE_BYTES_BUDGET,
31
+ MCP_TOOL_COUNT_BUDGET,
32
+ } from "../app/mcpToolSurface.ts";
33
+ import { bootMcpHarness, type McpHarness, type McpTool } from "./support/mcp-harness.ts";
34
+
35
+ describe("#715 gaps 2 & 4 — tractable MCP surface + heavy-tool latency budget", () => {
36
+ let h: McpHarness;
37
+ let tools: McpTool[];
38
+ before(async () => {
39
+ h = await bootMcpHarness();
40
+ tools = await h.listTools();
41
+ });
42
+ after(async () => {
43
+ await h.stop();
44
+ });
45
+
46
+ test("the full tools/list stays within the pinned count budget", () => {
47
+ assert(
48
+ tools.length <= MCP_TOOL_COUNT_BUDGET,
49
+ `projected MCP tool count ${tools.length} exceeds the budget ${MCP_TOOL_COUNT_BUDGET} — the ` +
50
+ `surface must not grow past the harness deferral point (issue #715). Either curate a door off ` +
51
+ `the surface (x-mcp) or, if this growth is intended, raise MCP_TOOL_COUNT_BUDGET deliberately ` +
52
+ `in app/mcpToolSurface.ts. Tools: ${tools.map((t) => t.name).sort().join(", ")}`,
53
+ );
54
+ });
55
+
56
+ test("the full tools/list stays within the pinned byte budget", () => {
57
+ const bytes = Buffer.byteLength(JSON.stringify(tools), "utf8");
58
+ assert(
59
+ bytes <= MCP_SURFACE_BYTES_BUDGET,
60
+ `serialized tools/list is ${bytes} bytes, over the budget ${MCP_SURFACE_BYTES_BUDGET} — a fat ` +
61
+ `new schema is re-inflating the surface (issue #715). Trim the schema, curate the door off, or ` +
62
+ `raise MCP_SURFACE_BYTES_BUDGET deliberately in app/mcpToolSurface.ts.`,
63
+ );
64
+ });
65
+
66
+ test("every curated tool actually projects onto the live surface (no dead allowlist entries)", () => {
67
+ const live = new Set(tools.map((t) => t.name));
68
+ const dead = CURATED_MCP_TOOLS.filter((name) => !live.has(name));
69
+ assert.deepEqual(
70
+ dead,
71
+ [],
72
+ `CURATED_MCP_TOOLS names ${JSON.stringify(dead)} do not project onto the live /app/mcp surface — ` +
73
+ `the recommended allowlist has drifted from the real tool set. Fix app/mcpToolSurface.ts.`,
74
+ );
75
+ });
76
+
77
+ test("the curated subset is materially smaller than the full surface (tractable import)", () => {
78
+ assert(
79
+ CURATED_MCP_TOOLS.length <= CURATED_MCP_TOOLS_BUDGET,
80
+ `the curated subset (${CURATED_MCP_TOOLS.length}) exceeds its budget ${CURATED_MCP_TOOLS_BUDGET} — ` +
81
+ `it is creeping back toward "*". Keep it to the tools an agent actually drives/reads.`,
82
+ );
83
+ assert(
84
+ CURATED_MCP_TOOLS.length < tools.length,
85
+ `the curated subset (${CURATED_MCP_TOOLS.length}) must be smaller than the full surface ` +
86
+ `(${tools.length}); otherwise importing it buys nothing over "*".`,
87
+ );
88
+ });
89
+
90
+ test("curated entries carry no duplicates", () => {
91
+ const seen = new Set<string>();
92
+ const dupes: string[] = [];
93
+ for (const name of CURATED_MCP_TOOLS) {
94
+ if (seen.has(name)) dupes.push(name);
95
+ seen.add(name);
96
+ }
97
+ assert.deepEqual(dupes, [], `CURATED_MCP_TOOLS has duplicate entries: ${JSON.stringify(dupes)}`);
98
+ });
99
+
100
+ test("a cold heavy tool (previewDeliveryGraph) completes within the client budget — no -32001", async () => {
101
+ // A minimal valid graph → real synchronous BPMN layout, the exact heavy path issue #715 saw
102
+ // time out cold. Pure door (nothing staged), so this is safe and repeatable.
103
+ const graphJson = JSON.stringify({ nodes: [{ id: "h", kind: "human" }] });
104
+ const t0 = Date.now();
105
+ const res = await h.callTool("previewDeliveryGraph", { body: { graphJson } });
106
+ const elapsed = Date.now() - t0;
107
+ assert(!res.isError, `previewDeliveryGraph should succeed on a valid graph: ${res.text}`);
108
+ assert(
109
+ elapsed <= HEAVY_TOOL_BUDGET_MS,
110
+ `cold previewDeliveryGraph took ${elapsed}ms, over the client budget ${HEAVY_TOOL_BUDGET_MS}ms — ` +
111
+ `a heavy synchronous door this slow risks the client -32001 that poisons the session (issue #715).`,
112
+ );
113
+ });
114
+ });
@@ -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
- let sessionId: string;
225
- try {
226
- // 1. initialize capture the runtime-minted session id.
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
- sessionId = mintedId;
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
- // 2. notifications/initialized — the client's post-init notification (no response expected).
246
- await rpc("notifications/initialized", undefined, sessionId);
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
- rpc: (method, params) => rpc(method, params, sessionId),
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", {}, sessionId);
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
- const res = await rpc("tools/call", { name, arguments: args }, sessionId, extraHeaders);
267
- const body = res.body as
268
- | { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
269
- | undefined;
270
- if (body?.error) {
271
- // A JSON-RPC-level error (e.g. an unknown tool name / protocol error) distinct from a
272
- // tool-level `isError` door failure. Surface it as an errored result carrying the message.
273
- const text = body.error.message ?? JSON.stringify(body.error);
274
- return { isError: true, text, json: safeParse(text), httpStatus: res.httpStatus, raw: body };
275
- }
276
- const first = body?.result?.content?.find((c) => c.type === "text");
277
- const text = first?.text ?? "";
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;
@@ -81,7 +81,7 @@ export default defineOperation("previewProposalBpmn", async ({ body }, app) => {
81
81
  // Determinism guard: the recompiled BPMN must content-address back to the requested digest. A mismatch
82
82
  // means the stored graph drifted from its digest — refuse rather than serve a diagram that doesn't
83
83
  // match the proposal the operator is about to dispatch.
84
- const recompiledDigest = deliveryGraphDigest(compiled.bpmn);
84
+ const recompiledDigest = deliveryGraphDigest(compiled.semanticBpmn);
85
85
  if (recompiledDigest !== digest) {
86
86
  app.log.error("preview-proposal-bpmn: digest drift", { digest, recompiledDigest });
87
87
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.177.0",
3
+ "version": "0.178.1",
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",
@@ -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",