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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +15 -1
- package/package.json +1 -1
- package/scripts/demo/package-lock.json +7 -7
- package/scripts/demo/walkthrough.mjs +2 -1
- package/scripts/mcp/install.py +150 -10
- package/scripts/mcp/openapi.py +10 -5
- package/skills/mcp-scaffold/SKILL.md +29 -8
- package/skills/mcp-scaffold/assets/typescript/README.md +4 -1
- package/skills/mcp-scaffold/assets/typescript/package.json +2 -1
- package/skills/mcp-scaffold/assets/typescript/src/auth.ts +13 -7
- package/skills/mcp-scaffold/assets/typescript/src/config.ts +7 -0
- package/skills/mcp-scaffold/assets/typescript/src/rest.ts +52 -8
- package/skills/mcp-scaffold/assets/typescript/src/server.ts +2 -2
- package/skills/mcp-scaffold/assets/typescript/src/tools/generated.ts +11 -12
- package/skills/mcp-scaffold/assets/typescript/test/contract/example.test.ts +2 -2
- package/skills/mcp-scaffold/assets/typescript/test/global-setup.ts +11 -3
- package/skills/mcp-scaffold/assets/typescript/test/hardening.test.ts +103 -5
- package/uv.lock +3 -3
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
18
|
-
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.
|
|
19
|
-
"integrity": "sha512
|
|
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.
|
|
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.
|
|
33
|
-
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.
|
|
34
|
-
"integrity": "sha512-
|
|
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
|
-
|
|
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
|
}
|
package/scripts/mcp/install.py
CHANGED
|
@@ -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>] [--
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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["
|
|
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=
|
|
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:
|
package/scripts/mcp/openapi.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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=
|
|
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` (
|
|
78
|
-
|
|
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`
|
|
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
|
|
285
|
-
|
|
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`.
|
|
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": "
|
|
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
|
-
/**
|
|
28
|
-
|
|
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<
|
|
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
|
|
42
|
+
return header(`Bearer ${secret}`);
|
|
37
43
|
case "api-key":
|
|
38
|
-
return { [auth.
|
|
44
|
+
return auth.queryParam !== null ? { headers: {}, query: { [auth.queryParam]: secret } } : header(secret);
|
|
39
45
|
case "basic":
|
|
40
|
-
return
|
|
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
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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, () =>
|
|
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
|
|
43
|
-
* back cannot hand the
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ".." }
|
|
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
|
-
/**
|
|
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
|
|
9
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
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)", {
|
|
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.
|
|
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/
|
|
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/
|
|
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]]
|