@nanobpm/nano-workforce 0.177.0 → 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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.178.0](https://github.com/nanobpm/nano-workforce/compare/v0.177.0...v0.178.0) (2026-09-03)
2
+
3
+ ### Features
4
+
5
+ * **mcp:** pin the workforce-visible MCP surface — session self-heal, tractable-surface budget, heavy-tool latency ([#717](https://github.com/nanobpm/nano-workforce/issues/717)) ([de55daa](https://github.com/nanobpm/nano-workforce/commit/de55daa1b5fa134c7fc0e08c96041113fecd1481)), closes [488/#501](https://github.com/488/nano-workforce/issues/501) [#715](https://github.com/nanobpm/nano-workforce/issues/715)
6
+
1
7
  ## [0.177.0](https://github.com/nanobpm/nano-workforce/compare/v0.176.1...v0.177.0) (2026-09-02)
2
8
 
3
9
  ### Features
@@ -0,0 +1,63 @@
1
+ // Authoring guard for the curated MCP tool subset (`app/mcpToolSurface.ts`, issue #715).
2
+ //
3
+ // The curated `CURATED_MCP_TOOLS` allowlist is the tractable subset a client imports instead of
4
+ // `["*"]`. `e2e/mcp-tractability.e2e.ts` proves every entry projects onto the LIVE surface, but that
5
+ // needs a booted instance. This fast unit guard checks the same list against the SAME framework
6
+ // walker the runtime MCP projector uses (`parseSpec` + `collectOperations`) so an authoring typo —
7
+ // a curated app-tool name that is not an `openapi.yaml` operationId, or one accidentally `x-mcp`-
8
+ // excluded — fails CI in `npm test` without booting anything. Framework `urban_debug_*` tools are
9
+ // not `openapi.yaml` operations, so they are validated by their reserved prefix instead.
10
+ //
11
+ // Derivation over duplication (AGENTS.md): the exclusion/projection rule is NOT re-implemented here —
12
+ // it is read from the framework walker's `mcpExcluded` flag, the exact rule the runtime honours.
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { test } from "node:test";
17
+ import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
18
+ import { CURATED_MCP_TOOLS, FRAMEWORK_TOOL_PREFIX } from "../app/mcpToolSurface.ts";
19
+ import { assert } from "#test-assert";
20
+
21
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
22
+ const SPEC_PATH = join(REPO_ROOT, "openapi.yaml");
23
+
24
+ function projectedAppTools(): Set<string> {
25
+ return new Set(
26
+ collectOperations(parseSpec(readFileSync(SPEC_PATH, "utf8")))
27
+ .filter((op) => !op.mcpExcluded)
28
+ .map((op) => op.operationId),
29
+ );
30
+ }
31
+
32
+ test("every curated APP tool is a projected (non-x-mcp) openapi operation", () => {
33
+ const projected = projectedAppTools();
34
+ for (const name of CURATED_MCP_TOOLS) {
35
+ if (name.startsWith(FRAMEWORK_TOOL_PREFIX)) continue; // framework tool — validated by prefix below
36
+ assert(
37
+ projected.has(name),
38
+ `curated tool "${name}" is not a projected openapi operation — it is missing from openapi.yaml ` +
39
+ `or has been x-mcp-excluded. A client importing the curated allowlist would silently not get it.`,
40
+ );
41
+ }
42
+ });
43
+
44
+ test("every curated FRAMEWORK tool carries the reserved urban_debug_ prefix", () => {
45
+ const projected = projectedAppTools();
46
+ for (const name of CURATED_MCP_TOOLS) {
47
+ if (!name.startsWith(FRAMEWORK_TOOL_PREFIX)) continue;
48
+ // A framework name must NOT also be an app operationId (that would be a namespace collision the
49
+ // runtime reserves against) — it is owned entirely by the runtime's engine-debug family.
50
+ assert(
51
+ !projected.has(name),
52
+ `curated framework tool "${name}" unexpectedly collides with an openapi operationId.`,
53
+ );
54
+ }
55
+ });
56
+
57
+ test("the curated subset has no duplicate entries", () => {
58
+ const seen = new Set<string>();
59
+ for (const name of CURATED_MCP_TOOLS) {
60
+ assert(!seen.has(name), `CURATED_MCP_TOOLS lists "${name}" more than once.`);
61
+ seen.add(name);
62
+ }
63
+ });
@@ -0,0 +1,112 @@
1
+ // Canonical source of truth for the workforce MCP tool SURFACE budget and the curated driving
2
+ // subset (issue #715, epic #605 "tractable surface").
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ===============
6
+ // The runtime-served MCP surface (`/app/mcp`, ADR 0067) projects EVERY non-`x-mcp` `openapi.yaml`
7
+ // operation — plus the framework-owned `urban_debug_*` engine tools — into a tool. Measured against
8
+ // the deployed surface (issue #715) that is **56 tools / ~79 KB of `tools/list`**: large enough that
9
+ // an agent harness (Copilot CLI and others) DEFERS the whole set behind a tool-search gate, and a
10
+ // client config of `"tools": ["*"]` imports all 56 eagerly. That is the #605 "tractable surface"
11
+ // problem, quantified.
12
+ //
13
+ // The workforce-side lever is TWO-fold, and both live here as one source of truth:
14
+ //
15
+ // 1. A **budget** on the full projected surface (count + bytes), so the surface can only ever
16
+ // SHRINK below these ceilings — a new door that pushes it over fails CI (`e2e/mcp-tractability.e2e.ts`).
17
+ // The transport-level count reduction (gating rarely-used framework `urban_debug_*` tools
18
+ // behind a mode) lives in the urban runtime and is tracked upstream (nano-ide#488); this budget
19
+ // pins the workforce-visible number so it cannot regress while that lands.
20
+ // 2. A **curated subset** — the tools an agent actually drives/reads with day to day — that a
21
+ // client imports via its MCP-server `"tools"` allowlist INSTEAD of `["*"]`, so the eagerly-loaded
22
+ // set is materially smaller than the full surface and stays under the harness deferral threshold.
23
+ // This is the "documented curated subset" the issue's acceptance allows.
24
+ //
25
+ // DERIVATION OVER DUPLICATION (AGENTS.md)
26
+ // =======================================
27
+ // This list is the ONE authored source. `e2e/mcp-tractability.e2e.ts` asserts every name here
28
+ // actually projects onto the LIVE `/app/mcp` surface (so a curated entry can never go dead), and
29
+ // `scripts/sync-mcp-curated.ts` renders it verbatim into the runbook (`docs/mcp-runbook.md`) — a
30
+ // drift test under `npm test` (`scripts/sync-mcp-curated.test.ts`, which CI runs) and
31
+ // `npm run sync:mcp-curated:check` both fail on any drift. The served "Connect over MCP" page
32
+ // (`pages/mcp.page.json`) carries the concept as prose and points here + at the runbook, so there is
33
+ // no second enumerated copy to drift. Never hand-edit the curated `tools` block in the runbook; edit
34
+ // HERE and re-run `npm run sync:mcp-curated`.
35
+
36
+ /**
37
+ * The curated driving/reading subset an MCP client should import via its server entry's `"tools"`
38
+ * allowlist instead of `["*"]`. Grouped by intent in authoring order; the union is what a workforce
39
+ * operator/agent needs to drive work, read status, answer escalations, and triage a wedged instance —
40
+ * WITHOUT eagerly loading the whole 56-tool surface. Every name is asserted to project onto the live
41
+ * surface by `e2e/mcp-tractability.e2e.ts`.
42
+ */
43
+ export const CURATED_MCP_TOOLS: readonly string[] = [
44
+ // ── Drive / act ──────────────────────────────────────────────────────────
45
+ "startConvergenceLoop",
46
+ "startPlanFanout",
47
+ "startEpicSet",
48
+ "startFeature",
49
+ "compileDeliveryGraph",
50
+ "previewDeliveryGraph",
51
+ "sequenceIssues",
52
+ "agentCompleteEscalation",
53
+ "completeUserTask",
54
+ "cancelInstance",
55
+ "appendBlackboard",
56
+ "readBlackboard",
57
+ // ── Read / orient ────────────────────────────────────────────────────────
58
+ "getVersion",
59
+ "getAgentInstructions",
60
+ "getAgentGuide",
61
+ "listActivePrs",
62
+ "listStagedProposals",
63
+ "listEscalations",
64
+ "getLineage",
65
+ "getPrHistory",
66
+ // ── Engine-truth reads (wedge triage) ────────────────────────────────────
67
+ "urban_debug_search_process_instances",
68
+ "urban_debug_search_element_instance_wait_states",
69
+ "urban_debug_search_incidents",
70
+ "urban_debug_search_variables",
71
+ "urban_debug_search_jobs",
72
+ "urban_debug_instance_state",
73
+ "urban_debug_open_user_tasks",
74
+ ];
75
+
76
+ /** The framework-reserved namespace for engine-debug tools (mirrors the runtime's `DEBUG_PREFIX`).
77
+ * A curated entry with this prefix is a framework tool (not an `openapi.yaml` operation), so the
78
+ * spec-level unit guard validates it by prefix rather than against the projected operation set. */
79
+ export const FRAMEWORK_TOOL_PREFIX = "urban_debug_";
80
+
81
+ /**
82
+ * Hard CEILING on the projected `tools/list` tool count. The deployed surface measures 56 (issue
83
+ * #715); this budget forbids GROWTH — a new door that pushes the count over fails CI. It is a
84
+ * regression guard, not the reduction itself: the reduction an agent actually experiences comes from
85
+ * importing {@link CURATED_MCP_TOOLS} rather than `["*"]`, and the transport-level shrink of the
86
+ * framework tool family is tracked upstream (nano-ide#488).
87
+ */
88
+ export const MCP_TOOL_COUNT_BUDGET = 60;
89
+
90
+ /**
91
+ * Hard CEILING on the serialized byte size of the full `tools/list` payload (the schema bytes a
92
+ * client must parse). The deployed surface measures ~78,962 bytes (issue #715); this ceiling forbids
93
+ * meaningful growth so a fat new schema cannot silently re-inflate the surface past the harness
94
+ * deferral point.
95
+ */
96
+ export const MCP_SURFACE_BYTES_BUDGET = 84_000;
97
+
98
+ /**
99
+ * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
100
+ * is not "tractable". This ceiling pins the curated set at roughly half the full count so a creeping
101
+ * curation cannot quietly grow back toward `["*"]`.
102
+ */
103
+ export const CURATED_MCP_TOOLS_BUDGET = 30;
104
+
105
+ /**
106
+ * The per-call budget (ms) a heavy tool (synchronous BPMN layout — `compileDeliveryGraph` /
107
+ * `previewDeliveryGraph` / `sequenceIssues`) must complete within so a cold call does not exceed a
108
+ * typical MCP client's request timeout and `-32001` (issue #715 gap 4). Measured cold at ~0.5 s in
109
+ * the hermetic harness; the 4 s ceiling leaves generous headroom while still failing loudly if a
110
+ * heavy door regresses into a multi-second stall.
111
+ */
112
+ export const HEAVY_TOOL_BUDGET_MS = 4_000;
@@ -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
@@ -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;
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.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",
@@ -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
+ }