@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.
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphCompiler.ts +66 -15
- package/app/deliveryGraphDispatch.ts +1 -1
- package/app/deliveryGraphProposals.ts +6 -2
- package/app/deliveryGraphStage.test.ts +145 -0
- package/app/deliveryGraphStage.ts +4 -4
- package/app/deliveryGraphTextIngress.ts +1 -1
- package/app/deliveryRunner.ts +18 -9
- package/app/mcpToolSurface.test.ts +63 -0
- package/app/mcpToolSurface.ts +112 -0
- package/docs/mcp-runbook.md +91 -0
- package/e2e/heavy-tool-progress.e2e.ts +86 -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/previewProposalBpmn.ts +1 -1
- package/package.json +3 -1
- 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/docs/mcp-runbook.md
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -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.
|
|
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.
|
|
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",
|
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",
|