@nanobpm/nano-workforce 0.158.1 → 0.159.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/.github/workflows/ci.yml +8 -0
- package/.github/workflows/invariants.yml +7 -0
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphProposals.test.ts +16 -0
- package/app/deliveryGraphProposals.ts +33 -13
- package/docs/mcp-runbook.md +46 -0
- package/e2e/delivery-graph-read-after-write.e2e.ts +132 -0
- package/e2e/mcp-surface.e2e.ts +235 -0
- package/e2e/support/mcp-harness.ts +373 -0
- package/openapi.yaml +1140 -18
- package/package.json +5 -2
- package/scripts/inline-mcp-bodies.ts +316 -0
- package/test/mcp-tool-schemas.test.ts +127 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -72,6 +72,14 @@ jobs:
|
|
|
72
72
|
- name: Check contract registry (no synonyms / undeclared keys)
|
|
73
73
|
run: npm run check:contracts
|
|
74
74
|
|
|
75
|
+
# MCP tool-schema gate (epic #605, S0): every projected (non-`x-mcp`) request-body operation
|
|
76
|
+
# must carry a self-contained, `$ref`-free inline `body` schema — the runtime projector copies
|
|
77
|
+
# it VERBATIM into the tool `inputSchema`, so a leaked `$ref` is unresolvable in a standard MCP
|
|
78
|
+
# client (nano-ide#502). The inline bodies are DERIVED from `components.schemas` by
|
|
79
|
+
# scripts/inline-mcp-bodies.ts; this fails if a source component changed without regenerating.
|
|
80
|
+
- name: Check MCP tool-body schemas (inline, $ref-free)
|
|
81
|
+
run: npm run check:mcp-bodies
|
|
82
|
+
|
|
75
83
|
# Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
|
|
76
84
|
# TypeScript types on the fly (Node >= 22.6) — no build step.
|
|
77
85
|
- name: Test (Node)
|
|
@@ -77,6 +77,13 @@ jobs:
|
|
|
77
77
|
- name: Check navigation index freshness
|
|
78
78
|
run: npm run sync:nav:check
|
|
79
79
|
|
|
80
|
+
# Projected MCP tool bodies (openapi.yaml) are a checked-in derived artifact too (epic #605 S0):
|
|
81
|
+
# each is DERIVED from `components.schemas` by scripts/inline-mcp-bodies.ts. Two branches can
|
|
82
|
+
# each edit a source component and its inline body in isolation yet leave the merged tree stale;
|
|
83
|
+
# re-assert freshness on the merged tree so a $ref cannot silently re-leak into the tool surface.
|
|
84
|
+
- name: Check MCP tool-body schemas freshness
|
|
85
|
+
run: npm run check:mcp-bodies
|
|
86
|
+
|
|
80
87
|
# Backstop: catch ANY other committed generated file that the merged sources render stale, even
|
|
81
88
|
# one without its own `--check` script above. A clean tree is the whole-repo invariant.
|
|
82
89
|
- name: No stale committed artifacts on the merged tree
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** read-after-write consistency for listStagedProposals (S2) ([#617](https://github.com/nanobpm/nano-workforce/issues/617)) ([bd0246c](https://github.com/nanobpm/nano-workforce/commit/bd0246cc81241041320aa483b0a0789f5e372031)), closes [#608](https://github.com/nanobpm/nano-workforce/issues/608)
|
|
6
|
+
|
|
7
|
+
## [0.159.0](https://github.com/nanobpm/nano-workforce/compare/v0.158.1...v0.159.0) (2026-08-29)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **openapi:** self-contained, $ref-free projected MCP tool schemas (S0) ([#614](https://github.com/nanobpm/nano-workforce/issues/614)) ([2018020](https://github.com/nanobpm/nano-workforce/commit/2018020a290c2f416e703e3584b27f92ccf27753)), closes [#605](https://github.com/nanobpm/nano-workforce/issues/605) [nano-ide#501](https://github.com/nanobpm/nano-ide/issues/501) [503/#504](https://github.com/503/nano-workforce/issues/504) [#606](https://github.com/nanobpm/nano-workforce/issues/606)
|
|
12
|
+
|
|
1
13
|
## [0.158.1](https://github.com/nanobpm/nano-workforce/compare/v0.158.0...v0.158.1) (2026-08-29)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DELIVERY_PROPOSAL_TTL_MS,
|
|
16
16
|
deliveryGraphProposals,
|
|
17
17
|
getStagedProposal,
|
|
18
|
+
isLiveStaged,
|
|
18
19
|
isProposalExpired,
|
|
19
20
|
markProposalDismissed,
|
|
20
21
|
markProposalDispatched,
|
|
@@ -82,6 +83,21 @@ test("isProposalExpired: past → true, future → false, blank/corrupt → true
|
|
|
82
83
|
assertEquals(isProposalExpired("garbage", at), true);
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
test("isLiveStaged: the ONE liveness predicate — only a `staged`, not-yet-expired row is live (#608)", () => {
|
|
87
|
+
const at = new Date("2024-06-01T00:00:00.000Z");
|
|
88
|
+
const live = row({ createdAt: "2024-05-31T23:00:00.000Z" }); // staged, TTL a day out → live
|
|
89
|
+
assertEquals(isLiveStaged(live, at), true);
|
|
90
|
+
// A future-created staged row is trivially live too (TTL further out).
|
|
91
|
+
assertEquals(isLiveStaged(row(), new Date()), true);
|
|
92
|
+
// Every non-`staged` status is NOT live, regardless of TTL.
|
|
93
|
+
for (const status of ["superseded", "dispatched", "expired", "dismissed"] as const) {
|
|
94
|
+
assertEquals(isLiveStaged({ ...live, status }, at), false);
|
|
95
|
+
}
|
|
96
|
+
// A `staged` row whose TTL has elapsed is NOT live (mirrors isProposalExpired, fail-closed).
|
|
97
|
+
assertEquals(isLiveStaged({ ...live, expires_at: "2024-05-30T00:00:00.000Z" }, at), false);
|
|
98
|
+
assertEquals(isLiveStaged({ ...live, expires_at: "" }, at), false);
|
|
99
|
+
});
|
|
100
|
+
|
|
85
101
|
test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a dispatch endpoint", () => {
|
|
86
102
|
const url = proposalReviewUrl("abc123", "https://cockpit.example");
|
|
87
103
|
assertEquals(url, "https://cockpit.example/app/pages/delivery-graphs#proposal-abc123");
|
|
@@ -207,9 +207,25 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
|
|
|
207
207
|
return toWrite;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
210
|
+
/** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
|
|
211
|
+
* `staged` (not superseded / dispatched / expired / dismissed) AND has not aged out of its TTL. This is
|
|
212
|
+
* the SINGLE SOURCE OF TRUTH the digest read (`getStagedProposal`), the list read
|
|
213
|
+
* (`listStagedProposals`), and — through them — the cockpit App-View, the dispatch/preview/dismiss
|
|
214
|
+
* doors, and the MCP `listStagedProposals` tool all resolve liveness through, so no two readers can ever
|
|
215
|
+
* drift on which rows are live (AGENTS.md "Derivation over duplication"). A read-after-write from
|
|
216
|
+
* `compileDeliveryGraph` is trustworthy because this predicate is evaluated over the SAME durable
|
|
217
|
+
* `delivery_graph_proposals` store the compile door commits the row to (the app's single default
|
|
218
|
+
* `app.data` source) — a freshly staged row (`status: 'staged'`, `expires_at` a full TTL in the future)
|
|
219
|
+
* is live by construction, with no projection/read-model between the write and the read to lag behind. */
|
|
220
|
+
export function isLiveStaged(row: DeliveryGraphProposal, at: Date = new Date()): boolean {
|
|
221
|
+
return row.status === "staged" && !isProposalExpired(row.expires_at, at);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Load a proposal that is live and dispatchable RIGHT NOW: it exists and satisfies {@link isLiveStaged}
|
|
225
|
+
* (it is `staged` — not superseded or already dispatched — and has not aged out of its TTL). Returns
|
|
226
|
+
* null otherwise, so the cockpit dispatch action refuses a stale/unknown/already-dispatched digest
|
|
227
|
+
* cleanly. Reads the authoritative `delivery_graph_proposals` row by primary key from the same
|
|
228
|
+
* `app.data` store the compile door stages into — a read-your-writes lookup, no read-model in between. */
|
|
213
229
|
export async function getStagedProposal(
|
|
214
230
|
data: DataLayer,
|
|
215
231
|
digest: string,
|
|
@@ -217,24 +233,28 @@ export async function getStagedProposal(
|
|
|
217
233
|
): Promise<DeliveryGraphProposal | null> {
|
|
218
234
|
const row = await deliveryGraphProposals(data).get(digest);
|
|
219
235
|
if (!row) return null;
|
|
220
|
-
|
|
221
|
-
if (isProposalExpired(row.expires_at, at)) return null;
|
|
222
|
-
return row;
|
|
236
|
+
return isLiveStaged(row, at) ? row : null;
|
|
223
237
|
}
|
|
224
238
|
|
|
225
|
-
/** Every LIVE staged proposal — `status = 'staged'` AND not aged out of its TTL —
|
|
226
|
-
* staged App-View (`pages/delivery-graphs/staged.mount.js`)
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
239
|
+
/** Every LIVE staged proposal — {@link isLiveStaged} (`status = 'staged'` AND not aged out of its TTL) —
|
|
240
|
+
* newest first. The staged App-View (`pages/delivery-graphs/staged.mount.js`) and the MCP
|
|
241
|
+
* `listStagedProposals` tool both poll THIS door to render/answer the Preview-DI + Dispatch list.
|
|
242
|
+
*
|
|
243
|
+
* READ-AFTER-WRITE GUARANTEE (issue #608). The list is served by a fresh query against the authoritative
|
|
244
|
+
* `delivery_graph_proposals` table on the app's single default `app.data` source — the SAME store, in
|
|
245
|
+
* the SAME scope, the `compileDeliveryGraph`/`stageProposal` write commits to. There is no
|
|
246
|
+
* projection/read-model or cache between the write and this read, so a digest `compileDeliveryGraph`
|
|
247
|
+
* just returned is listed on the very next call with NO intervening delay (the write is committed before
|
|
248
|
+
* the compile door responds). Liveness is decided by the shared {@link isLiveStaged} predicate — NOT a
|
|
249
|
+
* second `status = 'staged'` datasource filter, which cannot express an `expires_at > now` cutoff and so
|
|
250
|
+
* would linger an aged-out row until the poller's sweep realises the TTL. Read-only; no write. */
|
|
231
251
|
export async function listStagedProposals(
|
|
232
252
|
data: DataLayer,
|
|
233
253
|
at: Date = new Date(),
|
|
234
254
|
): Promise<DeliveryGraphProposal[]> {
|
|
235
255
|
const rows = await deliveryGraphProposals(data).find({ status: "staged" });
|
|
236
256
|
return rows
|
|
237
|
-
.filter((row) =>
|
|
257
|
+
.filter((row) => isLiveStaged(row, at))
|
|
238
258
|
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
239
259
|
}
|
|
240
260
|
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -124,6 +124,24 @@ cockpit *is* the approval (ADR 0005 Decision 7), so an agent cannot dispatch a d
|
|
|
124
124
|
graph through MCP. Agents author graphs through the pure `compileDeliveryGraph` /
|
|
125
125
|
`previewDeliveryGraph` doors, which stay exposed.
|
|
126
126
|
|
|
127
|
+
**Projected tool schemas are self-contained (epic #605, S0).** The projector copies each
|
|
128
|
+
operation's request-body schema *verbatim* into the tool's `inputSchema.properties.body`
|
|
129
|
+
and does **not** resolve `$ref`s, so every projected (non-`x-mcp`) request-body operation
|
|
130
|
+
in `openapi.yaml` presents an inline `type: object` body with no `$ref`; the two graph doors
|
|
131
|
+
additionally carry a worked `example` — an agent discovers the body shape (and calls the tool
|
|
132
|
+
with a real object, not a
|
|
133
|
+
stringified one) from the surface alone. The two graph doors split by convention:
|
|
134
|
+
`compileDeliveryGraph` takes the **structured `DeliveryGraph` object** (and *stages*);
|
|
135
|
+
`previewDeliveryGraph` takes the **text shape `{ "graphJson": "<serialized DeliveryGraph>" }`**
|
|
136
|
+
(and is *pure*). Every validation failure returns `issues`/`errors` as `[{ path, message }]`.
|
|
137
|
+
The inline bodies are **derived** from `components.schemas` by
|
|
138
|
+
`scripts/inline-mcp-bodies.ts` (single source of truth; run `npm run gen:mcp-bodies` after
|
|
139
|
+
editing a component), and `npm run check:mcp-bodies` + `test/mcp-tool-schemas.test.ts` (which
|
|
140
|
+
runs the real projector) fail CI if a `$ref` ever re-leaks. The upstream projector fix that
|
|
141
|
+
would make this mitigation unnecessary is tracked in
|
|
142
|
+
[nano-ide#501](https://github.com/nanobpm/nano-ide/issues/501) (#502 self-contained schemas,
|
|
143
|
+
#503 faithful object-body transport, #504 real-spec conformance guard).
|
|
144
|
+
|
|
127
145
|
## 5. Fallback
|
|
128
146
|
|
|
129
147
|
Agents without MCP are unchanged — resolve the instance, then
|
|
@@ -131,3 +149,31 @@ Agents without MCP are unchanged — resolve the instance, then
|
|
|
131
149
|
[`nano-workforce` skill](../skills/nano-workforce/SKILL.md), which fetches the same
|
|
132
150
|
live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
|
|
133
151
|
before.
|
|
152
|
+
|
|
153
|
+
## 6. Regression harness — pin the MCP surface from nwf's side
|
|
154
|
+
|
|
155
|
+
The MCP projection layer (schema shape, argument encoding, session handshake) is
|
|
156
|
+
covered end-to-end by a reusable e2e harness (epic #605 slice S1, issue #607):
|
|
157
|
+
`e2e/support/mcp-harness.ts`. It boots a hermetic in-process instance and drives
|
|
158
|
+
the **real** `/app/mcp` endpoint over the full Streamable-HTTP client handshake —
|
|
159
|
+
`initialize` → capture `Mcp-Session-Id` → `notifications/initialized` →
|
|
160
|
+
`tools/list` → `tools/call` — asserting the client-visible contract every agent
|
|
161
|
+
depends on:
|
|
162
|
+
|
|
163
|
+
- every projected tool schema is `$ref`-free with an explicit `type` (a leaked
|
|
164
|
+
`$ref` is unresolvable in the MCP context);
|
|
165
|
+
- an object argument arrives **as an object**, never coerced to a string;
|
|
166
|
+
- validation failures answer uniformly with `issues[{path,message}]`;
|
|
167
|
+
- side-effecting calls stage nothing, so the suite is safe to re-run.
|
|
168
|
+
|
|
169
|
+
It runs in CI under `npm run e2e` (hermetic — no socket, no GitHub), so a
|
|
170
|
+
reintroduced `$ref` or a stringified object body fails the build instead of
|
|
171
|
+
reaching an agent.
|
|
172
|
+
|
|
173
|
+
**Extending it (new per-tool case).** Import `bootMcpHarness` from
|
|
174
|
+
`e2e/support/mcp-harness.ts` in your own `e2e/<slice>.e2e.ts` and drive
|
|
175
|
+
`harness.listTools()` / `harness.callTool(name, args)` — the handshake, session
|
|
176
|
+
management and teardown are owned by the harness, so you add a `test(...)`, never
|
|
177
|
+
a second transport. The module header documents the seam and the exported
|
|
178
|
+
assertion helpers (`assertSchemaSelfContained`, `assertObjectBodyAccepted`,
|
|
179
|
+
`assertValidationIssues`) in full; `e2e/mcp-surface.e2e.ts` is the worked example.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Delivery-graph read-after-write regression net (epic #605 slice S2, issue #608).
|
|
2
|
+
//
|
|
3
|
+
// PINS the acceptance guarantee: immediately after `compileDeliveryGraph` returns a staged `digest`,
|
|
4
|
+
// `listStagedProposals` returns THAT digest — with NO intervening delay. In the evidence session
|
|
5
|
+
// (issue #608) a `compileDeliveryGraph` that returned `status:"ready"` with `digest:"ca8fb90a1b0c"`
|
|
6
|
+
// was followed by a `listStagedProposals` that answered `{"count":0,"proposals":[]}` — the read and
|
|
7
|
+
// the write had disagreed, so an agent could not confirm its own staging. The fix serves both from a
|
|
8
|
+
// single source of truth: the read (`listStagedProposals` / `getStagedProposal`, unified through the
|
|
9
|
+
// one `isLiveStaged` predicate) queries the SAME `delivery_graph_proposals` store, in the SAME scope,
|
|
10
|
+
// that the compile/stage write commits to — no read-model or cache in between.
|
|
11
|
+
//
|
|
12
|
+
// This case is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports
|
|
13
|
+
// `bootMcpHarness` and drives the REAL runtime-served `/app/mcp` surface over the client handshake —
|
|
14
|
+
// the exact transport an agent uses — so a regression that reintroduces the disagreement (a lagging
|
|
15
|
+
// projection, a divergent read filter, a supersede that drops the just-written row) fails the build.
|
|
16
|
+
// It does NOT re-implement the handshake (see the harness module header's EXTENSION SEAM).
|
|
17
|
+
//
|
|
18
|
+
// Run with `npm run e2e`.
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { after, before, describe, test } from "node:test";
|
|
21
|
+
import { assertObjectBodyAccepted, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
|
|
22
|
+
|
|
23
|
+
/** A minimal side-effecting graph — `compileDeliveryGraph` STAGES it (unlike the pure `previewDelivery
|
|
24
|
+
* Graph`), which is exactly the write whose visibility we verify. Two agent nodes → two side effects. */
|
|
25
|
+
const STAGES_A_PROPOSAL = {
|
|
26
|
+
name: "S2 read-after-write A",
|
|
27
|
+
nodes: [
|
|
28
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
29
|
+
{ id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
|
|
30
|
+
],
|
|
31
|
+
edges: [{ from: "open", to: "cut" }],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A dedicated logical key for the supersede test, distinct from the first test's key so the case is
|
|
35
|
+
* SELF-CONTAINED — it stages BOTH its own V1 and V2 in-test and asserts per-logical-key, rather than
|
|
36
|
+
* depending on an earlier test having staged a predecessor. */
|
|
37
|
+
const SUPERSEDE_KEY = "S2 supersede key";
|
|
38
|
+
|
|
39
|
+
/** V1 for the supersede test — the predecessor that staging V2 must retire. */
|
|
40
|
+
const SUPERSEDE_V1 = {
|
|
41
|
+
name: SUPERSEDE_KEY,
|
|
42
|
+
nodes: [
|
|
43
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
44
|
+
{ id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
|
|
45
|
+
],
|
|
46
|
+
edges: [{ from: "open", to: "cut" }],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** V2 — a STRUCTURALLY different graph (an extra node → different compiled BPMN → different content
|
|
50
|
+
* digest) sharing the SAME `name` (logical key) as V1, so staging it supersedes V1. The read after it
|
|
51
|
+
* must show the SECOND digest (the one the compile just returned), never the superseded first. */
|
|
52
|
+
const SUPERSEDE_V2 = {
|
|
53
|
+
name: SUPERSEDE_KEY,
|
|
54
|
+
nodes: [
|
|
55
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
|
|
56
|
+
{ id: "notes", kind: "agent", agent: { jobType: "senior:demo", prompt: "draft the release notes" } },
|
|
57
|
+
{ id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
|
|
58
|
+
],
|
|
59
|
+
edges: [
|
|
60
|
+
{ from: "open", to: "notes" },
|
|
61
|
+
{ from: "notes", to: "cut" },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
interface StagedRow { digest: string; title: string | null }
|
|
66
|
+
interface ListBody { count: number; proposals: StagedRow[] }
|
|
67
|
+
|
|
68
|
+
/** Compile a graph over MCP and return the staged digest the door reports (asserting it staged). */
|
|
69
|
+
async function compileAndStage(h: McpHarness, graph: unknown): Promise<string> {
|
|
70
|
+
const res = await h.callTool("compileDeliveryGraph", { body: graph });
|
|
71
|
+
assertObjectBodyAccepted(res, "compileDeliveryGraph"); // the object body was NOT stringified
|
|
72
|
+
assert.ok(!res.isError, `compileDeliveryGraph must stage a valid graph: ${res.text}`);
|
|
73
|
+
const json = res.json as { status?: string; digest?: string } | undefined;
|
|
74
|
+
assert.equal(json?.status, "ready", `compileDeliveryGraph must report status:"ready": ${res.text}`);
|
|
75
|
+
assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
|
|
76
|
+
return json.digest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The current live staged list, read over the SAME MCP surface. */
|
|
80
|
+
async function listStaged(h: McpHarness): Promise<ListBody> {
|
|
81
|
+
const res = await h.callTool("listStagedProposals", {});
|
|
82
|
+
assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
|
|
83
|
+
const json = res.json as ListBody | undefined;
|
|
84
|
+
assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
|
|
85
|
+
return json;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("S2 — listStagedProposals read-after-write is trustworthy (#608)", () => {
|
|
89
|
+
let h: McpHarness;
|
|
90
|
+
before(async () => { h = await bootMcpHarness(); });
|
|
91
|
+
after(async () => { await h.stop(); });
|
|
92
|
+
|
|
93
|
+
test("a digest compileDeliveryGraph just returned is listed on the very next call — no delay", async () => {
|
|
94
|
+
const digest = await compileAndStage(h, STAGES_A_PROPOSAL);
|
|
95
|
+
// The immediate read — no sleep, no retry, no poll — must show the write.
|
|
96
|
+
const list = await listStaged(h);
|
|
97
|
+
const digests = list.proposals.map((p) => p.digest);
|
|
98
|
+
assert.ok(
|
|
99
|
+
digests.includes(digest),
|
|
100
|
+
`the digest ${digest} compileDeliveryGraph just returned must appear in listStagedProposals ` +
|
|
101
|
+
`immediately (got ${JSON.stringify(digests)})`,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("supersede keeps read-after-write honest: the read shows the LATEST returned digest, not the superseded one", async () => {
|
|
106
|
+
// SELF-CONTAINED: stage V1 then V2 for the SAME logical key inside this test — V2 supersedes V1. The
|
|
107
|
+
// read must reflect the digest THIS compile returned (the live one) and the superseded predecessor
|
|
108
|
+
// must be gone, with no dependence on any other test having staged first.
|
|
109
|
+
const digestV1 = await compileAndStage(h, SUPERSEDE_V1);
|
|
110
|
+
const digestV2 = await compileAndStage(h, SUPERSEDE_V2);
|
|
111
|
+
assert.notEqual(digestV1, digestV2, "V1 and V2 must differ in content so V2 genuinely supersedes V1");
|
|
112
|
+
const list = await listStaged(h);
|
|
113
|
+
const digests = list.proposals.map((p) => p.digest);
|
|
114
|
+
assert.ok(
|
|
115
|
+
digests.includes(digestV2),
|
|
116
|
+
`after superseding, listStagedProposals must show the latest digest ${digestV2} (got ${JSON.stringify(digests)})`,
|
|
117
|
+
);
|
|
118
|
+
assert.ok(
|
|
119
|
+
!digests.includes(digestV1),
|
|
120
|
+
`the superseded predecessor digest ${digestV1} must be gone from listStagedProposals (got ${JSON.stringify(digests)})`,
|
|
121
|
+
);
|
|
122
|
+
// Exactly one live proposal FOR THIS logical key — filter by title so the assertion is per-logical-key
|
|
123
|
+
// and does not couple to how many other proposals exist in the shared store.
|
|
124
|
+
const forLogicalKey = list.proposals.filter((p) => p.title === SUPERSEDE_KEY);
|
|
125
|
+
assert.equal(
|
|
126
|
+
forLogicalKey.length,
|
|
127
|
+
1,
|
|
128
|
+
`exactly one live staged proposal must remain for the logical key ${JSON.stringify(SUPERSEDE_KEY)} (got ${JSON.stringify(forLogicalKey.map((p) => p.digest))})`,
|
|
129
|
+
);
|
|
130
|
+
assert.equal(forLogicalKey[0]?.digest, digestV2, "the sole remaining live proposal must be the latest digest");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// MCP surface end-to-end regression net (epic #605 slice S1, issue #607).
|
|
2
|
+
//
|
|
3
|
+
// Drives the app's REAL runtime-served MCP endpoint (`/app/mcp`, ADR 0067) over the full
|
|
4
|
+
// Streamable-HTTP client handshake — `initialize` → `Mcp-Session-Id` → `notifications/initialized`
|
|
5
|
+
// → `tools/list` → `tools/call` — against a hermetic in-process instance, via the reusable
|
|
6
|
+
// `e2e/support/mcp-harness.ts` module. It PINS the client-visible contract the S0 defect broke:
|
|
7
|
+
//
|
|
8
|
+
// • every projected tool schema is `$ref`-free with an explicit `type` (S0 / nano-ide#502);
|
|
9
|
+
// • an object argument arrives AS AN OBJECT, never coerced to a string (S0 / nano-ide#503);
|
|
10
|
+
// • validation failures answer uniformly with `issues[{path,message}]`;
|
|
11
|
+
// • the reads parse; the mutating framework tools are gated; side-effecting calls leave NO live
|
|
12
|
+
// staged proposal behind (safe to run repeatedly).
|
|
13
|
+
//
|
|
14
|
+
// It is the harness siblings extend: S2 (#608), S4 (#610) and S5 (#611) add their own per-tool case
|
|
15
|
+
// by importing `bootMcpHarness` — they do NOT re-implement the handshake. See the module header of
|
|
16
|
+
// `support/mcp-harness.ts` for the extension seam.
|
|
17
|
+
//
|
|
18
|
+
// Run with `npm run e2e`.
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { after, before, describe, test } from "node:test";
|
|
21
|
+
import {
|
|
22
|
+
assertObjectBodyAccepted,
|
|
23
|
+
assertSchemaSelfContained,
|
|
24
|
+
assertValidationIssues,
|
|
25
|
+
bootMcpHarness,
|
|
26
|
+
type McpHarness,
|
|
27
|
+
type McpTool,
|
|
28
|
+
MINIMAL_VALID_GRAPH,
|
|
29
|
+
schemaHasRef,
|
|
30
|
+
STRINGIFIED_BODY_MESSAGE,
|
|
31
|
+
} from "./support/mcp-harness.ts";
|
|
32
|
+
|
|
33
|
+
// The read tools the surface must expose and answer (issue #607 scope). Each is safe and repeatable.
|
|
34
|
+
const READ_TOOLS = [
|
|
35
|
+
"getVersion",
|
|
36
|
+
"getAgentInstructions",
|
|
37
|
+
"listActivePrs",
|
|
38
|
+
"listStagedProposals",
|
|
39
|
+
"getLineage",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
// The object-body doors covered by a `tools/call` (issue #607 scope). `previewDeliveryGraph` is the
|
|
43
|
+
// PURE positive proof (a valid graph, nothing staged). The rest are side-EFFECTING (they stage a
|
|
44
|
+
// proposal or start a process on a VALID body), so the harness drives them with a deliberately
|
|
45
|
+
// INVALID — but object-shaped — body: validation rejects it, which (a) proves the object argument
|
|
46
|
+
// reached the door AS AN OBJECT and (b) persists nothing, keeping the harness repeatable.
|
|
47
|
+
const OBJECT_BODY_DOORS = [
|
|
48
|
+
"compileDeliveryGraph",
|
|
49
|
+
"startConvergenceLoop",
|
|
50
|
+
"startPlanFanout",
|
|
51
|
+
"startEpicSet",
|
|
52
|
+
"startFeature",
|
|
53
|
+
"agentCompleteEscalation",
|
|
54
|
+
"appendBlackboard",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
// S0-PENDING self-containment allowlist (issue #606 / upstream nano-ide#502).
|
|
58
|
+
// --------------------------------------------------------------------------
|
|
59
|
+
// S1 (this harness) and S0 (openapi.yaml restructuring) are the two WAVE-0 scaffold slices and land
|
|
60
|
+
// in parallel — so this test must be GREEN on `main` whether or not S0 has merged yet. On the
|
|
61
|
+
// pre-S0 spec these object-body tools still project a leaked `$ref` (`body: { $ref: <component> }`);
|
|
62
|
+
// S0 inlines them. For each, the audit tolerates EITHER the self-contained shape (post-S0) OR
|
|
63
|
+
// EXACTLY the one known pre-S0 `$ref` (this map's value) — and FAILS on any OTHER `$ref` shape (a
|
|
64
|
+
// novel leak, a wrong target). Once S0 lands, delete the graduated entries here so they fall under
|
|
65
|
+
// the hard self-containment assertion like every other tool. A tool NOT in this map is hard-asserted
|
|
66
|
+
// self-contained NOW — so reintroducing a `$ref` into any clean tool (or a sibling's NEW tool) fails
|
|
67
|
+
// the build immediately.
|
|
68
|
+
const KNOWN_PENDING_S0: Readonly<Record<string, string>> = {
|
|
69
|
+
compileDeliveryGraph: "#/components/schemas/DeliveryGraph",
|
|
70
|
+
previewDeliveryGraph: "#/components/schemas/DeliveryGraphPreviewSubmit",
|
|
71
|
+
startConvergenceLoop: "#/components/schemas/ConvergenceStart",
|
|
72
|
+
startPlanFanout: "#/components/schemas/PlanStart",
|
|
73
|
+
startEpicSet: "#/components/schemas/EpicSetStart",
|
|
74
|
+
startFeature: "#/components/schemas/FeatureStart",
|
|
75
|
+
agentCompleteEscalation: "#/components/schemas/AgentCompleteRequest",
|
|
76
|
+
appendBlackboard: "#/components/schemas/BlackboardAppendRequest",
|
|
77
|
+
saveToLibrary: "#/components/schemas/SaveToLibrarySubmit",
|
|
78
|
+
importToLibrary: "#/components/schemas/ImportToLibrarySubmit",
|
|
79
|
+
previewProposalBpmn: "#/components/schemas/DeliveryGraphProposalBpmnRequest",
|
|
80
|
+
enrolAgenticWorker: "#/components/schemas/EnrolRequest",
|
|
81
|
+
revertEscalationCompletion: "#/components/schemas/RevertCompletionRequest",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Collect every `$ref` string anywhere in a parsed JSON Schema. */
|
|
85
|
+
function collectRefs(schema: unknown, acc: string[] = []): string[] {
|
|
86
|
+
if (Array.isArray(schema)) {
|
|
87
|
+
for (const item of schema) collectRefs(item, acc);
|
|
88
|
+
} else if (schema && typeof schema === "object") {
|
|
89
|
+
for (const [key, value] of Object.entries(schema as Record<string, unknown>)) {
|
|
90
|
+
if (key === "$ref" && typeof value === "string") acc.push(value);
|
|
91
|
+
else collectRefs(value, acc);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return acc;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Audit one tool's projected schema against the S0 self-containment contract, tolerating exactly
|
|
98
|
+
* the one documented pre-S0 leak for a {@link KNOWN_PENDING_S0} tool (see that map's comment). */
|
|
99
|
+
function auditToolSchema(tool: McpTool): void {
|
|
100
|
+
const pendingRef = KNOWN_PENDING_S0[tool.name];
|
|
101
|
+
if (pendingRef === undefined) {
|
|
102
|
+
// Not pending an S0 fix → the schema must already be self-contained. This is the live guard that
|
|
103
|
+
// fails the build the moment a `$ref` is (re)introduced into a clean or newly-added tool.
|
|
104
|
+
assertSchemaSelfContained(tool.inputSchema, tool.name);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Pending an S0 fix: accept the post-S0 clean shape, OR the exact known pre-S0 `$ref`.
|
|
108
|
+
if (!schemaHasRef(tool.inputSchema)) {
|
|
109
|
+
assertSchemaSelfContained(tool.inputSchema, tool.name);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const refs = collectRefs(tool.inputSchema);
|
|
113
|
+
const unexpected = refs.filter((r) => r !== pendingRef);
|
|
114
|
+
assert.equal(
|
|
115
|
+
unexpected.length,
|
|
116
|
+
0,
|
|
117
|
+
`tool "${tool.name}": unexpected \`$ref\`(s) ${JSON.stringify(unexpected)} — only the known ` +
|
|
118
|
+
`pre-S0 leak "${pendingRef}" is tolerated (issue #606 / nano-ide#502). Any other \`$ref\` is a defect.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S1 / #607)", () => {
|
|
123
|
+
let h: McpHarness;
|
|
124
|
+
let tools: McpTool[];
|
|
125
|
+
let toolNames: Set<string>;
|
|
126
|
+
|
|
127
|
+
before(async () => {
|
|
128
|
+
h = await bootMcpHarness();
|
|
129
|
+
tools = await h.listTools();
|
|
130
|
+
toolNames = new Set(tools.map((t) => t.name));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
after(async () => {
|
|
134
|
+
await h?.stop();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("initialize handshake succeeds and tools/list projects the covered tools", () => {
|
|
138
|
+
assert.ok(h.sessionId, "the initialize handshake must yield an Mcp-Session-Id");
|
|
139
|
+
assert.ok(tools.length > 0, "tools/list must project at least one tool");
|
|
140
|
+
for (const name of [...READ_TOOLS, ...OBJECT_BODY_DOORS, "previewDeliveryGraph"]) {
|
|
141
|
+
assert.ok(toolNames.has(name), `tools/list must expose "${name}"`);
|
|
142
|
+
}
|
|
143
|
+
// The operator-only doors stay OFF the MCP surface (ADR 0067 §2 — `x-mcp` excluded).
|
|
144
|
+
for (const excluded of ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"]) {
|
|
145
|
+
assert.ok(!toolNames.has(excluded), `"${excluded}" is operator-only and must NOT be projected`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("every projected tool schema is $ref-free with an explicit type (S0 contract)", () => {
|
|
150
|
+
for (const tool of tools) auditToolSchema(tool);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("read tools answer with parseable responses", async () => {
|
|
154
|
+
for (const name of READ_TOOLS) {
|
|
155
|
+
const res = await h.callTool(name, {});
|
|
156
|
+
assert.ok(!res.isError, `read "${name}" must not error: ${res.text}`);
|
|
157
|
+
assert.ok(res.text.length > 0, `read "${name}" must return content`);
|
|
158
|
+
// Every read but the markdown guide answers JSON; the guide answers a non-empty string.
|
|
159
|
+
if (name !== "getAgentInstructions") {
|
|
160
|
+
assert.notEqual(res.json, undefined, `read "${name}" must return parseable JSON: ${res.text.slice(0, 120)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("previewDeliveryGraph accepts a structured object body and stays pure (nothing staged)", async () => {
|
|
166
|
+
const res = await h.callTool("previewDeliveryGraph", { body: { graphJson: JSON.stringify(MINIMAL_VALID_GRAPH) } });
|
|
167
|
+
assertObjectBodyAccepted(res, "previewDeliveryGraph");
|
|
168
|
+
assert.ok(!res.isError, `previewDeliveryGraph must compile a valid graph: ${res.text}`);
|
|
169
|
+
const json = res.json as { ok?: boolean; staged?: boolean } | undefined;
|
|
170
|
+
assert.equal(json?.ok, true, `previewDeliveryGraph must report ok:true: ${res.text}`);
|
|
171
|
+
assert.equal(json?.staged, false, "previewDeliveryGraph is a PURE preview — it must never stage");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("object-body doors receive the argument as an object, not a string (uniform validation)", async () => {
|
|
175
|
+
// A deliberately-invalid-but-object body per door: validation rejects it (persisting nothing),
|
|
176
|
+
// which proves the object argument reached the door AS AN OBJECT — never the S0 stringified body.
|
|
177
|
+
for (const name of OBJECT_BODY_DOORS) {
|
|
178
|
+
const args = name === "appendBlackboard" ? { token: "harness-invalid", body: {} } : { body: {} };
|
|
179
|
+
const res = await h.callTool(name, args);
|
|
180
|
+
assert.ok(res.isError, `door "${name}" must reject an empty body with a validation error`);
|
|
181
|
+
assertValidationIssues(res, name); // also asserts the object body was NOT stringified
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("mutating framework tools are gated without the shared secret (set-variables)", async () => {
|
|
186
|
+
const res = await h.callTool("urban_debug_set_variables", { processInstanceKey: "1", variables: {} });
|
|
187
|
+
assert.ok(res.isError, "urban_debug_set_variables must refuse a credential-free mutation");
|
|
188
|
+
assert.match(
|
|
189
|
+
res.text,
|
|
190
|
+
/shared secret|allowMutations/i,
|
|
191
|
+
`the refusal must name the guard: ${res.text}`,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// The falsifiable core (issue #607 acceptance): DELIBERATELY reintroducing either half of the S0
|
|
196
|
+
// defect makes the harness fail. These pin the detector's teeth independently of whether S0 has
|
|
197
|
+
// landed — so the guard cannot silently rot into a no-op.
|
|
198
|
+
describe("reintroducing the S0 defect fails the build", () => {
|
|
199
|
+
test("a $ref in a tool schema is caught by the self-containment assertion", () => {
|
|
200
|
+
const good = { type: "object", properties: { body: { type: "object", properties: { n: { type: "number" } } } } };
|
|
201
|
+
assert.doesNotThrow(() => assertSchemaSelfContained(good, "synthetic-clean"));
|
|
202
|
+
const withRef = { type: "object", properties: { body: { $ref: "#/components/schemas/DeliveryGraph" } }, required: ["body"] };
|
|
203
|
+
assert.throws(() => assertSchemaSelfContained(withRef, "synthetic-ref"), /\$ref/, "a reintroduced $ref must throw");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a typeless schema is caught by the self-containment assertion", () => {
|
|
207
|
+
const typeless = { properties: { body: { type: "object" } } };
|
|
208
|
+
assert.throws(() => assertSchemaSelfContained(typeless, "synthetic-typeless"), /type/, "a typeless schema must throw");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a stringified object body is rejected by the door and caught by assertObjectBodyAccepted", async () => {
|
|
212
|
+
// Simulate the S0 client coercion: send the body as a JSON STRING instead of an object.
|
|
213
|
+
const res = await h.callTool("compileDeliveryGraph", { body: JSON.stringify(MINIMAL_VALID_GRAPH) });
|
|
214
|
+
assert.ok(res.isError, "a stringified object body must be rejected by the door");
|
|
215
|
+
assert.ok(
|
|
216
|
+
res.text.includes(STRINGIFIED_BODY_MESSAGE),
|
|
217
|
+
`the door must report "${STRINGIFIED_BODY_MESSAGE}": ${res.text}`,
|
|
218
|
+
);
|
|
219
|
+
// The harness's guard must recognize that signature as a failure.
|
|
220
|
+
assert.throws(
|
|
221
|
+
() => assertObjectBodyAccepted(res, "compileDeliveryGraph"),
|
|
222
|
+
/stringified/,
|
|
223
|
+
"assertObjectBodyAccepted must flag a stringified-body result",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("side-effecting calls leave no live staged proposals behind (repeatable)", async () => {
|
|
229
|
+
const res = await h.callTool("listStagedProposals", {});
|
|
230
|
+
assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
|
|
231
|
+
const json = res.json as { count?: number; proposals?: unknown[] } | undefined;
|
|
232
|
+
assert.equal(json?.count, 0, `the harness must stage nothing: ${res.text}`);
|
|
233
|
+
assert.deepEqual(json?.proposals, [], "no live staged proposals may remain");
|
|
234
|
+
});
|
|
235
|
+
});
|