wicked-garden 12.44.0 → 12.46.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.
@@ -1 +1 @@
1
- {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.44.0"}], "version": "12.44.0"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.46.0"}], "version": "12.46.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.44.0",
3
+ "version": "12.46.0",
4
4
  "description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 the wicked-estate code graph + injected bus/dispatch/capability/archetype edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory + knowledge (the mem domain over wicked-estate), real multi-model second opinions (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (the in-catalog qe domain). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault, a self-contained infra peer wicked-garden installs directly; wicked-bus is an opt-in layer \u2014 install what you'll use, the toolkit works without the rest.",
5
5
  "wicked_vault_version": "^0.5.0",
6
6
  "wicked_ledger_version": "^0.4.0",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.46.0] — 2026-10-09
6
+
7
+ ### Fixed
8
+ - **`mcp-scaffold` TypeScript template: a server with generated tools starts (wicked-garden#1249, PR #1251).** `src/tools/generated.ts` handed each `tools.json` entry's plain JSON Schema to fastmcp 4.22, which accepts only a Standard Schema, so any non-empty `tools.json` (branch A, the conversion-service path) made the server exit at startup ("parameters must implement Standard Schema") and the conformance tests time out. Each schema is now wrapped in fastmcp's `jsonSchemaAdapter`, validated with `ajv` + `ajv-formats` (now direct dependencies instead of fastmcp's optional peers). The example `echo` tool is registered only while `tools.json` is empty, so a generated server exposes exactly its generated tools. An upstream 4xx/5xx reaches the caller as `HTTP <status>: <excerpt>` with every credential the server sent scrubbed and the excerpt bounded to 300 characters. `<KEY>_BASE_URL` may change the path but never the origin: the credential follows the base URL, so another host is an edit to `mcp-server.config.json`. Tests: the template's vitest suite boots a copy of the built server with a one-tool `tools.json` against a local stub (lists only that tool, calls it, rejects a call missing a required argument) and `test/hardening.test.ts` pins the scrubbing and the origin rule; `tests/mcp/test_mcp_scaffold.py`'s npm end-to-end also probes the built server with a generated tool and now runs in CI.
9
+ - **`mcp-scaffold` install: a key held by a different registry server is refused, not "updated" (wicked-garden#1250, PR #1251).** `scripts/mcp/install.py` reads `GET /api/v1/mcp/servers` before staging and again right before the save: an entry under the key that is not this install (another `kind`, command or args — e.g. a REST server with the same name) exits 1 naming it and the two ways out (another key, or remove that server first), writing nothing to the registry; an unreadable registry (not a 404) refuses too. The skill's scope phase lists the registry's keys before picking one.
10
+ - **Walkthrough probes learn the fixture's port (wicked-garden#1248, PR #1251).** The recorder starts the fixture on a free port but spawned probes with only `DATA_DIR`, so a probe reading the app over HTTP could not reach it (curl exit 7, the chapter FAILed while the app was right). Probes now also get `PORT` and `BASE_URL` (`http://127.0.0.1:$PORT`). The lint flags a probe argv with a literal loopback port (`probe_port`) and a `fixture.env` that pins `PORT` or `BASE_URL` (`fixture_env`); `refs/walkthrough-author.md` documents the probe environment and the `["sh", "-c", "curl -sf \"$BASE_URL/..\""]` pattern.
11
+
12
+ ## [12.45.0] — 2026-10-08
13
+
14
+ ### Fixed
15
+ - **Walkthrough: `record` re-runs the storyline lint before it records (wicked-garden#1240, DES-walkthrough-proof §4.4 step 1).** The storyline may have been edited since the `walkthrough_plan` step linted it, so `scripts/demo/walkthrough.mjs record` now runs `lintStoryline` on the storyline it loaded, against the tree it staged (`git archive` of `WICKED_TREE` plus the `fixture.reuse` links, so a `fixture.start` script must be one the tree under review declares), after staging and before the fixture starts. Any finding refuses: `result.json` `{overall: "INCONCLUSIVE", cause: "storyline_refused", reason: "lint: <rule> at <where>: <detail>; ..."}` with every chapter listed INCONCLUSIVE, `progress.json` and the seal line written, exit 0 — nothing is started, filmed or captured. Without `jq` the lint cannot run a check's negative samples and fails closed (`negative_unverified`). Recorder test storylines now pass the lint through `tests/demo/fixtures/lint_complete.mjs` (a negative sample on every check, plus passing pad checks — a `#status` locator, a `probe.mjs` probe, their join, one guard — for any class a chapter lacks, checked after the chapter's own `run()`); tests that pinned a path the lint now refuses first assert the named refusal, and three new tests pin it (a tautology negative, the unpadded fixture, a `fixture.start` script not in the tree). The demo skill and `refs/walkthrough-author.md` say record re-runs the lint.
16
+
5
17
  ## [12.44.0] — 2026-10-08
6
18
 
7
19
  ### Added
@@ -994,7 +1006,9 @@ through `v9.2.18`. Major themes across that span:
994
1006
 
995
1007
  For each of those releases, see the corresponding tag in git history.
996
1008
 
997
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.44.0...HEAD
1009
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.46.0...HEAD
1010
+ [12.46.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.45.0...v12.46.0
1011
+ [12.45.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.44.0...v12.45.0
998
1012
  [12.44.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.43.0...v12.44.0
999
1013
  [12.43.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...v12.43.0
1000
1014
  [12.42.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...v12.42.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.44.0",
3
+ "version": "12.46.0",
4
4
  "type": "module",
5
5
  "description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
6
6
  "keywords": [
@@ -47,6 +47,10 @@ const SECRET_ENV = /(SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KE
47
47
  // Inline-code flags: `node -e`, `python -c`, `sh -c`, ... — the fixture must be a script the tree declares.
48
48
  const INLINE_FLAGS = new Set(["-e", "-c", "--eval", "-p", "--print", "--command"]);
49
49
  const SCRIPT_LIKE = /\.(?:m?js|cjs|ts|mts|py|sh|rb|pl)$/i;
50
+ // The recorder starts the fixture on a FREE port and hands PORT + BASE_URL to the fixture's probes
51
+ // (garden#1248): a storyline that pins either, or a probe URL with a literal loopback port, cannot pass.
52
+ const RECORDER_ENV = new Set(["PORT", "BASE_URL"]);
53
+ const LITERAL_PORT = /(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]):\d+/i;
50
54
 
51
55
  /** The first line of a thrown value's message; never throws itself (a storyline can throw anything). */
52
56
  function why(e) {
@@ -103,11 +107,19 @@ export function lintStoryline(story, { tree = null, steps = null, jq = "jq" } =
103
107
  } else {
104
108
  for (const name of Object.keys(fixture.env)) {
105
109
  if (SECRET_ENV.test(name)) add("secret_env", `fixture.env.${name}`, "a secret-shaped env name is refused: no credential reaches the jailed app");
110
+ if (RECORDER_ENV.has(name)) add("fixture_env", `fixture.env.${name}`, `${name} is set by the recorder (the fixture runs on a free port): read it, do not pin it`);
106
111
  }
107
112
  }
108
113
  }
109
114
  if (fixture.probes !== undefined && (!fixture.probes || typeof fixture.probes !== "object" || Array.isArray(fixture.probes))) {
110
115
  add("fixture_probes", "fixture.probes", "fixture.probes must be an object of name: argv");
116
+ } else if (fixture.probes) {
117
+ for (const [name, argv] of Object.entries(fixture.probes)) {
118
+ const literal = Array.isArray(argv) ? argv.find((a) => typeof a === "string" && LITERAL_PORT.test(a)) : undefined;
119
+ if (literal !== undefined) {
120
+ add("probe_port", `fixture.probes.${name}`, `${JSON.stringify(literal)} hard-codes a port, but the recorder starts the fixture on a free one: read $BASE_URL (or $PORT), e.g. ["sh", "-c", "curl -sf \\"$BASE_URL/charges\\""]`);
121
+ }
122
+ }
111
123
  }
112
124
  }
113
125
  const probes = fixture && typeof fixture.probes === "object" && fixture.probes ? fixture.probes : {};
@@ -9,6 +9,7 @@
9
9
  // The storyline: --storyline <path>, else $WICKED_WALKTHROUGH_AUTHOR/storyline.mjs (the walkthrough_plan step's
10
10
  // author dir, which the engine hands the record tool); with neither, record refuses (storyline_refused).
11
11
  // lint (walkthrough-lint.mjs, DES §4.5) is the walkthrough_plan step's pinned validator: exit 0 = no finding.
12
+ // record re-runs it on the storyline it loaded (DES §4.4 step 1): any finding refuses (storyline_refused).
12
13
  // Safety: WICKED_WALKTHROUGH_JAIL must be set; without it writes INCONCLUSIVE(unjailed_host) and exits 0.
13
14
  //
14
15
  // What the proof root holds is ONE contract with crew's walkthrough view and acceptance
@@ -43,7 +44,7 @@ import { tmpdir } from "node:os";
43
44
  import { makeSegmentRecorder, failedAtInVideo, stitchParts, extractFrame, SEGMENT_TRIM_START } from "./record.mjs";
44
45
  import { fixtureOrigin } from "./readonly.mjs";
45
46
  import { crewRunStamp } from "../qe/lib/crew-run.mjs";
46
- import { lintAction } from "./walkthrough-lint.mjs";
47
+ import { lintAction, lintStoryline } from "./walkthrough-lint.mjs";
47
48
 
48
49
  // ---- arg parsing ---------------------------------------------------------------
49
50
  const [action, ...rawArgs] = process.argv.slice(2);
@@ -438,7 +439,7 @@ function evidenceListForCapture(capture, def) {
438
439
 
439
440
  // ---- collector -----------------------------------------------------------------
440
441
 
441
- async function runCollector(def, { stage, root, dataDir, seg, id, priorCaptures, consoleErrors }) {
442
+ async function runCollector(def, { stage, root, dataDir, seg, id, priorCaptures, consoleErrors, probeEnv = {} }) {
442
443
  const captureDir = path.join(root, "capture");
443
444
  fs.mkdirSync(captureDir, { recursive: true });
444
445
 
@@ -462,7 +463,9 @@ async function runCollector(def, { stage, root, dataDir, seg, id, priorCaptures,
462
463
  try {
463
464
  result = spawnSync(probeArgv[0], probeArgv.slice(1), {
464
465
  cwd: path.join(root, "app"),
465
- env: { ...process.env, DATA_DIR: dataDir },
466
+ // PORT + BASE_URL: the fixture runs on a free port, so a probe that reads the app over
467
+ // HTTP learns where from these (garden#1248), never from a literal in its argv.
468
+ env: { ...process.env, DATA_DIR: dataDir, ...probeEnv },
466
469
  encoding: "utf8",
467
470
  timeout: 15_000,
468
471
  });
@@ -950,6 +953,23 @@ async function main() {
950
953
  }
951
954
  }
952
955
 
956
+ // Re-run the lint (DES-walkthrough-proof §4.4 step 1): the storyline may have been edited since the
957
+ // walkthrough_plan step linted it, so a storyline the lint refuses is never started or recorded. It
958
+ // runs on the staged tree (with its fixture.reuse links), so fixture.start must name a script the tree
959
+ // under review declares.
960
+ let lint;
961
+ try { lint = lintStoryline(story, { tree: appDir }); } catch (e) {
962
+ lint = { findings: [{ rule: "storyline_unreadable", where: storyPath, detail: String(e?.message ?? e).split("\n")[0] }] };
963
+ }
964
+ if (lint.findings.length) {
965
+ const shown = lint.findings.slice(0, 5).map((f) => `${f.rule} at ${f.where}: ${f.detail}`);
966
+ const more = lint.findings.length > shown.length ? ` (+${lint.findings.length - shown.length} more)` : "";
967
+ writeResult(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused", reason: `lint: ${shown.join("; ")}${more}`, chapters: allChapters });
968
+ writeProgress(ROOT, { overall: "INCONCLUSIVE", cause: "storyline_refused" });
969
+ finishWithSeal(ROOT, { tree: TREE, storyline_sha: storySha, contract_shas: {}, overall: "INCONCLUSIVE", cause: "storyline_refused", chapters: allChapters });
970
+ process.exit(0);
971
+ }
972
+
953
973
  // 3. Spawn fixture, poll ready
954
974
  const dataDir = path.join(ROOT, "data");
955
975
  fs.mkdirSync(dataDir, { recursive: true });
@@ -1041,6 +1061,7 @@ async function main() {
1041
1061
  stage, root: ROOT, dataDir, seg, id,
1042
1062
  priorCaptures: segCaptures,
1043
1063
  consoleErrors: consoleErrBuf.current,
1064
+ probeEnv: { PORT: String(fixturePort), BASE_URL: fixtureOriginStr },
1044
1065
  });
1045
1066
  } catch (e) {
1046
1067
  capture = { _verifier_error: e.message };
@@ -20,7 +20,11 @@
20
20
  ``node <root>/<key>/current/dist/server.js`` is stable across updates.
21
21
  5. **Register** with wicked-crew (``POST /api/v1/mcp/servers/preview`` with
22
22
  ``auth: {ref: "env:<SERVER>_TOKEN"}`` — a reference, never a value — then
23
- ``POST /api/v1/mcp/servers {previewHash}``); saving over the same key is the update.
23
+ ``POST /api/v1/mcp/servers {previewHash}``); saving over the same key is the update — but
24
+ only of THIS install: before anything is staged, a registry entry under the key that is a
25
+ different server (another ``kind``, command or args — e.g. a REST server named the same)
26
+ refuses the install (exit 1) naming it, and nothing is written; the check runs again right
27
+ before the save, so a key claimed while staging is never overwritten either.
24
28
  6. **CLI configs** through ``wicked-installer mcp upsert`` (never written here); an installer
25
29
  without the verb is reported, not failed.
26
30
  7. **Record** ``<root>/<key>/installed.json`` and print one JSON record.
@@ -207,6 +211,48 @@ def smoke(server_js: Path, var: str) -> tuple[str, dict]:
207
211
  return "failed", report
208
212
 
209
213
 
214
+ # ── 0. key collision ───────────────────────────────────────────────────────────────────────
215
+
216
+ def installed_rel(npm_spec: str | None) -> Path:
217
+ """server.js relative to ``current/`` — known before staging, so the registry check runs first."""
218
+ if npm_spec:
219
+ pkg = NPM_SPEC_RE.match(npm_spec).group("pkg")
220
+ return Path("node_modules", *pkg.split("/"), "dist", "server.js")
221
+ return Path("dist", "server.js")
222
+
223
+
224
+ def check_key(origin: str, key: str, server_js: Path) -> dict | None:
225
+ """Refuse when the registry holds a DIFFERENT server under ``key`` (garden#1250).
226
+
227
+ The same key is the update only when the entry is this install (``mcp-stdio``, ``node``,
228
+ the same staged path). No daemon: nothing to collide with here (registration reports it).
229
+ Answers the existing entry's identity when it is this install, else None."""
230
+ try:
231
+ status, body = http_json("GET", f"{origin}/api/v1/mcp/servers")
232
+ except (urllib.error.URLError, OSError):
233
+ return None
234
+ if status == 404: # a daemon without the registry list route: registration will say so
235
+ return None
236
+ if status >= 400:
237
+ raise Failure(1, f"cannot read the registry to check the key {key!r} (HTTP {status}): "
238
+ f"{_message(body)}; nothing was installed")
239
+ servers = body.get("servers") if isinstance(body.get("servers"), list) else []
240
+ existing = next((s for s in servers if isinstance(s, dict) and s.get("name") == key), None)
241
+ if existing is None:
242
+ return None
243
+ ours = {"kind": "mcp-stdio", "command": "node", "args": [str(server_js)]}
244
+ theirs = {k: existing.get(k) for k in ours}
245
+ if theirs == ours:
246
+ return theirs
247
+ what = existing.get("kind") or "unknown-kind"
248
+ where = existing.get("url") or " ".join([str(existing.get("command") or ""),
249
+ *map(str, existing.get("args") or [])]).strip()
250
+ raise Failure(1, f"the registry already holds a different server under the key {key!r} "
251
+ f"({what}{': ' + where if where else ''}); refusing to replace it. Pick "
252
+ f"another key in {CONFIG_NAME}, or remove the existing server first "
253
+ f"(studio MCP tools, or install.py --uninstall {key}); nothing was installed")
254
+
255
+
210
256
  # ── 5. register ────────────────────────────────────────────────────────────────────────────
211
257
 
212
258
  def register(origin: str, key: str, server_js: Path, var: str) -> dict:
@@ -350,6 +396,8 @@ def do_install(args) -> tuple[int, dict]:
350
396
  root = install_root(args.install_root)
351
397
  key_root = root / key
352
398
  next_dir = key_root / "next"
399
+ origin = crew_origin(args.crew_url)
400
+ check_key(origin, key, key_root / "current" / installed_rel(args.from_npm))
353
401
  if next_dir.is_symlink():
354
402
  next_dir.unlink()
355
403
  elif next_dir.exists():
@@ -372,7 +420,10 @@ def do_install(args) -> tuple[int, dict]:
372
420
  "installedAt": _now()}
373
421
  code = 0
374
422
  try:
375
- reg = register(crew_origin(args.crew_url), key, current_js, var)
423
+ # Again right before the save: the key may have been claimed while this staged, or the
424
+ # first check found no daemon to ask (the registry has no conditional save to lean on).
425
+ check_key(origin, key, current_js)
426
+ reg = register(origin, key, current_js, var)
376
427
  except Failure as err:
377
428
  reg = {"registered": False, "error": str(err)}
378
429
  code = 1
@@ -191,7 +191,8 @@ again until it exits 0. The rules, in short: `baseUrl: "fixture"`; `fixture.star
191
191
  the tree declares (no `-e`/`-c`, nothing outside the tree, no secret-shaped env name); every chapter `proves` plan
192
192
  step ids and has a `locator` check, a `probe` or `artifact` check, and a `join` of the two; the walkthrough has a
193
193
  `guard` check; every check carries `negative` samples its `jq_pred` verifier FAILS on. The record tool
194
- (`walkthrough_review`) reads the same file through `WICKED_WALKTHROUGH_AUTHOR`. Full rules and a passing
194
+ (`walkthrough_review`) reads the same file through `WICKED_WALKTHROUGH_AUTHOR` and re-runs the lint first: a
195
+ finding refuses the recording (`storyline_refused`). Full rules and a passing
195
196
  example: `refs/walkthrough-author.md`.
196
197
 
197
198
  ### Probe selectors (read-only)
@@ -13,7 +13,9 @@ review and judges every check through the vault. No model judges a check.
13
13
  `wicked-garden run scripts/demo/walkthrough.mjs lint --root <author dir>`. It denies the step until the
14
14
  lint exits 0.
15
15
  - The record step reads the same file through `WICKED_WALKTHROUGH_AUTHOR` (the author dir the engine hands
16
- it). Outside a run, pass `--storyline <path>` to `record` and `lint`.
16
+ it) and re-runs the lint before it records anything: a finding refuses the recording
17
+ (`INCONCLUSIVE`, cause `storyline_refused`, the rules in `reason`). Outside a run, pass
18
+ `--storyline <path>` to `record` and `lint`.
17
19
 
18
20
  ## Lint rules
19
21
 
@@ -26,6 +28,8 @@ finding, 1 with any, 2 on a usage error. Each rule id below is what a finding ca
26
28
  | `base_url` | `baseUrl: "fixture"` — the tool films the fixture it starts, never a live system |
27
29
  | `fixture_start` | `fixture.start` is an argv of strings; no inline code (`-e`, `-c`, `--eval`, `-p`, `--print`, `--command`); no absolute path or `..`; with `--tree <dir>`, every script-like argument exists in the tree |
28
30
  | `secret_env` | no `fixture.env` name looks like a credential (`TOKEN`, `SECRET`, `PASSWORD`, `API_KEY`, `PRIVATE_KEY`, `CREDENTIAL`, `AUTH`, ...) |
31
+ | `fixture_env` | `fixture.env` does not set `PORT` or `BASE_URL`: the recorder sets both (the fixture runs on a free port) |
32
+ | `probe_port` | no probe argv hard-codes a loopback port (`127.0.0.1:<port>`, `localhost:<port>`): read `$BASE_URL` / `$PORT` instead (see Probes below) |
29
33
  | `segments` | at least one chapter (a non-`intro` segment): an empty plan proves nothing |
30
34
  | `segment_key` / `segment_run` | keys match `^[a-z0-9][a-z0-9-]*$` and are unique; `run(ctx)` is a function |
31
35
  | `proves` | every chapter names plan step ids in `proves[]`; with `--steps a,b`, each id is one of them |
@@ -42,6 +46,27 @@ source non-null. The verifier reads the capture the tool records: a locator `{in
42
46
  `{raw: {argv, exit_code, stdout, stderr}, parsed}`, an artifact `{path, sha256}`, a guard
43
47
  `{foreign_writes, console_errors}`, a join `{joined: {<source id>: value}}`.
44
48
 
49
+ ## Probes
50
+
51
+ A probe is an argv the record tool runs with `cwd` = the app (the tree under review) and this
52
+ environment on top of its own:
53
+
54
+ | Variable | Value |
55
+ |---|---|
56
+ | `DATA_DIR` | the fixture's data directory (the same one the app writes to) |
57
+ | `PORT` | the port the record tool started the fixture on — a FREE port, chosen per recording |
58
+ | `BASE_URL` | the fixture's origin, `http://127.0.0.1:$PORT` |
59
+
60
+ Never write the port into the storyline: `fixture.env.PORT` is overridden and a literal
61
+ `http://127.0.0.1:4173/...` in a probe reaches nothing (curl exit 7, the chapter FAILs while the app
62
+ is right). A probe that reads the app over HTTP expands the variable through a shell or a script:
63
+
64
+ ```
65
+ probes: {
66
+ charges: ["sh", "-c", "curl -sf \"$BASE_URL/charges\""], // or ["node", "scripts/probe-charges.mjs"] reading process.env.BASE_URL
67
+ },
68
+ ```
69
+
45
70
  ## A storyline that passes
46
71
 
47
72
  ```js
@@ -62,8 +62,9 @@ wicked-garden run scripts/mcp/scaffold.py --name <server-name> [--lang typescrip
62
62
 
63
63
  The one-file templates answer `initialize` (negotiating the protocol version), `ping`,
64
64
  `tools/list` and `tools/call`, ignore notifications, and ship two example tools: `echo`
65
- (read) and `counter_increment` (write). The TypeScript template ships `echo` (read) plus
66
- whatever `tools.json` holds. Replace the examples with your own.
65
+ (read) and `counter_increment` (write). The TypeScript template registers what `tools.json`
66
+ holds, and its example `echo` (read) only while `tools.json` is empty. Replace the examples
67
+ with your own.
67
68
 
68
69
  ## 2. Generate tools from an OpenAPI document (`openapi`)
69
70
 
@@ -99,7 +100,9 @@ external `$ref`s are not followed.
99
100
  instruments, the per-tool rate limit (`rateLimitPerMin`) and `canAccess: requireSession`.
100
101
  Throw `UserError` for a failure the caller should see — it comes back as `isError`.
101
102
  - **Generated tools (branch A)** come from `tools.json`: `src/tools/generated.ts` registers
102
- each entry with its JSON Schema and the annotations its class implies; calls go through
103
+ each entry with its JSON Schema (wrapped in fastmcp's `jsonSchemaAdapter` — fastmcp rejects
104
+ a raw JSON Schema and the server would not start) and the annotations its class implies;
105
+ an upstream error reaches the caller bounded, with the credential scrubbed; calls go through
103
106
  `src/rest.ts` — pinned to `baseUrl`, allowlisted arguments only, no redirects, auth and
104
107
  hop-by-hop headers never settable by an argument. **Hand-written tools (branch B)** follow
105
108
  `src/tools/example.ts`: a zod schema validated before the handler runs, honest annotations.
@@ -212,7 +215,10 @@ config's `key`:
212
215
  variable answers `secret_missing`: the record says `registered: false`, `missing`, and two
213
216
  remedies (export it in the daemon's environment and re-run; or studio **MCP tools → Add
214
217
  existing** with the secret, stored in the OS keychain). No daemon: `registered: false`
215
- with the reason. Re-registering the same key is the update (`registered: "updated"`).
218
+ with the reason. Re-registering the same key is the update (`registered: "updated"`) —
219
+ of THIS install only: before anything is staged, a registry entry under the key that is a
220
+ different server (another `kind`, command or args — say a REST server with the same name)
221
+ exits 1 naming it, with nothing written. Pick another key, or remove that server first.
216
222
  6. **CLI configs** through `wicked-installer mcp upsert` (never written by this script); an
217
223
  installer without the verb is reported as `clis: "skipped: …"`, not failed.
218
224
  7. **Record** `<root>/<key>/installed.json` `{key, version, source, command, args,
@@ -261,7 +267,9 @@ run. Follow `wicked-garden-governed-worker`, and:
261
267
  - **scope** — settle what to expose, the auth scheme, the transport (stdio unless a remote
262
268
  client needs httpStream) and the **target directory**: the root of a near-empty repo, or a
263
269
  subdirectory of an existing one. A repository that is not registered must be created and
264
- registered by the operator first — say so, do not create it. Record whether an OpenAPI
270
+ registered by the operator first — say so, do not create it. **List the registry's keys**
271
+ (`GET /api/v1/mcp/servers` on the crew daemon, or studio MCP tools) and pick a key no other
272
+ server holds: install refuses a key held by a different server. Record whether an OpenAPI
265
273
  document was given and whether to install when complete (the operator still decides at
266
274
  the install gate).
267
275
  - **source-discovery** — with an OpenAPI document, run the `openapi` action (step 2) and
@@ -35,7 +35,8 @@ registry inject exactly one environment variable into a stdio server. See `.env.
35
35
 
36
36
  Optional overrides: `MCP_TRANSPORT` (`stdio` | `httpStream`), `MCP_HOST` (default
37
37
  `127.0.0.1`), `MCP_PORT`,
38
- `__SERVER_ENV___BASE_URL`. httpStream requires `MCP_SERVER_BEARER`: every request must carry
38
+ `__SERVER_ENV___BASE_URL` (the path may change, the origin may not: the secret follows the base
39
+ URL, so a different host is a reviewed edit to `mcp-server.config.json`). httpStream requires `MCP_SERVER_BEARER`: every request must carry
39
40
  `Authorization: Bearer <it>` or is refused with 401.
40
41
 
41
42
  ## Telemetry
@@ -50,8 +51,11 @@ Off by default: the OpenTelemetry SDK runs with no exporter unless
50
51
  - **Generated (branch A):** `tools.json` holds the tools wicked-crew's conversion service
51
52
  derived from an OpenAPI 3 document — run
52
53
  `wicked-garden run scripts/mcp/openapi.py --name __SERVER_NAME__ --base-url <url> --spec-url <url> --out .`
53
- `src/tools/generated.ts` registers each one; calls go through `src/rest.ts`, pinned to
54
- `baseUrl`, allowlisted arguments only, no redirects.
54
+ `src/tools/generated.ts` registers each one (its JSON Schema wrapped in fastmcp's
55
+ `jsonSchemaAdapter`, validated with `ajv`); calls go through `src/rest.ts`, pinned to
56
+ `baseUrl`, allowlisted arguments only, no redirects. An upstream 4xx/5xx reaches the caller
57
+ as a tool error carrying the status and a bounded body excerpt with the credential scrubbed.
58
+ The example `echo` tool is registered only while `tools.json` is empty.
55
59
  - **Hand-written (branch B):** add a file like `src/tools/example.ts`: a zod schema, honest
56
60
  annotations, registered through `defineTool` (span, child logger, metrics, rate limit and
57
61
  `canAccess` come with it).
@@ -31,6 +31,8 @@
31
31
  "@opentelemetry/exporter-trace-otlp-http": "^0.223.0",
32
32
  "@opentelemetry/sdk-logs": "^0.223.0",
33
33
  "@opentelemetry/sdk-node": "^0.223.0",
34
+ "ajv": "^8.20.0",
35
+ "ajv-formats": "^3.0.1",
34
36
  "fastmcp": "^4.22.4",
35
37
  "loglayer": "^9.4.0",
36
38
  "zod": "^4.6.5"
@@ -62,9 +62,17 @@ export function secretVar(key: string): string {
62
62
  return `${envPrefix(key)}_TOKEN`;
63
63
  }
64
64
 
65
+ function originOf(url: string): string | null {
66
+ try {
67
+ return new URL(url).origin;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
65
73
  /**
66
74
  * Load mcp-server.config.json, apply the env overrides (MCP_TRANSPORT, MCP_HOST, MCP_PORT,
67
- * <SERVER>_BASE_URL) and validate. Fails naming the field — never a value.
75
+ * <SERVER>_BASE_URL, same origin only) and validate. Fails naming the field — never a value.
68
76
  */
69
77
  export function loadConfig(env: NodeJS.ProcessEnv = process.env, root: string = SERVER_ROOT): ServerConfig {
70
78
  const file = join(root, "mcp-server.config.json");
@@ -79,7 +87,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env, root: string =
79
87
  if (env.MCP_PORT) raw.port = Number(env.MCP_PORT);
80
88
  if (env.MCP_HOST) raw.host = env.MCP_HOST;
81
89
  const baseOverride = key ? env[`${envPrefix(key)}_BASE_URL`] : undefined;
82
- if (baseOverride) raw.baseUrl = baseOverride;
90
+ if (baseOverride) {
91
+ // The upstream credential follows baseUrl, so an override may change the path but never
92
+ // the origin: repinning egress to another host is a reviewed edit to the committed config.
93
+ const committed = typeof raw.baseUrl === "string" ? originOf(raw.baseUrl) : null;
94
+ if (committed === null || originOf(baseOverride) !== committed) {
95
+ throw new Error(`${envPrefix(key)}_BASE_URL must keep the committed baseUrl origin; edit mcp-server.config.json to change the host`);
96
+ }
97
+ raw.baseUrl = baseOverride;
98
+ }
83
99
  const parsed = ConfigSchema.safeParse(raw);
84
100
  if (!parsed.success) {
85
101
  const issue = parsed.error.issues[0];
@@ -27,7 +27,9 @@ async function main(): Promise<void> {
27
27
  });
28
28
 
29
29
  const generated = registerGeneratedTools(server, config, () => upstreamHeaders(config, secret));
30
- registerExampleTools(server, config);
30
+ // The example `echo` tool only stands in while tools.json is empty: a server with generated
31
+ // tools exposes exactly those (plus whatever you register by hand).
32
+ if (generated === 0) registerExampleTools(server, config);
31
33
  root.withMetadata({ generated, transport: config.transport }).info("starting");
32
34
 
33
35
  const stop = async () => {
@@ -2,7 +2,7 @@
2
2
  // SPDX-FileCopyrightText: __YEAR__ the __SERVER_NAME__ authors
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { UserError, type FastMCP, type ToolParameters } from "fastmcp";
5
+ import { UserError, jsonSchemaAdapter, type FastMCP, type JsonSchemaObject, type ToolParameters } from "fastmcp";
6
6
  import type { Session } from "../auth.js";
7
7
  import { SERVER_ROOT, type ServerConfig } from "../config.js";
8
8
  import { callRest, type RestMapping } from "../rest.js";
@@ -34,6 +34,24 @@ export function readToolsJson(root: string = SERVER_ROOT): GeneratedTool[] {
34
34
  return Array.isArray(doc.tools) ? doc.tools : [];
35
35
  }
36
36
 
37
+ /** The most of an upstream error body a caller sees. */
38
+ export const ERROR_BODY_MAX = 300;
39
+
40
+ /**
41
+ * The tool error for an upstream 4xx/5xx: the status plus a bounded excerpt of the body with
42
+ * every credential this server sent scrubbed out, so an upstream that echoes its auth header
43
+ * back cannot hand the secret to the caller.
44
+ */
45
+ export function upstreamError(status: number, body: string, sent: Record<string, string>): string {
46
+ let text = body;
47
+ for (const value of Object.values(sent)) {
48
+ const parts = [value, value.includes(" ") ? value.slice(value.indexOf(" ") + 1) : ""];
49
+ for (const part of parts) if (part.length >= 4) text = text.split(part).join("[redacted]");
50
+ }
51
+ if (text.length > ERROR_BODY_MAX) text = `${text.slice(0, ERROR_BODY_MAX)}… (truncated)`;
52
+ return text ? `HTTP ${status}: ${text}` : `HTTP ${status}`;
53
+ }
54
+
37
55
  /** Register every tools.json entry; each call goes through callRest, pinned to the base URL. */
38
56
  export function registerGeneratedTools(
39
57
  server: FastMCP<Session>,
@@ -45,8 +63,10 @@ export function registerGeneratedTools(
45
63
  defineTool(server, config, {
46
64
  name: t.name,
47
65
  description: t.description ?? t.name,
48
- // A plain JSON Schema, exactly as the conversion service derived it from the OpenAPI document.
49
- parameters: t.inputSchema as unknown as ToolParameters,
66
+ // fastmcp takes a Standard Schema, not a plain JSON Schema: a raw schema here makes the
67
+ // server refuse to start. jsonSchemaAdapter validates the conversion service's schema
68
+ // with ajv (a direct dependency of this server).
69
+ parameters: jsonSchemaAdapter(t.inputSchema as JsonSchemaObject) as unknown as ToolParameters,
50
70
  annotations: annotationsFor(t.class),
51
71
  execute: async (args) => {
52
72
  const result = await callRest(t.rest, (args ?? {}) as Record<string, unknown>, {
@@ -55,7 +75,7 @@ export function registerGeneratedTools(
55
75
  readOnly: t.class === "read",
56
76
  });
57
77
  const text = typeof result.body === "string" ? result.body : JSON.stringify(result.body);
58
- if (result.status >= 400) throw new UserError(`HTTP ${result.status}: ${text}`);
78
+ if (result.status >= 400) throw new UserError(upstreamError(result.status, text, await headers()));
59
79
  return text;
60
80
  },
61
81
  });
@@ -1,6 +1,8 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // SPDX-FileCopyrightText: __YEAR__ the __SERVER_NAME__ authors
3
3
  import { spawn } from "node:child_process";
4
+ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { createServer as createHttpServer } from "node:http";
4
6
  import { createServer } from "node:net";
5
7
  import { join } from "node:path";
6
8
  import { fileURLToPath } from "node:url";
@@ -15,13 +17,22 @@ function minimalEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
15
17
  }
16
18
 
17
19
  /** Send frames over stdio; resolve with every reply by id once `want` ids answered. */
18
- function converse(env: NodeJS.ProcessEnv, frames: object[], want: number[]): Promise<Map<number, any>> {
20
+ function converse(env: NodeJS.ProcessEnv, frames: object[], want: number[], server: string = SERVER): Promise<Map<number, any>> {
19
21
  return new Promise((resolve, reject) => {
20
- const child = spawn(process.execPath, [SERVER], { env, stdio: ["pipe", "pipe", "pipe"] });
22
+ const child = spawn(process.execPath, [server], { env, stdio: ["pipe", "pipe", "pipe"] });
21
23
  const replies = new Map<number, any>();
22
24
  let buf = "";
23
25
  // Generous: loading fastmcp + the OpenTelemetry SDK takes seconds on a loaded host.
24
26
  const timer = setTimeout(() => { child.kill(); reject(new Error(`timed out; got ${[...replies.keys()]}`)); }, 60_000);
27
+ let stderr = "";
28
+ child.stderr.on("data", (c: Buffer) => (stderr += c.toString("utf8")));
29
+ // A server that dies before answering fails fast, naming why (not a 60 s timeout).
30
+ child.on("exit", (code) => {
31
+ if (!want.every((id) => replies.has(id))) {
32
+ clearTimeout(timer);
33
+ reject(new Error(`server exited (${code}) before answering: ${stderr.slice(0, 500)}`));
34
+ }
35
+ });
25
36
  child.stdout.on("data", (chunk: Buffer) => {
26
37
  buf += chunk.toString("utf8");
27
38
  let nl: number;
@@ -83,6 +94,49 @@ describe("stdio conformance", () => {
83
94
  });
84
95
  });
85
96
 
97
+ describe("generated tools (branch A)", () => {
98
+ // A copy of the built server with a one-tool tools.json, beside node_modules so imports
99
+ // resolve: proves the server STARTS with generated tools, lists exactly them, and calls one.
100
+ it("starts with a non-empty tools.json, lists only the generated tools and calls one", async () => {
101
+ const upstream = createHttpServer((req, res) => {
102
+ if (req.url === "/v1/notes/n1") res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id: "n1" }));
103
+ else res.writeHead(404).end("not found");
104
+ });
105
+ await new Promise<void>((r) => upstream.listen(0, "127.0.0.1", r));
106
+ const dir = mkdtempSync(join(ROOT, ".branch-a-"));
107
+ try {
108
+ cpSync(join(ROOT, "dist"), join(dir, "dist"), { recursive: true });
109
+ const config = JSON.parse(readFileSync(join(ROOT, "mcp-server.config.json"), "utf8"));
110
+ config.baseUrl = `http://127.0.0.1:${(upstream.address() as { port: number }).port}/v1`;
111
+ writeFileSync(join(dir, "mcp-server.config.json"), JSON.stringify(config));
112
+ writeFileSync(join(dir, "tools.json"), JSON.stringify({ tools: [{
113
+ name: "get_note", description: "Read one note", class: "read",
114
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
115
+ rest: { method: "GET", pathTemplate: "/notes/{id}", pathMap: { id: "id" }, queryMap: {}, headerMap: {},
116
+ bodyMap: null, bodyArg: null, argAllowlist: ["id"], timeoutMs: 5000 },
117
+ }] }));
118
+ const replies = await converse(minimalEnv({ [SECRET]: "dummy" }), [
119
+ { id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "conformance", version: "0" } } },
120
+ { method: "notifications/initialized" },
121
+ { id: 2, method: "tools/list", params: {} },
122
+ { id: 3, method: "tools/call", params: { name: "get_note", arguments: { id: "n1" } } },
123
+ { id: 4, method: "tools/call", params: { name: "get_note", arguments: {} } },
124
+ ], [1, 2, 3, 4], join(dir, "dist", "server.js"));
125
+ const tools = replies.get(2).result.tools as Array<{ name: string; inputSchema: { required?: string[] }; annotations?: { readOnlyHint?: boolean } }>;
126
+ expect(tools.map((t) => t.name)).toEqual(["get_note"]);
127
+ expect(tools[0]?.annotations?.readOnlyHint).toBe(true);
128
+ expect(tools[0]?.inputSchema.required).toEqual(["id"]);
129
+ expect(JSON.parse(replies.get(3).result.content[0].text)).toEqual({ id: "n1" });
130
+ // The schema is enforced: a call missing a required argument never reaches the upstream.
131
+ const bad = replies.get(4);
132
+ expect(bad.error !== undefined || bad.result?.isError === true).toBe(true);
133
+ } finally {
134
+ rmSync(dir, { recursive: true, force: true });
135
+ await new Promise<void>((r) => upstream.close(() => r()));
136
+ }
137
+ });
138
+ });
139
+
86
140
  describe("httpStream", () => {
87
141
  it("refuses a request without the bearer with 401", async () => {
88
142
  const port = await freePort();
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: __YEAR__ the __SERVER_NAME__ authors
3
+ import { describe, expect, it } from "vitest";
4
+ import { loadConfig } from "../src/config.js";
5
+ import { ERROR_BODY_MAX, upstreamError } from "../src/tools/generated.js";
6
+
7
+ const BASE_VAR = "__SERVER_ENV___BASE_URL";
8
+
9
+ describe("upstream error text", () => {
10
+ it("scrubs every credential the server sent, whole or after its scheme", () => {
11
+ const text = upstreamError(401, "bad header: Bearer tok-1234 (tok-1234)", { Authorization: "Bearer tok-1234" });
12
+ expect(text).toBe("HTTP 401: bad header: [redacted] ([redacted])");
13
+ });
14
+
15
+ it("bounds the body a caller sees", () => {
16
+ const text = upstreamError(500, "x".repeat(ERROR_BODY_MAX * 3), {});
17
+ expect(text.length).toBeLessThan(ERROR_BODY_MAX + 40);
18
+ expect(text).toContain("(truncated)");
19
+ });
20
+ });
21
+
22
+ describe("base URL override", () => {
23
+ const committed = new URL(loadConfig({}).baseUrl);
24
+
25
+ it("may move the path on the committed origin", () => {
26
+ const moved = `${committed.origin}/v2`;
27
+ expect(loadConfig({ [BASE_VAR]: moved }).baseUrl).toBe(moved);
28
+ });
29
+
30
+ it("refuses another origin: the credential would follow it", () => {
31
+ expect(() => loadConfig({ [BASE_VAR]: "https://elsewhere.invalid/v1" })).toThrow(/committed baseUrl origin/);
32
+ });
33
+ });