@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 +6 -0
- package/app/mcpToolSurface.test.ts +63 -0
- package/app/mcpToolSurface.ts +112 -0
- package/docs/mcp-runbook.md +76 -0
- package/e2e/mcp-session-selfheal.e2e.ts +113 -0
- package/e2e/mcp-tractability.e2e.ts +114 -0
- package/e2e/support/mcp-harness.ts +81 -28
- package/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/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;
|
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
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.178.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
|
|
52
52
|
"gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
|
|
53
53
|
"check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
|
|
54
|
+
"sync:mcp-curated": "node --experimental-strip-types scripts/sync-mcp-curated.ts",
|
|
55
|
+
"sync:mcp-curated:check": "node --experimental-strip-types scripts/sync-mcp-curated.ts --check",
|
|
54
56
|
"gen:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts",
|
|
55
57
|
"check:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts --check",
|
|
56
58
|
"dev": "urban dev",
|
package/pages/mcp.page.json
CHANGED
|
@@ -106,6 +106,32 @@
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"type": "text",
|
|
111
|
+
"id": "tractable-heading",
|
|
112
|
+
"props": { "text": "Import a curated tool set (tractable surface)", "variant": "heading" }
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"type": "text",
|
|
116
|
+
"id": "tractable-body",
|
|
117
|
+
"props": {
|
|
118
|
+
"text": "The full projected surface is ~56 tools / ~79 KB of tools/list (app operations plus the framework urban_debug_* engine family) \u2014 large enough that a coding-agent harness may defer the whole set behind a tool-search gate, and \"tools\": [\"*\"] imports every one eagerly. Prefer a curated allowlist of the tools you actually drive and read with: set the server entry's \"tools\" to the curated subset instead of [\"*\"]. The curated list is maintained as the single source of truth in the repo (app/mcpToolSurface.ts, CURATED_MCP_TOOLS) and rendered as a copyable JSON block in the runbook \u2014 see docs/mcp-runbook.md \u00a7\"Import a curated subset\". [\"*\"] still works where the client does not defer; the full surface stays reachable either way.",
|
|
119
|
+
"variant": "sub"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "text",
|
|
124
|
+
"id": "session-recovery-heading",
|
|
125
|
+
"props": { "text": "Recover a lost session (re-initialize on -32000)", "variant": "heading" }
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"type": "text",
|
|
129
|
+
"id": "session-recovery-body",
|
|
130
|
+
"props": {
|
|
131
|
+
"text": "The /app/mcp transport is stateful streamable-HTTP: every tool call carries an mcp-session-id, and a call with a missing / stale / idle-dropped / proxy-reset / evicted session id is refused with -32000 \"no valid session id, and not an initialize request.\" A client that does not re-handshake then sees the whole surface report \"tool does not exist\" until it re-initializes \u2014 a single hiccup (notably a heavy-tool timeout) can brick every tool. The self-heal is a fresh initialize handshake, which mints a new session and restores the entire catalogue in one round trip; a well-behaved MCP client does this automatically on a -32000. A stateless/resumable transport that removes the session dependency is tracked upstream (nano-ide#488).",
|
|
132
|
+
"variant": "sub"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
109
135
|
{
|
|
110
136
|
"type": "text",
|
|
111
137
|
"id": "secret-heading",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Drift guard for the curated MCP tool block in the runbook (issue #715).
|
|
2
|
+
//
|
|
3
|
+
// The curated `"tools"` allowlist is authored once in `app/mcpToolSurface.ts` and rendered into
|
|
4
|
+
// `docs/mcp-runbook.md` by `scripts/sync-mcp-curated.ts`. This test — run under `npm test`, which CI
|
|
5
|
+
// already gates — fails if the runbook block drifts from the source of truth, so the enforcement does
|
|
6
|
+
// not depend on a separate workflow step (AGENTS.md: "Derivation over duplication: no drift
|
|
7
|
+
// surfaces"). Run `npm run sync:mcp-curated` to reconcile. Mirrors `scripts/sync-nav.test.ts`.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert } from "#test-assert";
|
|
10
|
+
import { reconciledRunbook } from "./sync-mcp-curated.ts";
|
|
11
|
+
|
|
12
|
+
test("docs/mcp-runbook.md curated-tools block is in sync with app/mcpToolSurface.ts", () => {
|
|
13
|
+
const { current, next } = reconciledRunbook();
|
|
14
|
+
assert(
|
|
15
|
+
current === next,
|
|
16
|
+
"docs/mcp-runbook.md curated-tools block is STALE vs app/mcpToolSurface.ts — " +
|
|
17
|
+
"run `npm run sync:mcp-curated` and commit the result.",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Render the curated MCP tool allowlist (`CURATED_MCP_TOOLS`, app/mcpToolSurface.ts) into the runbook
|
|
2
|
+
// (docs/mcp-runbook.md) between its `curated-tools` sentinels (issue #715).
|
|
3
|
+
//
|
|
4
|
+
// The curated `"tools"` allowlist an MCP client imports instead of `["*"]` (the tractable-surface
|
|
5
|
+
// subset) is authored ONCE in `app/mcpToolSurface.ts`. The runbook shows it as a copyable JSON block;
|
|
6
|
+
// this script derives that block from the source so the two can never drift (AGENTS.md "no drift
|
|
7
|
+
// surfaces"), mirroring the repo's other derive/verify pairs (sync-nav, inline-mcp-bodies, layout-bpmn).
|
|
8
|
+
//
|
|
9
|
+
// node --experimental-strip-types scripts/sync-mcp-curated.ts # write the runbook block
|
|
10
|
+
// node --experimental-strip-types scripts/sync-mcp-curated.ts --check # verify (CI) — non-zero on drift
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { CURATED_MCP_TOOLS } from "../app/mcpToolSurface.ts";
|
|
15
|
+
|
|
16
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
17
|
+
export const RUNBOOK_PATH = `${ROOT}docs/mcp-runbook.md`;
|
|
18
|
+
|
|
19
|
+
const BEGIN = "<!-- BEGIN GENERATED: curated-tools (npm run sync:mcp-curated) -->";
|
|
20
|
+
const END = "<!-- END GENERATED: curated-tools -->";
|
|
21
|
+
|
|
22
|
+
/** The generated block: a fenced JSON array of curated tool names, one per line, in authored order. */
|
|
23
|
+
export function renderBlock(): string {
|
|
24
|
+
const lines = CURATED_MCP_TOOLS.map((name, i) => {
|
|
25
|
+
const comma = i === CURATED_MCP_TOOLS.length - 1 ? "" : ",";
|
|
26
|
+
return ` ${JSON.stringify(name)}${comma}`;
|
|
27
|
+
});
|
|
28
|
+
return ["```json", "[", ...lines, "]", "```"].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function replaceBetweenSentinels(source: string, block: string): string {
|
|
32
|
+
const begin = source.indexOf(BEGIN);
|
|
33
|
+
const end = source.indexOf(END);
|
|
34
|
+
if (begin === -1 || end === -1 || end < begin) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`docs/mcp-runbook.md is missing the curated-tools sentinels (${BEGIN} … ${END}). ` +
|
|
37
|
+
"Restore them so the generated block has a home.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const before = source.slice(0, begin + BEGIN.length);
|
|
41
|
+
const after = source.slice(end);
|
|
42
|
+
return `${before}\n${block}\n${after}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The runbook content the generator WOULD write given its current on-disk state. */
|
|
46
|
+
export function reconciledRunbook(): { current: string; next: string } {
|
|
47
|
+
const current = readFileSync(RUNBOOK_PATH, "utf8");
|
|
48
|
+
return { current, next: replaceBetweenSentinels(current, renderBlock()) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function main(): void {
|
|
52
|
+
const check = process.argv.includes("--check");
|
|
53
|
+
const { current, next } = reconciledRunbook();
|
|
54
|
+
|
|
55
|
+
if (check) {
|
|
56
|
+
if (current !== next) {
|
|
57
|
+
console.error(
|
|
58
|
+
"docs/mcp-runbook.md curated-tools block is STALE vs app/mcpToolSurface.ts. " +
|
|
59
|
+
"Run `npm run sync:mcp-curated` and commit the result.",
|
|
60
|
+
);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
console.log("sync:mcp-curated: runbook curated-tools block is up to date.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (current !== next) {
|
|
67
|
+
writeFileSync(RUNBOOK_PATH, next);
|
|
68
|
+
console.log("sync:mcp-curated: wrote curated-tools block into docs/mcp-runbook.md.");
|
|
69
|
+
} else {
|
|
70
|
+
console.log("sync:mcp-curated: runbook curated-tools block already up to date.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Run as a CLI only when invoked directly (not when imported by the drift test).
|
|
75
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
76
|
+
main();
|
|
77
|
+
}
|