wicked-garden 12.47.0 → 12.48.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.47.0"}], "version": "12.47.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.48.0"}], "version": "12.48.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.47.0",
3
+ "version": "12.48.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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.48.0] — 2026-10-09
6
+
7
+ ### Added
8
+ - **mcp install: `--target worker|operator` and `--dry-run` (wicked-core#820, PR #1263).** Per the operator ruling, `install.py` registers a server only in program-owned places by default (`--target worker`): the staged copy under the install root, the crew MCP tools registry, and the worker seats' CLI configs under the worker home (`$WICKED_WORKER_HOME`, else `~/.wicked-worker`). `--target operator` also writes the operator's own CLI configs. `--dry-run` writes nothing and prints the consent gate's plan as one JSON line: each choice with its exact absolute write list and `operator_owned` flags, from the installer's own dry run. It never downloads the installer.
9
+ - **mcp TypeScript template: query-parameter API keys (wicked-garden#1261, PR #1263).** `auth.queryParam` (api-key scheme only) sends the key as a query parameter, added after the boundary check and never settable by an argument. One credentials carrier feeds both the request and the error scrub, which removes every credential sent (raw, URL-encoded, JSON-escaped) from upstream error bodies and from transport errors before the span or the caller sees them.
10
+
11
+ ### Fixed
12
+ - **mcp scripts no longer default to `:7701` (wicked-garden#1258, PR #1263).** `install.py` and `openapi.py` take the daemon only from `--crew-url` or `$WICKED_CREW_URL`, and exit 2 naming the remedy when neither is set. A guessed port had sent a worker to another daemon's registry. The skill text lists keys on `$WICKED_CREW_URL`.
13
+ - **demo walkthrough: a `join` over an `artifact` source can pass (wicked-garden#1246, PR #1262).** An artifact source now joins as its sha256. Before, it always joined as `null`, so a lint-clean storyline could never pass that chapter.
14
+
15
+ ### Changed
16
+ - Dependencies: TypeScript 7 for the MCP scaffold and the site (#1259), playwright 1.64.0 (#1257), motion 14.1.0 (#1260), werkzeug 3.1.9 / astro 7.3.8 / sharp 0.35.5 (#1256), wicked-ci 1.4.1 (#1241).
17
+
5
18
  ## [12.47.0] — 2026-10-09
6
19
 
7
20
  ### Changed
@@ -1011,7 +1024,8 @@ through `v9.2.18`. Major themes across that span:
1011
1024
 
1012
1025
  For each of those releases, see the corresponding tag in git history.
1013
1026
 
1014
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.47.0...HEAD
1027
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.48.0...HEAD
1028
+ [12.48.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.47.0...v12.48.0
1015
1029
  [12.47.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.46.0...v12.47.0
1016
1030
  [12.46.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.45.0...v12.46.0
1017
1031
  [12.45.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.44.0...v12.45.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.47.0",
3
+ "version": "12.48.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": [
@@ -14,12 +14,12 @@
14
14
  }
15
15
  },
16
16
  "node_modules/playwright": {
17
- "version": "1.63.0",
18
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
19
- "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
17
+ "version": "1.64.0",
18
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
19
+ "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
20
20
  "license": "Apache-2.0",
21
21
  "dependencies": {
22
- "playwright-core": "1.63.0"
22
+ "playwright-core": "1.64.0"
23
23
  },
24
24
  "bin": {
25
25
  "playwright": "cli.js"
@@ -29,9 +29,9 @@
29
29
  }
30
30
  },
31
31
  "node_modules/playwright-core": {
32
- "version": "1.63.0",
33
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
34
- "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
32
+ "version": "1.64.0",
33
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
34
+ "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
35
35
  "license": "Apache-2.0",
36
36
  "bin": {
37
37
  "playwright-core": "cli.js"
@@ -531,7 +531,8 @@ async function runCollector(def, { stage, root, dataDir, seg, id, priorCaptures,
531
531
  const joined = {};
532
532
  for (const srcId of (def.sources ?? [])) {
533
533
  const cap = (priorCaptures ?? {})[srcId];
534
- joined[srcId] = cap?.parsed ?? cap?.inner_text ?? null;
534
+ // An artifact source joins as its sha256 — present iff the artifact verifier passed it (garden#1246).
535
+ joined[srcId] = cap?.parsed ?? cap?.inner_text ?? cap?.sha256 ?? null;
535
536
  }
536
537
  return { joined };
537
538
  }
@@ -2,9 +2,23 @@
2
2
  """The ``install`` action: install or update a built MCP server for running, idempotent by key.
3
3
 
4
4
  wicked-garden run scripts/mcp/install.py (--from-run | --dir <server dir>) [--key <key>]
5
- [--from-npm <pkg>@<version>] [--cli all|claude,codex,opencode,antigravity,pi|none]
5
+ [--from-npm <pkg>@<version>] [--target worker|operator] [--dry-run]
6
+ [--cli all|claude,codex,opencode,antigravity,pi|none]
6
7
  [--crew-url <origin>] [--install-root <dir>] [--json]
7
- wicked-garden run scripts/mcp/install.py --uninstall <key> [--cli ...] [--crew-url ...]
8
+ wicked-garden run scripts/mcp/install.py --uninstall <key> [--target ...] [--cli ...] [--crew-url ...]
9
+
10
+ **Target** (core#820, operator ruling 2026-10-09): ``--target worker`` (the default) writes only
11
+ program-owned places: the staged copy under the install root, the crew registry, and the worker
12
+ seats' CLI configs under the worker home (``$WICKED_WORKER_HOME``, else ``~/.wicked-worker``:
13
+ ``claude/.claude.json``, ``codex/config.toml``, ``opencode/config/opencode/``). ``--target
14
+ operator`` does all of that AND registers the server in the operator's own CLI configs.
15
+ ``--dry-run`` writes nothing and prints ONE JSON line, the plan the consent gate shows:
16
+ ``{"dry_run": true, "key", "choices": [{"id", "label", "default"?, "writes": [{"path", "what",
17
+ "cli"?, "operator_owned"}]}], "skipped": [{"cli", "why"}]}``.
18
+
19
+ **Daemon origin** (garden#1258): ``--crew-url``, else ``$WICKED_CREW_URL`` (crew sets it to its own
20
+ bound URL for every governed unit). There is no port default: a guessed ``:7701`` reached another
21
+ daemon's registry. Neither set exits 2 naming the remedy.
8
22
 
9
23
  1. **Locate** the server: ``--from-run`` finds the one ``mcp-server.config.json`` under
10
24
  ``$WICKED_TREE`` (else the cwd), outside node_modules/dist; ``--dir`` names it. The key and
@@ -51,7 +65,6 @@ from pathlib import Path
51
65
 
52
66
  HERE = Path(__file__).resolve().parent
53
67
  PROBE = HERE / "probe.py"
54
- DEFAULT_ORIGIN = "http://127.0.0.1:7701"
55
68
  CONFIG_NAME = "mcp-server.config.json"
56
69
  NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
57
70
  NPM_SPEC_RE = re.compile(r"^(?P<pkg>(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*)@(?P<version>[^@\s]+)$")
@@ -82,7 +95,50 @@ def env_name_for(key: str) -> str:
82
95
 
83
96
 
84
97
  def crew_origin(flag: str | None) -> str:
85
- return (flag or os.environ.get("WICKED_CREW_URL") or DEFAULT_ORIGIN).rstrip("/")
98
+ """The daemon THIS run belongs to — never a guessed port (garden#1258)."""
99
+ origin = flag or os.environ.get("WICKED_CREW_URL")
100
+ if not origin:
101
+ raise Failure(2, "no wicked-crew origin: $WICKED_CREW_URL is unset (crew sets it for every "
102
+ "governed unit) and no --crew-url was given; pass --crew-url <the daemon's "
103
+ "URL> — never assume a port")
104
+ return origin.rstrip("/")
105
+
106
+
107
+ TARGETS = ("worker", "operator")
108
+ CHOICE_LABELS = {"worker": "Install for workers", "operator": "Also install into my CLIs"}
109
+
110
+
111
+ def worker_home() -> Path:
112
+ """The ONE worker home every daemon's seats run from (``$WICKED_WORKER_HOME``)."""
113
+ env = os.environ.get("WICKED_WORKER_HOME")
114
+ return Path(env) if env else Path.home() / ".wicked-worker"
115
+
116
+
117
+ def worker_home_args() -> list[str]:
118
+ """wicked-installer's per-CLI home flags pointed at the worker seats' config roots — the
119
+ layout the engine spawns seats with (wicked-apps-core ``seat_config_in``): claude's
120
+ ``CLAUDE_CONFIG_DIR``, codex's ``CODEX_HOME``, opencode's ``XDG_CONFIG_HOME/opencode``,
121
+ pi's agent dir, antigravity's root."""
122
+ base = worker_home()
123
+ return ["--claude-home", str(base / "claude"), "--codex-home", str(base / "codex"),
124
+ "--opencode-home", str(base / "opencode" / "config" / "opencode"),
125
+ "--pi-home", str(base / "pi"), "--gemini-home", str(base / "agy")]
126
+
127
+
128
+ def operator_owned(path: str, root: Path) -> bool:
129
+ """Is ``path`` outside the program-owned roots (``~/.wicked*``, the worker home, the install
130
+ root)? A URL (the registry) is the daemon's, never the operator's files."""
131
+ if "://" in path:
132
+ return False
133
+ p = Path(os.path.abspath(os.path.expanduser(path)))
134
+ owned = [worker_home(), root, Path.home() / ".wicked"]
135
+ if any(p == o or o in p.parents for o in (Path(os.path.abspath(x)) for x in owned)):
136
+ return False
137
+ try:
138
+ rel = p.relative_to(Path.home())
139
+ except ValueError:
140
+ return True
141
+ return not (rel.parts and rel.parts[0].startswith(".wicked"))
86
142
 
87
143
 
88
144
  def install_root(flag: Path | None) -> Path:
@@ -288,9 +344,12 @@ def register(origin: str, key: str, server_js: Path, var: str) -> dict:
288
344
 
289
345
  # ── 6. CLI configs ─────────────────────────────────────────────────────────────────────────
290
346
 
291
- def installer(argv: list[str]):
292
- """Run ``wicked-installer <argv> --json``: its envelope, or the reportable condition."""
347
+ def installer(argv: list[str], download: bool = True):
348
+ """Run ``wicked-installer <argv> --json``: its envelope, or the reportable condition.
349
+ ``download=False`` (the dry run) never falls back to ``npx -y``, which would install it."""
293
350
  exe = shutil.which("wicked-installer")
351
+ if exe is None and not download:
352
+ return "skipped: wicked-installer is not on PATH (the dry run never downloads it)"
294
353
  cmd = [exe, *argv] if exe else [shutil.which("npx") or "npx", "-y", "wicked-installer", *argv]
295
354
  try:
296
355
  done = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
@@ -433,11 +492,72 @@ def do_install(args) -> tuple[int, dict]:
433
492
 
434
493
  out = {**record, **reg, "probe": probe_report}
435
494
  out.setdefault("registry", None)
436
- out["clis"] = "skipped: --cli none" if clis == "none" else installer(
437
- ["mcp", "upsert", key, "--command", "node", "--arg", str(current_js), "--cli", clis, "--json"])
495
+ upsert = ["mcp", "upsert", key, "--command", "node", "--arg", str(current_js), "--cli", clis, "--json"]
496
+ out["target"] = args.target
497
+ if clis == "none":
498
+ out["clis"] = "skipped: --cli none"
499
+ else:
500
+ out["clis"] = installer([*upsert, *worker_home_args()])
501
+ if args.target == "operator":
502
+ out["operatorClis"] = installer(upsert)
438
503
  return code, out
439
504
 
440
505
 
506
+ def _cli_rows(envelope, root: Path, writes: list, skipped: list) -> None:
507
+ """Split one installer dry-run envelope into planned writes and skipped CLIs."""
508
+ if not isinstance(envelope, list):
509
+ raise Failure(1, f"cannot plan the CLI configs: {envelope}")
510
+ for row in envelope:
511
+ if not isinstance(row, dict):
512
+ continue
513
+ cli, result, target = row.get("cli"), row.get("result"), row.get("target")
514
+ if result == "planned" and isinstance(target, str):
515
+ what = f"{cli} MCP config" + (" (codex mcp add)" if cli == "codex" else "")
516
+ writes.append({"path": os.path.abspath(os.path.expanduser(target)), "what": what,
517
+ "cli": cli, "operator_owned": operator_owned(target, root)})
518
+ else:
519
+ why = str(result or "unknown")
520
+ if row.get("detail") and result not in ("unsupported",):
521
+ why = f"{why}: {row['detail']}"
522
+ if not any(s["cli"] == cli and s["why"] == why for s in skipped):
523
+ skipped.append({"cli": cli, "why": why})
524
+
525
+
526
+ def do_plan(args) -> dict:
527
+ """``--dry-run``: what each target would write, from the installer's own dry run. Writes
528
+ nothing; the registry is only READ (the same key check the install runs first)."""
529
+ if args.from_npm and not NPM_SPEC_RE.match(args.from_npm):
530
+ raise Failure(2, f"--from-npm {args.from_npm!r}: use <pkg>@<version>")
531
+ server_dir, config = locate(args)
532
+ key = config.get("key") or args.key
533
+ if not key or not NAME_RE.match(key):
534
+ raise Failure(2, f"invalid or missing server key {key!r}")
535
+ if server_dir is None and not args.from_npm:
536
+ raise Failure(2, "pass --from-run or --dir <server dir> (or --from-npm with --key)")
537
+ clis = cli_list(args.cli)
538
+ root = install_root(args.install_root)
539
+ origin = crew_origin(args.crew_url)
540
+ current_js = root / key / "current" / installed_rel(args.from_npm)
541
+ check_key(origin, key, current_js)
542
+ base = [{"path": str(Path(os.path.abspath(root / key / "current"))), "what": "install root",
543
+ "operator_owned": operator_owned(str(root / key), root)},
544
+ {"path": f"{origin}/api/v1/mcp/servers/{key}", "what": "wicked-crew MCP tools registry entry",
545
+ "operator_owned": False}]
546
+ skipped: list = []
547
+ worker_writes = list(base)
548
+ operator_writes = list(base)
549
+ if clis != "none":
550
+ upsert = ["mcp", "upsert", key, "--command", "node", "--arg", str(current_js), "--cli", clis,
551
+ "--dry-run", "--json"]
552
+ _cli_rows(installer([*upsert, *worker_home_args()], download=False), root, worker_writes, skipped)
553
+ operator_writes = list(worker_writes)
554
+ _cli_rows(installer(upsert, download=False), root, operator_writes, skipped)
555
+ return {"dry_run": True, "key": key, "choices": [
556
+ {"id": "worker", "label": CHOICE_LABELS["worker"], "default": True, "writes": worker_writes},
557
+ {"id": "operator", "label": CHOICE_LABELS["operator"], "writes": operator_writes}],
558
+ "skipped": skipped}
559
+
560
+
441
561
  def do_uninstall(args) -> tuple[int, dict]:
442
562
  key = args.uninstall
443
563
  if not NAME_RE.match(key):
@@ -458,7 +578,13 @@ def do_uninstall(args) -> tuple[int, dict]:
458
578
  except (urllib.error.URLError, OSError):
459
579
  out["registered"] = False
460
580
  out["reason"] = f"no daemon at {origin}"
461
- out["clis"] = "skipped: --cli none" if clis == "none" else installer(["mcp", "remove", key, "--json"])
581
+ out["target"] = args.target
582
+ if clis == "none":
583
+ out["clis"] = "skipped: --cli none"
584
+ else:
585
+ out["clis"] = installer(["mcp", "remove", key, "--json", *worker_home_args()])
586
+ if args.target == "operator":
587
+ out["operatorClis"] = installer(["mcp", "remove", key, "--json"])
462
588
  key_root = install_root(args.install_root) / key
463
589
  out["removed"] = str(key_root) if key_root.exists() else None
464
590
  if key_root.exists():
@@ -476,10 +602,24 @@ def main(argv: list[str] | None = None) -> int:
476
602
  parser.add_argument("--key", help="the server key (must equal the config's key)")
477
603
  parser.add_argument("--from-npm", metavar="PKG@VERSION", help="install a published package instead")
478
604
  parser.add_argument("--cli", default="all", help="all | none | " + ",".join(CLIS))
479
- parser.add_argument("--crew-url", help=f"the wicked-crew origin (default $WICKED_CREW_URL, else {DEFAULT_ORIGIN})")
605
+ parser.add_argument("--crew-url", help="the wicked-crew origin (default $WICKED_CREW_URL; no port is assumed)")
606
+ parser.add_argument("--target", choices=TARGETS, default="worker",
607
+ help="worker (default): program-owned places only; operator: also your own CLI configs")
608
+ parser.add_argument("--dry-run", action="store_true",
609
+ help="write nothing; print the plan (each target's exact write list) as one JSON line")
480
610
  parser.add_argument("--install-root", type=Path, help="default $WICKED_MCP_INSTALL_ROOT, else ~/.wicked/mcp-servers")
481
611
  parser.add_argument("--json", action="store_true", help="print the record as one compact JSON line")
482
612
  args = parser.parse_args(argv)
613
+ if args.dry_run:
614
+ if args.uninstall:
615
+ print(json.dumps({"ok": False, "error": "--dry-run plans an install, not --uninstall"}))
616
+ return 2
617
+ try:
618
+ print(json.dumps(do_plan(args)))
619
+ return 0
620
+ except Failure as err:
621
+ sys.stderr.write(f"install.py --dry-run: {err}\n")
622
+ return err.code
483
623
  try:
484
624
  code, out = do_uninstall(args) if args.uninstall else do_install(args)
485
625
  except Failure as err:
@@ -6,7 +6,7 @@
6
6
 
7
7
  The conversion is wicked-crew's: this posts ``{name, kind: "rest", url, openapiUrl | openapi,
8
8
  operations?}`` to ``POST <origin>/api/v1/mcp/servers/preview`` (the origin is ``--crew-url``,
9
- else ``$WICKED_CREW_URL``, else http://127.0.0.1:7701), and writes the answer's ``tools`` and
9
+ else ``$WICKED_CREW_URL`` — no port is assumed, garden#1258), and writes the answer's ``tools`` and
10
10
  ``skipped`` into ``<dir>/tools.json`` as ``{"source": {...}, "tools": [...], "skipped": [...]}``
11
11
  — one tool per operation, each with its ``class`` and ``rest`` request mapping — then sets
12
12
  ``baseUrl`` in ``<dir>/mcp-server.config.json``. It refuses to overwrite a ``tools.json``
@@ -30,7 +30,6 @@ import urllib.parse
30
30
  import urllib.request
31
31
  from pathlib import Path
32
32
 
33
- DEFAULT_ORIGIN = "http://127.0.0.1:7701"
34
33
  PREVIEW = "/api/v1/mcp/servers/preview"
35
34
  NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
36
35
  TIMEOUT_S = 60
@@ -48,8 +47,10 @@ def _fail(code: int, message: str) -> int:
48
47
  return code
49
48
 
50
49
 
51
- def crew_origin(flag: str | None) -> str:
52
- return (flag or os.environ.get("WICKED_CREW_URL") or DEFAULT_ORIGIN).rstrip("/")
50
+ def crew_origin(flag: str | None) -> str | None:
51
+ """The daemon THIS run belongs to, or None — never a guessed port (garden#1258)."""
52
+ origin = flag or os.environ.get("WICKED_CREW_URL")
53
+ return origin.rstrip("/") if origin else None
53
54
 
54
55
 
55
56
  def _http_url(raw: str, what: str, bare: bool) -> str:
@@ -125,7 +126,7 @@ def main(argv: list[str] | None = None) -> int:
125
126
  spec.add_argument("--spec-url", help="an http(s) URL the daemon fetches the document from")
126
127
  parser.add_argument("--operations", help="comma-separated operationIds to keep (default all)")
127
128
  parser.add_argument("--out", required=True, type=Path, help="the server directory")
128
- parser.add_argument("--crew-url", help=f"the wicked-crew daemon origin (default $WICKED_CREW_URL, else {DEFAULT_ORIGIN})")
129
+ parser.add_argument("--crew-url", help="the wicked-crew daemon origin (default $WICKED_CREW_URL; no port is assumed)")
129
130
  args = parser.parse_args(argv)
130
131
 
131
132
  if not NAME_RE.match(args.name):
@@ -160,6 +161,10 @@ def main(argv: list[str] | None = None) -> int:
160
161
  return _fail(err.code, str(err))
161
162
 
162
163
  origin = crew_origin(args.crew_url)
164
+ if origin is None:
165
+ return _fail(2, "no wicked-crew origin: $WICKED_CREW_URL is unset (crew sets it for every "
166
+ "governed unit) and no --crew-url was given; pass --crew-url <the daemon's URL> "
167
+ "— never assume a port")
163
168
  try:
164
169
  answer = post_json(origin + PREVIEW, body)
165
170
  except Refused as err:
@@ -74,8 +74,9 @@ wicked-garden run scripts/mcp/openapi.py --name <key> --base-url <url> (--spec <
74
74
 
75
75
  The conversion service is wicked-crew's (`POST /api/v1/mcp/servers/preview`, kind `rest`) —
76
76
  the same one studio's *Wrap an API* uses, so there is one OpenAPI reading in the platform.
77
- The daemon origin is `--crew-url`, else `$WICKED_CREW_URL` (set in every governed unit),
78
- else `http://127.0.0.1:7701`. One tool per operation; the class comes from the method
77
+ The daemon origin is `--crew-url`, else `$WICKED_CREW_URL` (crew sets it to its own URL in
78
+ every governed unit). There is no port default: never type a port — a guessed one can
79
+ be another daemon's registry. With neither set the scripts exit 2 naming the remedy. One tool per operation; the class comes from the method
79
80
  (GET/HEAD/OPTIONS read, POST write, PUT/PATCH/DELETE destructive); 1 MB and 10 s bounds;
80
81
  external `$ref`s are not followed.
81
82
 
@@ -111,6 +112,11 @@ external `$ref`s are not followed.
111
112
  basic user, token URL, scopes, client id, base URL, rate limit — is committed in
112
113
  `mcp-server.config.json`. A missing secret fails at startup naming the variable. One
113
114
  variable is the contract because crew's broker and registry inject exactly one.
115
+ - **A query-parameter API key** (an OpenAPI `apiKey` scheme with `in: query`) is config, not a
116
+ hand extension: `"auth": {"scheme": "api-key", "queryParam": "<name>", ...}`. The server adds
117
+ it to the URL after the boundary check, no argument can set it, and it is scrubbed (raw and
118
+ URL-encoded) from upstream error bodies exactly like a header credential — every credential
119
+ the server sends goes through one carrier, `upstreamCredentials`.
114
120
  - **Logging goes to stderr only** (loglayer; the console transport is bound to stderr),
115
121
  with trace ids stamped by the OpenTelemetry plugin and secrets redacted by name.
116
122
  - **Telemetry is off by default:** the SDK arms its OTLP exporters only when
@@ -190,8 +196,8 @@ Then derive each tool's class by hand from the table in step 3.
190
196
  ## 5. Install for running (`install`)
191
197
 
192
198
  ```
193
- wicked-garden run scripts/mcp/install.py (--from-run | --dir <server dir>) [--key <key>] [--from-npm <pkg>@<version>] [--cli all|claude,codex,opencode,antigravity,pi|none] [--crew-url <origin>] [--install-root <dir>] [--json]
194
- wicked-garden run scripts/mcp/install.py --uninstall <key>
199
+ wicked-garden run scripts/mcp/install.py (--from-run | --dir <server dir>) [--key <key>] [--from-npm <pkg>@<version>] [--target worker|operator] [--dry-run] [--cli all|claude,codex,opencode,antigravity,pi|none] [--crew-url <origin>] [--install-root <dir>] [--json]
200
+ wicked-garden run scripts/mcp/install.py --uninstall <key> [--target worker|operator]
195
201
  ```
196
202
 
197
203
  Installs the built TypeScript server — or updates the installed copy — idempotent by the
@@ -220,7 +226,20 @@ config's `key`:
220
226
  different server (another `kind`, command or args — say a REST server with the same name)
221
227
  exits 1 naming it, with nothing written. Pick another key, or remove that server first.
222
228
  6. **CLI configs** through `wicked-installer mcp upsert` (never written by this script); an
223
- installer without the verb is reported as `clis: "skipped: …"`, not failed.
229
+ installer without the verb is reported as `clis: "skipped: …"`, not failed. **`--target
230
+ worker`** (the default) registers the server only in the worker seats' CLI configs under
231
+ the worker home (`$WICKED_WORKER_HOME`, else `~/.wicked-worker`: `claude/.claude.json`,
232
+ `codex/config.toml`, `opencode/config/opencode/`); **`--target operator`** also writes the
233
+ operator's own CLI configs (`~/.claude.json`, `~/.codex/config.toml`, the opencode
234
+ config), reported as `operatorClis`. Only the operator chooses `operator`, at the install
235
+ gate.
236
+
237
+ **`--dry-run`** writes nothing (it only reads the registry for the key check) and prints ONE
238
+ JSON line, the plan the install gate shows: `{"dry_run": true, "key", "choices": [{"id":
239
+ "worker" | "operator", "label", "default"?, "writes": [{"path", "what", "cli"?,
240
+ "operator_owned"}]}], "skipped": [{"cli", "why"}]}` — every path absolute, each choice listing
241
+ everything it would write, the CLI rows from `wicked-installer mcp upsert --dry-run`. It exits
242
+ non-zero (the reason on stderr) when it cannot plan.
224
243
  7. **Record** `<root>/<key>/installed.json` `{key, version, source, command, args,
225
244
  envNames, installedAt, registered, smoke}` and print it with `probe`, `registry` and `clis`.
226
245
 
@@ -268,7 +287,8 @@ run. Follow `wicked-garden-governed-worker`, and:
268
287
  client needs httpStream) and the **target directory**: the root of a near-empty repo, or a
269
288
  subdirectory of an existing one. A repository that is not registered must be created and
270
289
  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
290
+ (`GET $WICKED_CREW_URL/api/v1/mcp/servers` — the daemon this run belongs to, never a typed
291
+ port; or studio MCP tools) and pick a key no other
272
292
  server holds: install refuses a key held by a different server. Record whether an OpenAPI
273
293
  document was given and whether to install when complete (the operator still decides at
274
294
  the install gate).
@@ -281,8 +301,9 @@ run. Follow `wicked-garden-governed-worker`, and:
281
301
  - **build** — scaffold (step 1), then branch A (generated tools from `tools.json`) or branch
282
302
  B (hand-written tools through `defineTool`), with a contract test per tool and
283
303
  `npm test` green; probe it (step 4).
284
- - **install** — a Tool phase runs `install.py --from-run` after an unconditional operator
285
- gate (step 5). Read its record: `smoke` (`ok` or `pending_credential`), `registered`
304
+ - **install-plan / install** — a Tool phase runs `install.py --from-run --dry-run --json`
305
+ (the plan), then the operator's consent gate offers its choices with their files, then a
306
+ Tool phase runs `install.py --from-run --target <the approved choice>` (step 5). Read its record: `smoke` (`ok` or `pending_credential`), `registered`
286
307
  (`true`, `"updated"`, or `false` with `missing`/`remedy` or `reason`) and `clis` (per-CLI
287
308
  results, or why they were skipped). Exit 0 means staged; a disclosed pending credential is
288
309
  not a failure.
@@ -29,7 +29,10 @@ The probe starts the server with a minimal environment plus only the variables n
29
29
  The server reads exactly ONE secret: `__SERVER_ENV___TOKEN` — the bearer token, the API key, the
30
30
  basic-auth password or the OAuth2 client secret, as `auth.scheme` in `mcp-server.config.json`
31
31
  says. Everything else about auth (header name, basic user, token URL, scopes, client id) is
32
- committed, non-secret configuration in `mcp-server.config.json`. A missing secret fails at
32
+ committed, non-secret configuration in `mcp-server.config.json`. An API key the upstream takes
33
+ as a QUERY parameter is `"auth": {"scheme": "api-key", "queryParam": "<name>"}` — sent after the
34
+ boundary check, never settable by an argument, and scrubbed from upstream error bodies like any
35
+ header credential. A missing secret fails at
33
36
  startup naming the variable. One variable is the contract because wicked-crew's broker and
34
37
  registry inject exactly one environment variable into a stdio server. See `.env.example`.
35
38
 
@@ -40,8 +40,9 @@
40
40
  "devDependencies": {
41
41
  "@eslint/js": "^10.0.1",
42
42
  "@types/node": "^24.0.0",
43
+ "@typescript/native": "npm:typescript@~7.0.2",
43
44
  "eslint": "^10.12.0",
44
- "typescript": "~6.0.3",
45
+ "typescript": "npm:@typescript/typescript6@~6.0.2",
45
46
  "typescript-eslint": "^8.71.1",
46
47
  "vitest": "^5.0.3"
47
48
  }
@@ -3,6 +3,7 @@
3
3
  import { timingSafeEqual } from "node:crypto";
4
4
  import type { IncomingMessage } from "node:http";
5
5
  import { secretVar, type ServerConfig } from "./config.js";
6
+ import type { Credentials } from "./rest.js";
6
7
 
7
8
  /** The authenticated MCP client of an httpStream session. */
8
9
  export type Session = { principal: string };
@@ -24,26 +25,31 @@ export function loadSecret(config: ServerConfig, env: NodeJS.ProcessEnv = proces
24
25
  type FetchLike = typeof fetch;
25
26
  let cached: { key: string; token: string; expiresAt: number } | undefined;
26
27
 
27
- /** The header(s) the upstream API expects, built from the secret plus the committed config. */
28
- export async function upstreamHeaders(
28
+ /**
29
+ * Every credential the upstream API expects — header(s), or a query parameter for an api-key
30
+ * with `auth.queryParam` — built from the secret plus the committed config. ONE carrier: the
31
+ * request builder sends exactly these, and the error scrub removes exactly these.
32
+ */
33
+ export async function upstreamCredentials(
29
34
  config: ServerConfig,
30
35
  secret: string,
31
36
  fetchImpl: FetchLike = fetch,
32
- ): Promise<Record<string, string>> {
37
+ ): Promise<Credentials> {
33
38
  const { auth } = config;
39
+ const header = (value: string): Credentials => ({ headers: { [auth.header]: value }, query: {} });
34
40
  switch (auth.scheme) {
35
41
  case "bearer":
36
- return { [auth.header]: `Bearer ${secret}` };
42
+ return header(`Bearer ${secret}`);
37
43
  case "api-key":
38
- return { [auth.header]: secret };
44
+ return auth.queryParam !== null ? { headers: {}, query: { [auth.queryParam]: secret } } : header(secret);
39
45
  case "basic":
40
- return { [auth.header]: `Basic ${Buffer.from(`${auth.user}:${secret}`).toString("base64")}` };
46
+ return header(`Basic ${Buffer.from(`${auth.user}:${secret}`).toString("base64")}`);
41
47
  case "oauth2-client-credentials": {
42
48
  const key = `${auth.tokenUrl} ${auth.clientId} ${auth.scopes.join(" ")}`;
43
49
  if (!cached || cached.key !== key || cached.expiresAt <= Date.now()) {
44
50
  cached = { key, ...(await exchange(config, secret, fetchImpl)) };
45
51
  }
46
- return { [auth.header]: `Bearer ${cached.token}` };
52
+ return header(`Bearer ${cached.token}`);
47
53
  }
48
54
  }
49
55
  }
@@ -15,6 +15,10 @@ const AuthSchema = z.object({
15
15
  tokenUrl: z.url({ protocol: /^https?$/ }).nullable(),
16
16
  scopes: z.array(z.string().min(1)),
17
17
  clientId: z.string().min(1).nullable(),
18
+ // api-key only: send the key as this QUERY parameter instead of the header (an OpenAPI
19
+ // `apiKey` scheme with `in: query`, garden#1261). The server adds it after the boundary check
20
+ // and scrubs it from upstream error bodies like any other credential it sends.
21
+ queryParam: z.string().min(1).nullable().default(null),
18
22
  });
19
23
 
20
24
  const ConfigSchema = z
@@ -35,6 +39,9 @@ const ConfigSchema = z
35
39
  if (u.username || u.password || u.search || u.hash) {
36
40
  ctx.addIssue({ code: "custom", path: ["baseUrl"], message: "no credentials, query or fragment" });
37
41
  }
42
+ if (cfg.auth.queryParam !== null && cfg.auth.scheme !== "api-key") {
43
+ ctx.addIssue({ code: "custom", path: ["auth", "queryParam"], message: "a query-parameter key is the api-key scheme only" });
44
+ }
38
45
  if (cfg.auth.scheme === "basic" && cfg.auth.user === null) {
39
46
  ctx.addIssue({ code: "custom", path: ["auth", "user"], message: "basic auth needs auth.user" });
40
47
  }
@@ -16,10 +16,16 @@ export interface RestMapping {
16
16
  timeoutMs: number;
17
17
  }
18
18
 
19
+ /** Every credential a request carries: header(s) and/or query parameter(s). */
20
+ export interface Credentials {
21
+ headers: Record<string, string>;
22
+ query: Record<string, string>;
23
+ }
24
+
19
25
  export interface RestContext {
20
26
  baseUrl: string;
21
- /** The upstream auth header(s) — its name is also forbidden to arguments. */
22
- headers: () => Promise<Record<string, string>>;
27
+ /** The upstream credentials — their header and query names are also forbidden to arguments. */
28
+ credentials: () => Promise<Credentials>;
23
29
  /** Read tools are retried on 429/5xx; write and destructive tools never are. */
24
30
  readOnly: boolean;
25
31
  fetchImpl?: typeof fetch;
@@ -28,6 +34,33 @@ export interface RestContext {
28
34
  export interface RestResult {
29
35
  status: number;
30
36
  body: unknown;
37
+ /** The exact credentials this request carried — what an error scrub must remove. */
38
+ sent: Credentials;
39
+ }
40
+
41
+ /**
42
+ * `text` with every credential in `sent` (header and query values; raw, after a `Bearer `-style
43
+ * scheme, and URL-encoded) replaced by `[redacted]`. The ONE scrub: upstream error bodies and
44
+ * transport errors (whose message may carry the request URL, query key included) both use it.
45
+ */
46
+ export function scrubCredentials(text: string, sent: Credentials): string {
47
+ let out = text;
48
+ for (const value of [...Object.values(sent.headers), ...Object.values(sent.query)]) {
49
+ const bare = value.includes(" ") ? value.slice(value.indexOf(" ") + 1) : "";
50
+ const spellings = (v: string) => [
51
+ v,
52
+ encodeURIComponent(v),
53
+ new URLSearchParams({ v }).toString().slice(2),
54
+ JSON.stringify(v).slice(1, -1), // as an echoed JSON body serializes it
55
+ ];
56
+ const parts = [...spellings(value), ...(bare ? spellings(bare) : [])];
57
+ // Longest first, so a scrubbed prefix never leaves the rest of a longer spelling behind.
58
+ // Every non-empty spelling: a short key is still a key.
59
+ for (const part of [...new Set(parts)].sort((a, b) => b.length - a.length)) {
60
+ if (part.length > 0) out = out.split(part).join("[redacted]");
61
+ }
62
+ }
63
+ return out;
31
64
  }
32
65
 
33
66
  /** Headers an argument can never set: routing, auth and the hop-by-hop set. */
@@ -66,8 +99,9 @@ export function buildRequest(
66
99
  baseUrl: string,
67
100
  mapping: RestMapping,
68
101
  args: Record<string, unknown>,
69
- authHeaders: Record<string, string>,
102
+ creds: Credentials = { headers: {}, query: {} },
70
103
  ): { url: URL; init: RequestInit } {
104
+ const authHeaders = creds.headers;
71
105
  const base = new URL(baseUrl);
72
106
  const allowed = new Set(mapping.argAllowlist);
73
107
  const sent = Object.fromEntries(Object.entries(args).filter(([k]) => allowed.has(k)));
@@ -84,9 +118,11 @@ export function buildRequest(
84
118
  url.pathname = `${basePath(base)}${filled}`;
85
119
  const escape = escapes(base, url);
86
120
  if (escape !== null) throw new BoundaryError(escape);
121
+ const authQuery = new Set(Object.keys(creds.query));
87
122
  for (const [arg, q] of Object.entries(mapping.queryMap)) {
88
123
  const v = sent[arg];
89
- if (v === undefined || v === null) continue;
124
+ // An argument never sets (or doubles) the credential's query parameter.
125
+ if (v === undefined || v === null || authQuery.has(q)) continue;
90
126
  for (const item of Array.isArray(v) ? v : [v]) url.searchParams.append(q, scalar(item, `the query argument ${arg}`));
91
127
  }
92
128
  const deny = new Set(DENY_HEADERS);
@@ -109,6 +145,8 @@ export function buildRequest(
109
145
  }
110
146
  if (body !== undefined) headers["content-type"] = "application/json";
111
147
  Object.assign(headers, authHeaders);
148
+ // After the boundary check (a query never moves the origin or path): the credential last.
149
+ for (const [q, value] of Object.entries(creds.query)) url.searchParams.set(q, value);
112
150
  return { url, init: { method: mapping.method, headers, ...(body !== undefined ? { body } : {}) } };
113
151
  }
114
152
 
@@ -129,7 +167,8 @@ async function readBody(res: Response): Promise<unknown> {
129
167
  * 429/5xx twice for read tools only. One child span `http.client.request` per call.
130
168
  */
131
169
  export async function callRest(mapping: RestMapping, args: Record<string, unknown>, ctx: RestContext): Promise<RestResult> {
132
- const { url, init } = buildRequest(ctx.baseUrl, mapping, args, await ctx.headers());
170
+ const sent = await ctx.credentials();
171
+ const { url, init } = buildRequest(ctx.baseUrl, mapping, args, sent);
133
172
  const fetchImpl = ctx.fetchImpl ?? fetch;
134
173
  return tracer.startActiveSpan(
135
174
  "http.client.request",
@@ -146,12 +185,17 @@ export async function callRest(mapping: RestMapping, args: Record<string, unknow
146
185
  continue;
147
186
  }
148
187
  if (res.status >= 400) span.setStatus({ code: SpanStatusCode.ERROR });
149
- return { status: res.status, body: await readBody(res) };
188
+ return { status: res.status, body: await readBody(res), sent };
150
189
  }
151
190
  } catch (err) {
152
- span.recordException(err as Error);
191
+ // A transport error can name the request URL — a query-parameter key with it. Neither
192
+ // the span nor the caller ever sees the raw message.
193
+ const e = err instanceof Error ? err : new Error(String(err));
194
+ const safe = new Error(scrubCredentials(e.message, sent));
195
+ safe.name = e.name;
196
+ span.recordException(safe);
153
197
  span.setStatus({ code: SpanStatusCode.ERROR });
154
- throw err;
198
+ throw safe;
155
199
  } finally {
156
200
  span.end();
157
201
  }
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // SPDX-FileCopyrightText: __YEAR__ the __SERVER_NAME__ authors
3
3
  import { FastMCP } from "fastmcp";
4
- import { authenticate, configureAuth, loadSecret, upstreamHeaders, type Session } from "./auth.js";
4
+ import { authenticate, configureAuth, loadSecret, upstreamCredentials, type Session } from "./auth.js";
5
5
  import { loadConfig, secretVar } from "./config.js";
6
6
  import { fastmcpLogger, initLogging } from "./logging.js";
7
7
  import { startTelemetry, stopTelemetry } from "./telemetry.js";
@@ -26,7 +26,7 @@ async function main(): Promise<void> {
26
26
  authenticate: config.transport === "httpStream" ? authenticate : undefined,
27
27
  });
28
28
 
29
- const generated = registerGeneratedTools(server, config, () => upstreamHeaders(config, secret));
29
+ const generated = registerGeneratedTools(server, config, () => upstreamCredentials(config, secret));
30
30
  // The example `echo` tool only stands in while tools.json is empty: a server with generated
31
31
  // tools exposes exactly those (plus whatever you register by hand).
32
32
  if (generated === 0) registerExampleTools(server, config);
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
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
- import { callRest, type RestMapping } from "../rest.js";
8
+ import { callRest, scrubCredentials, type Credentials, type RestMapping } from "../rest.js";
9
9
  import { defineTool } from "./registry.js";
10
10
 
11
11
  /** One tools.json entry, as wicked-crew's conversion service (`openapi` action) writes it. */
@@ -39,15 +39,12 @@ export const ERROR_BODY_MAX = 300;
39
39
 
40
40
  /**
41
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.
42
+ * every credential this server sent — header AND query (garden#1261) — scrubbed out, raw and
43
+ * URL-encoded, so an upstream that echoes its auth header or request URL back cannot hand the
44
+ * secret to the caller.
44
45
  */
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
- }
46
+ export function upstreamError(status: number, body: string, sent: Credentials): string {
47
+ let text = scrubCredentials(body, sent);
51
48
  if (text.length > ERROR_BODY_MAX) text = `${text.slice(0, ERROR_BODY_MAX)}… (truncated)`;
52
49
  return text ? `HTTP ${status}: ${text}` : `HTTP ${status}`;
53
50
  }
@@ -56,7 +53,7 @@ export function upstreamError(status: number, body: string, sent: Record<string,
56
53
  export function registerGeneratedTools(
57
54
  server: FastMCP<Session>,
58
55
  config: ServerConfig,
59
- headers: () => Promise<Record<string, string>>,
56
+ credentials: () => Promise<Credentials>,
60
57
  tools: GeneratedTool[] = readToolsJson(),
61
58
  ): number {
62
59
  for (const t of tools) {
@@ -71,11 +68,13 @@ export function registerGeneratedTools(
71
68
  execute: async (args) => {
72
69
  const result = await callRest(t.rest, (args ?? {}) as Record<string, unknown>, {
73
70
  baseUrl: config.baseUrl,
74
- headers,
71
+ credentials,
75
72
  readOnly: t.class === "read",
76
73
  });
77
74
  const text = typeof result.body === "string" ? result.body : JSON.stringify(result.body);
78
- if (result.status >= 400) throw new UserError(upstreamError(result.status, text, await headers()));
75
+ // The credentials the request CARRIED, never a fresh fetch (an OAuth2 token may have
76
+ // rotated in between).
77
+ if (result.status >= 400) throw new UserError(upstreamError(result.status, text, result.sent));
79
78
  return text;
80
79
  },
81
80
  });
@@ -48,7 +48,7 @@ function toolUnderTest(tool: GeneratedTool) {
48
48
  const config = { ...loadConfig({}), baseUrl };
49
49
  const added: any[] = [];
50
50
  const fake = { addTool: (t: unknown) => added.push(t) } as any;
51
- registerGeneratedTools(fake, config, async () => ({ Authorization: "Bearer test-token" }), [tool]);
51
+ registerGeneratedTools(fake, config, async () => ({ headers: { Authorization: "Bearer test-token" }, query: {} }), [tool]);
52
52
  return added[0];
53
53
  }
54
54
 
@@ -70,6 +70,6 @@ describe("get_note contract", () => {
70
70
  });
71
71
 
72
72
  it("never leaves the base URL", () => {
73
- expect(() => buildRequest(baseUrl, getNote.rest, { id: ".." }, {})).toThrow(BoundaryError);
73
+ expect(() => buildRequest(baseUrl, getNote.rest, { id: ".." })).toThrow(BoundaryError);
74
74
  });
75
75
  });
@@ -2,9 +2,17 @@
2
2
  // SPDX-FileCopyrightText: __YEAR__ the __SERVER_NAME__ authors
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
5
6
 
6
- /** Build dist/ once before the suite: the conformance smoke speaks to the built server. */
7
+ /**
8
+ * Build dist/ once before the suite: the conformance smoke speaks to the built server.
9
+ * The compiler is TypeScript 7, installed as `@typescript/native`; `typescript` is aliased to the
10
+ * TS 6 API package because typescript-eslint does not support TS 7 yet. TS 7 does not export
11
+ * `./bin/tsc`, so the bin is resolved through its manifest.
12
+ */
7
13
  export default function setup(): void {
8
- const tsc = createRequire(import.meta.url).resolve("typescript/bin/tsc");
9
- execFileSync(process.execPath, [tsc], { stdio: "inherit" });
14
+ const require = createRequire(import.meta.url);
15
+ const manifest = require.resolve("@typescript/native/package.json");
16
+ const { bin } = require(manifest) as { bin: { tsc: string } };
17
+ execFileSync(process.execPath, [join(dirname(manifest), bin.tsc)], { stdio: "inherit" });
10
18
  }
@@ -1,19 +1,44 @@
1
1
  // SPDX-License-Identifier: MIT
2
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";
3
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
4
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { createServer, type Server } from "node:http";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { UserError } from "fastmcp";
9
+ import { upstreamCredentials } from "../src/auth.js";
10
+ import { loadConfig, SERVER_ROOT, type ServerConfig } from "../src/config.js";
11
+ import { buildRequest, type RestMapping } from "../src/rest.js";
12
+ import { ERROR_BODY_MAX, registerGeneratedTools, upstreamError } from "../src/tools/generated.js";
6
13
 
7
14
  const BASE_VAR = "__SERVER_ENV___BASE_URL";
8
15
 
9
16
  describe("upstream error text", () => {
10
17
  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" });
18
+ const text = upstreamError(401, "bad header: Bearer tok-1234 (tok-1234)", {
19
+ headers: { Authorization: "Bearer tok-1234" },
20
+ query: {},
21
+ });
12
22
  expect(text).toBe("HTTP 401: bad header: [redacted] ([redacted])");
13
23
  });
14
24
 
25
+ it("scrubs a query-parameter key too, raw and URL-encoded (garden#1261)", () => {
26
+ const sent = { headers: {}, query: { api_key: "k3y/with+chars" } };
27
+ const text = upstreamError(403, "bad key k3y/with+chars in /x?api_key=k3y%2Fwith%2Bchars", sent);
28
+ expect(text).not.toContain("k3y");
29
+ expect(text).toBe("HTTP 403: bad key [redacted] in /x?api_key=[redacted]");
30
+ });
31
+
32
+ it("scrubs a short key and a JSON-escaped key too (review r2)", () => {
33
+ expect(upstreamError(401, "?api_key=abc", { headers: {}, query: { api_key: "abc" } })).toBe("HTTP 401: ?api_key=[redacted]");
34
+ const quoted = 'k"ey\\x';
35
+ const body = JSON.stringify({ api_key: quoted });
36
+ const text = upstreamError(401, body, { headers: {}, query: { api_key: quoted } });
37
+ expect(text).toBe('HTTP 401: {"api_key":"[redacted]"}');
38
+ });
39
+
15
40
  it("bounds the body a caller sees", () => {
16
- const text = upstreamError(500, "x".repeat(ERROR_BODY_MAX * 3), {});
41
+ const text = upstreamError(500, "x".repeat(ERROR_BODY_MAX * 3), { headers: {}, query: {} });
17
42
  expect(text.length).toBeLessThan(ERROR_BODY_MAX + 40);
18
43
  expect(text).toContain("(truncated)");
19
44
  });
@@ -31,3 +56,76 @@ describe("base URL override", () => {
31
56
  expect(() => loadConfig({ [BASE_VAR]: "https://elsewhere.invalid/v1" })).toThrow(/committed baseUrl origin/);
32
57
  });
33
58
  });
59
+
60
+ describe("query-parameter API key (garden#1261)", () => {
61
+ const queryConfig = (base: string): ServerConfig => {
62
+ const c = loadConfig({});
63
+ return { ...c, baseUrl: base, auth: { ...c.auth, scheme: "api-key", queryParam: "api_key" } };
64
+ };
65
+ const search: RestMapping = {
66
+ method: "GET", pathTemplate: "/search", pathMap: {}, queryMap: { q: "q", key: "api_key" }, headerMap: {},
67
+ bodyMap: null, bodyArg: null, argAllowlist: ["q", "key"], timeoutMs: 5000,
68
+ };
69
+
70
+ it("sends the key as the query parameter, never a header, and no argument can set it", async () => {
71
+ const creds = await upstreamCredentials(queryConfig("https://api.example.com/v1"), "s3cret-key");
72
+ expect(creds).toEqual({ headers: {}, query: { api_key: "s3cret-key" } });
73
+ const { url, init } = buildRequest("https://api.example.com/v1", search, { q: "cats", key: "attacker" }, creds);
74
+ expect(url.searchParams.getAll("api_key")).toEqual(["s3cret-key"]);
75
+ expect(url.searchParams.get("q")).toBe("cats");
76
+ expect(Object.values(init.headers as Record<string, string>)).not.toContain("s3cret-key");
77
+ });
78
+
79
+ it("refuses a query key on any scheme but api-key", () => {
80
+ const dir = mkdtempSync(join(tmpdir(), "mcp-cfg-"));
81
+ const committed = JSON.parse(readFileSync(join(SERVER_ROOT, "mcp-server.config.json"), "utf8"));
82
+ writeFileSync(join(dir, "mcp-server.config.json"),
83
+ JSON.stringify({ ...committed, auth: { ...committed.auth, scheme: "bearer", queryParam: "api_key" } }));
84
+ expect(() => loadConfig({}, dir)).toThrow(/auth\.queryParam/);
85
+ writeFileSync(join(dir, "mcp-server.config.json"),
86
+ JSON.stringify({ ...committed, auth: { ...committed.auth, scheme: "api-key", queryParam: "api_key" } }));
87
+ expect(loadConfig({}, dir).auth.queryParam).toBe("api_key");
88
+ });
89
+
90
+ describe("an upstream that echoes the request URL in its error", () => {
91
+ let stub: Server;
92
+ let base = "";
93
+ beforeAll(async () => {
94
+ stub = createServer((req, res) => {
95
+ res.writeHead(401, { "content-type": "text/plain" }).end(`invalid key for ${req.url}`);
96
+ });
97
+ await new Promise<void>((r) => stub.listen(0, "127.0.0.1", r));
98
+ base = `http://127.0.0.1:${(stub.address() as { port: number }).port}/v1`;
99
+ });
100
+ afterAll(() => new Promise<void>((r) => stub.close(() => r())));
101
+
102
+ it("a transport error naming the URL is scrubbed before the span or the caller sees it", async () => {
103
+ const { callRest } = await import("../src/rest.js");
104
+ const creds = { headers: {}, query: { api_key: "s3cret-key" } };
105
+ const err = await callRest(search, { q: "x" }, {
106
+ baseUrl: base,
107
+ credentials: async () => creds,
108
+ readOnly: false,
109
+ fetchImpl: (async (u: URL) => {
110
+ throw new TypeError(`fetch failed for ${u.href}`);
111
+ }) as unknown as typeof fetch,
112
+ }).catch((e: unknown) => e);
113
+ expect((err as Error).message).toContain("api_key=[redacted]");
114
+ expect((err as Error).message).not.toContain("s3cret-key");
115
+ });
116
+
117
+ it("comes back scrubbed", async () => {
118
+ const config = queryConfig(base);
119
+ const added: any[] = [];
120
+ const fake = { addTool: (t: unknown) => added.push(t) } as any;
121
+ registerGeneratedTools(fake, config, () => upstreamCredentials(config, "s3cret-key"), [
122
+ { name: "search", inputSchema: { type: "object", properties: { q: { type: "string" } } }, class: "read", rest: search },
123
+ ]);
124
+ const ctx = { requestId: "r1", sessionId: "s1", log: {} } as any;
125
+ const err = await added[0].execute({ q: "cats" }, ctx).catch((e: unknown) => e);
126
+ expect(err).toBeInstanceOf(UserError);
127
+ expect((err as Error).message).toContain("HTTP 401: invalid key for /v1/search?q=cats&api_key=[redacted]");
128
+ expect((err as Error).message).not.toContain("s3cret-key");
129
+ });
130
+ });
131
+ });
package/uv.lock CHANGED
@@ -679,14 +679,14 @@ wheels = [
679
679
 
680
680
  [[package]]
681
681
  name = "werkzeug"
682
- version = "3.1.8"
682
+ version = "3.1.9"
683
683
  source = { registry = "https://pypi.org/simple" }
684
684
  dependencies = [
685
685
  { name = "markupsafe" },
686
686
  ]
687
- sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
687
+ sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" }
688
688
  wheels = [
689
- { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
689
+ { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" },
690
690
  ]
691
691
 
692
692
  [[package]]