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