toolfunnel 0.3.0 → 0.4.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/README.md +24 -6
- package/bin/toolfunnel.js +39 -4
- package/docs/KNOWN_BUGS.md +225 -0
- package/docs/packaging.md +115 -0
- package/docs/releasing.md +31 -0
- package/docs/setup.md +1 -1
- package/package.json +3 -1
- package/src/auth/config.js +4 -2
- package/src/auth/resource-server.js +28 -3
- package/src/core/config-home.js +134 -0
- package/src/core/logger.js +4 -2
- package/src/core/requires.js +138 -0
- package/src/core/server-config.js +77 -0
- package/src/extend/create-tool.md +24 -4
- package/src/extend/package.md +33 -0
- package/src/mcp/aggregator.js +12 -7
- package/src/mcp/http-transport.js +40 -21
- package/src/mcp/mcp-client.js +9 -3
- package/src/mcp/server.js +84 -2
- package/src/packages/packager.js +5 -3
- package/src/tools/registry.js +43 -0
- package/src/ui/server.js +49 -3
- package/tools/scripts/tf-env.js +43 -0
- package/tools/scripts/tf-hook-add.js +4 -5
- package/tools/scripts/tf-hook-set.js +4 -5
- package/tools/scripts/tf-list.js +9 -12
- package/tools/scripts/tf-log.js +4 -1
- package/tools/scripts/tf-mcp-add.js +4 -6
- package/tools/scripts/tf-mcp-set.js +4 -6
- package/tools/scripts/tf-pack.js +237 -0
- package/tools/scripts/tf-tool-add.js +16 -16
- package/tools/scripts/tf-tool-set.js +5 -11
- package/tools/tools.register.json +46 -1
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
> A zero-dependency* MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them **leanly** to cut agent token cost, and gate every call through your own policy hooks before it runs.
|
|
6
6
|
|
|
7
|
+
**Zero code, zero dependencies: your own MCP server in 60 seconds** — from three ordinary scripts to a named, packaged, policy-gated MCP server (everything on screen is real output; see [demo/](demo/)):
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
7
11
|
|
|
8
12
|
## The problem
|
|
9
13
|
|
|
@@ -25,7 +29,7 @@ ToolFunnel is one small MCP server that sits between your agent and everything e
|
|
|
25
29
|
2. **Forwards other MCP servers - leanly.** Attach an upstream MCP and its tools appear in the same lean register as your own (briefs + instructions-on-demand), runnable through `toolfunnel_run_tool` and the gate. Curate which appear, promote a chosen few to top-level "every-turn" tools, or leave them lean by default. Every forwarded call still passes the gate.
|
|
26
30
|
3. **Lean register** - the agent sees short tool *briefs*; the full instructions for a tool are fetched **on demand**, so context stays small. *(This is the token saver.)*
|
|
27
31
|
4. **Server-side policy gate** - every server-side execution path fires your PreToolUse / PostToolUse hooks **inside the gateway**, so your policy works on *any* client, not just hosts that support hooks. The gate travels with the gateway, and **fails closed**.
|
|
28
|
-
5. **Configure by file, UI, or in-band** - plain JSON files, an optional loopback web UI (`node bin/toolfunnel.js --ui`), or
|
|
32
|
+
5. **Configure by file, UI, or in-band** - plain JSON files, an optional loopback web UI (`node bin/toolfunnel.js --ui`), or nine in-band **management functions** all add / curate / toggle tools, upstreams, and hooks - live, no restart - and package the whole setup for deployment.
|
|
29
33
|
6. **Audit when you want it** - a toggleable JSONL log (**default off**) records tool runs, every gate allow/deny decision, and every upstream connect / disconnect / reconnect.
|
|
30
34
|
7. **Live & self-healing** - attach/curate/toggle tools, upstreams, and hooks on a *running* gateway with no restart; and if an attached MCP's process dies, the gateway detects it and reconnects in the background with backoff.
|
|
31
35
|
|
|
@@ -84,7 +88,7 @@ They're just here to get you started - turn any of them off or delete them as yo
|
|
|
84
88
|
|
|
85
89
|
### Management functions
|
|
86
90
|
|
|
87
|
-
The gateway can manage **itself**.
|
|
91
|
+
The gateway can manage **itself**. Nine first-party tools in the `management` category let an agent (or the web UI) add, curate, toggle, and package the gateway's own configuration. They are ordinary register tools - reached through `toolfunnel_run_tool`, so every call passes the same gate as any other gateway tool (a single PreToolUse matcher such as `tf_.*` locks the whole management surface down, since each is named `tf_*`).
|
|
88
92
|
|
|
89
93
|
| Function | What it does |
|
|
90
94
|
|----------------|------------------------------------------------------------------------------|
|
|
@@ -96,6 +100,7 @@ The gateway can manage **itself**. Eight first-party tools in the `management` c
|
|
|
96
100
|
| `tf_hook_set` | Enable, disable, or remove a hook (live toggles via the `hooks.state.json` overlay). |
|
|
97
101
|
| `tf_list` | List register tools, upstream MCPs, or hooks with their live active state (read-only). Hidden tools are omitted by default - pass `{ includeHidden: true }` to show them. |
|
|
98
102
|
| `tf_log` | Enable, disable, or check the [audit log](#activity--audit-log) (`{ action: enable\|disable\|status }`). |
|
|
103
|
+
| `tf_pack` | Package the live setup into a deployment artifact under `dist/` - a portable config home, or a publishable npm package wrapping toolfunnel. See [Packaging](#packaging-ship-your-own-mcp). |
|
|
99
104
|
|
|
100
105
|
Tool / hook register edits are **live** (re-read per call, no reconnect). Upstream attach/curate changes are picked up live too: the gateway watches its config and reloads - reconnecting upstreams and emitting `notifications/tools/list_changed` - with no restart.
|
|
101
106
|
|
|
@@ -301,7 +306,7 @@ toolfunnel/
|
|
|
301
306
|
└─ toolfunnel.log.jsonl # the JSONL records (default path)
|
|
302
307
|
```
|
|
303
308
|
|
|
304
|
-
Rule of thumb: `src/` is the gateway machinery; `tools/`, `mcp/`, and `hooks/` are where your configuration lives.
|
|
309
|
+
Rule of thumb: `src/` is the gateway machinery; `tools/`, `mcp/`, and `hooks/` are where your configuration lives. By default these resolve relative to the repo root — or point them anywhere with a **config home** (`--config-dir` / `TOOLFUNNEL_HOME`): an empty home is seeded from the shipped defaults on first use and never overwritten after, so an `npm update` can never eat your tools. See [docs/packaging.md](docs/packaging.md).
|
|
305
310
|
|
|
306
311
|
## Configuration
|
|
307
312
|
|
|
@@ -309,16 +314,29 @@ All config is plain JSON under the repo - edit it by hand, through the [config w
|
|
|
309
314
|
|
|
310
315
|
| File | Purpose |
|
|
311
316
|
|-----------------------------------|-------------------------------------------------------------------------|
|
|
312
|
-
| `tools/tools.register.json` | The first-party tool register (`{ version, description, tools: [...] }`) - 7 demos +
|
|
317
|
+
| `tools/tools.register.json` | The first-party tool register (`{ version, description, tools: [...] }`) - 7 demos + 9 management functions. Entries may carry an `inputSchema`, advertised verbatim when the tool is promoted hot |
|
|
313
318
|
| `tools/tools.state.json` | The visibility-matrix overlay, keyed by surfaced name → `{ enabled?, hidden?, hot? }`. Default-ON for `enabled` (empty `{}` enables everything), default-OFF for `hot`/`hidden` (meta-tools default `hot`-ON). Read fresh per call - no restart needed |
|
|
314
319
|
| `hooks/hooks.manifest.json` | The policy gate (`{ version, hooks: [] }`). An empty `hooks` array = allow-all |
|
|
315
320
|
| `hooks/hooks.state.json` | Optional live enable/disable overlay for hooks; an entry here wins over the manifest's seed `enabled` |
|
|
316
321
|
| `mcp/expose.json` | Upstream MCP servers + the curated set of their tools to expose (`{ version, upstreams: [], expose: [] }`). **Empty by default**, so the gateway connects to nothing |
|
|
317
322
|
| `mcp/expose.example.json` | An annotated sample showing how to wire an upstream and expose two of its tools |
|
|
318
323
|
| `logs/log.config.json` | The audit-log toggle (`{ enabled, path }`). **Default OFF**; created only when logging is enabled |
|
|
324
|
+
| `toolfunnel.json` | Optional identity + defaults: `{ serverName, serverVersion, httpPort, uiPort, requires }`. Absent = the gateway is plain `toolfunnel`. This is how a packaged MCP introduces itself as ITSELF |
|
|
325
|
+
|
|
326
|
+
## Packaging: ship your own MCP
|
|
327
|
+
|
|
328
|
+
Everything you build here — tools, curation, upstream selections, policy hooks, identity — lives
|
|
329
|
+
in one folder (the config home), and one management call packages it:
|
|
330
|
+
|
|
331
|
+
- `tf_pack { format: "home" }` → a portable setup: zip it, git it, or run it with `--config-dir`.
|
|
332
|
+
- `tf_pack { format: "npm", name: "my-mcp" }` → a **publishable npm package** that depends on
|
|
333
|
+
toolfunnel (never forks it), bundles your setup, and gives your users `npx my-mcp` — your
|
|
334
|
+
server, your name in the handshake, your tools and schemas, **your gate enforced on their
|
|
335
|
+
machine regardless of client**.
|
|
336
|
+
|
|
337
|
+
Declare runtime needs (`python`, `git`, …) in `toolfunnel.json` `requires` and the gateway warns
|
|
338
|
+
at startup about anything missing. Full story: [docs/packaging.md](docs/packaging.md).
|
|
319
339
|
|
|
320
340
|
## License
|
|
321
341
|
|
|
322
342
|
[MIT](LICENSE)
|
|
323
|
-
</content>
|
|
324
|
-
</invoke>
|
package/bin/toolfunnel.js
CHANGED
|
@@ -39,13 +39,40 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
39
39
|
' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
|
|
40
40
|
' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
|
|
41
41
|
' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
|
|
42
|
+
' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
|
|
42
43
|
' toolfunnel --help Show this help',
|
|
43
44
|
'',
|
|
45
|
+
'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
|
|
46
|
+
'the config home — by default the package root (a git clone works as before), or the value',
|
|
47
|
+
'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
|
|
48
|
+
'defaults on first use and is NEVER overwritten after — so an npm update cannot eat your',
|
|
49
|
+
'tools. This is also how a wrapped MCP ships its own bundled setup.',
|
|
50
|
+
'',
|
|
51
|
+
'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
|
|
52
|
+
' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
|
|
53
|
+
'A --port flag always wins over the config value.',
|
|
54
|
+
'',
|
|
44
55
|
].join('\n')
|
|
45
56
|
);
|
|
46
57
|
process.exit(0);
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
// Resolve + seed the config home FIRST — before any src/ module is required — so every
|
|
61
|
+
// module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
|
|
62
|
+
// same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
|
|
63
|
+
const { initConfigHome } = require('../src/core/config-home');
|
|
64
|
+
const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
|
|
65
|
+
|
|
66
|
+
// Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, …)
|
|
67
|
+
// for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
|
|
68
|
+
// problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
|
|
69
|
+
try {
|
|
70
|
+
const { checkRequires } = require('../src/core/requires');
|
|
71
|
+
for (const p of checkRequires(configHome)) {
|
|
72
|
+
process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
|
|
73
|
+
}
|
|
74
|
+
} catch (_e) { /* preflight must never stop the gateway */ }
|
|
75
|
+
|
|
49
76
|
if (args[0] === 'install-oauth') {
|
|
50
77
|
// Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
|
|
51
78
|
// at zero runtime dependencies — you only ever fetch jose if you decide you need OAuth.
|
|
@@ -68,9 +95,13 @@ if (args[0] === 'install-oauth') {
|
|
|
68
95
|
});
|
|
69
96
|
} else if (args.includes('--ui')) {
|
|
70
97
|
const { createUiServer } = require('../src/ui/server');
|
|
98
|
+
const { loadServerConfig } = require('../src/core/server-config');
|
|
99
|
+
const { resolveConfigHome } = require('../src/core/config-home');
|
|
100
|
+
const cfg = loadServerConfig(resolveConfigHome());
|
|
71
101
|
const host = flagValue('--host', '127.0.0.1');
|
|
72
|
-
|
|
73
|
-
const
|
|
102
|
+
// Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
|
|
103
|
+
const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
|
|
104
|
+
const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
|
|
74
105
|
const server = createUiServer({ host, port });
|
|
75
106
|
server
|
|
76
107
|
.start()
|
|
@@ -88,9 +119,13 @@ if (args[0] === 'install-oauth') {
|
|
|
88
119
|
process.on('SIGTERM', shutdown);
|
|
89
120
|
} else if (args.includes('--http')) {
|
|
90
121
|
const { createHttpMcpServer } = require('../src/mcp/http-transport');
|
|
122
|
+
const { loadServerConfig } = require('../src/core/server-config');
|
|
123
|
+
const { resolveConfigHome } = require('../src/core/config-home');
|
|
124
|
+
const cfg = loadServerConfig(resolveConfigHome());
|
|
91
125
|
const host = flagValue('--host', '127.0.0.1');
|
|
92
|
-
|
|
93
|
-
const
|
|
126
|
+
// Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
|
|
127
|
+
const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
|
|
128
|
+
const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
|
|
94
129
|
const server = createHttpMcpServer({ host, port });
|
|
95
130
|
server
|
|
96
131
|
.start()
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# ToolFunnel — 0.4.0 Board (bugs + features)
|
|
2
|
+
|
|
3
|
+
> Items 1–5: findings from a full source review on **2026-07-03** (v0.3.0, commit `1c5841c`),
|
|
4
|
+
> each ranked, located, explained, and paired with a proposed fix.
|
|
5
|
+
> **All five FIXED 2026-07-06** in commit `6c9821c` (suite 23/23) — the detail sections below are
|
|
6
|
+
> kept as the record of what was wrong and why the fix took the shape it did.
|
|
7
|
+
> Items 6–9: the 0.4.0 feature board (identity, verification passes, packaging).
|
|
8
|
+
>
|
|
9
|
+
> **Context for future-me:** the core gate architecture reviewed as *sound* — see
|
|
10
|
+
> "What is already solid" at the bottom before re-auditing. Do not re-review the
|
|
11
|
+
> whole tree; start from this list.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The board
|
|
16
|
+
|
|
17
|
+
| # | Kind | Area | One-liner | Status |
|
|
18
|
+
|---|------|------|-----------|--------|
|
|
19
|
+
| 1 | **High** bug | UI server | Config UI could bind off-loopback with NO auth and NO refusal — unauthenticated LAN RCE surface | **FIXED** `6c9821c` |
|
|
20
|
+
| 2 | Low bug | mcp-client (win) | `winLaunch` cmd.exe metacharacter interpretation — "no injection surface" comment was overstated | **FIXED** `6c9821c` (honest caveat) |
|
|
21
|
+
| 3 | Low bug | aggregator | `_scheduleReconnect` logged `reconnect_slow` every 30s forever instead of once at the transition | **FIXED** `6c9821c` |
|
|
22
|
+
| 4 | Low bug | http-transport | Streaming (chunked) over-cap body didn't get the clean `-32700` — and the "engineered" declared-CL path had the same RST flaw | **FIXED** `6c9821c` (half-close) |
|
|
23
|
+
| 5 | Defensive | auth/resource-server | OIDC discovery followed redirects with no origin-check on the discovered `jwks_uri` | **FIXED** `6c9821c` (origin pin) |
|
|
24
|
+
| 6 | Feature | mcp/server | Configurable server identity — `serverName`/`serverVersion` **and the default `--http`/`--ui` ports** — so a wrapped MCP introduces itself under ITS name and ships its own port defaults. Defaults unchanged. | **DONE** `a734f4b` (toolfunnel.json) |
|
|
25
|
+
| 7 | Verify | protocol/register | Confirm the SCHEMA a hot-promoted register tool advertises is the author's real inputSchema (README promises "your own tools and schemas") | **DONE** `4c219e4` — plumbing was sound; tf_tool_add DROPPED the field (fixed), validated + documented now |
|
|
26
|
+
| 8 | Verify | reload | Confirm `tools.register.json` edits are picked up live — the file-watchers are proven for `mcp/expose.json` + hooks; direct register edits may need a reload path | **DONE** `4c219e4` — was a REAL BUG (startup snapshot); Registry.reload() + tools/ watcher + state list_changed |
|
|
27
|
+
| 9 | Feature | packaging | External config home (`TOOLFUNNEL_HOME` / `--config-dir`) + the full packaging story — see below | **DONE** `108db4e` (home) + `bc88c53` (tf_pack + requires) + `a1fa845` (docs) |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 9. The packaging story (the 0.4.0 headline)
|
|
32
|
+
|
|
33
|
+
**The problem:** an npm/npx install keeps its config *inside the package directory* — an
|
|
34
|
+
`npm update` EATS the user's tools, hooks, and curation. Config must live in a home of its own.
|
|
35
|
+
|
|
36
|
+
**Four pieces, in dependency order:**
|
|
37
|
+
|
|
38
|
+
1. **Config home** — `TOOLFUNNEL_HOME` env var / `--config-dir` flag; default stays the repo root
|
|
39
|
+
for a git clone (unchanged), but an npm-installed gateway defaults to a per-user home. The
|
|
40
|
+
enabler for everything below.
|
|
41
|
+
2. **Tool-pack** — a pack IS a zipped config home. Docs, not code: the layout already travels
|
|
42
|
+
(`tools/` + `tools.register.json` + `mcp/expose.json` + `hooks/`).
|
|
43
|
+
3. **npm-wrap** — docs + a worked example, no core code: a third party publishes THEIR package
|
|
44
|
+
with **`toolfunnel` as a dependency (caret range — DEPEND, NEVER COPY)**, their pack bundled,
|
|
45
|
+
and a 2-line `bin` that points toolfunnel at the bundled config home → `npx their-mcp`.
|
|
46
|
+
- Their own runtime deps go in THEIR `package.json` — npm installs the whole tree on npx;
|
|
47
|
+
script tools resolve `require()` relative to themselves. We add no machinery.
|
|
48
|
+
- Why depend-not-copy: every install of their MCP is a toolfunnel download; our security
|
|
49
|
+
fixes reach every wrapped MCP via normal `npm update`; no stale forks. (This batch's UI-bind
|
|
50
|
+
fix is the worked example of why.)
|
|
51
|
+
4. **requires-preflight** — a `requires` field in the pack manifest (JSON only) checked with
|
|
52
|
+
`child_process` version probes + a hand-rolled version compare; friendly missing-runtime
|
|
53
|
+
errors ("this pack needs python ≥3.10").
|
|
54
|
+
|
|
55
|
+
**★ HARD CONSTRAINT: ToolFunnel itself gains ZERO runtime dependencies from any of this.**
|
|
56
|
+
JSON manifest (no yaml), hand-rolled version compare (no semver), node built-ins only.
|
|
57
|
+
|
|
58
|
+
**Packs are COMPOSITE** (already true today — `mcp/expose.json` lives in the config home):
|
|
59
|
+
own scripts + upstream MCP references + curation in one pack.
|
|
60
|
+
- Upstreams travel as REFERENCES (npx self-fetches them; pin versions in the pack).
|
|
61
|
+
- Curation travels — ship the 4 chosen tools, not the whole upstream server.
|
|
62
|
+
- **THE GATE TRAVELS** — a shipped pack enforces its policy hooks on the recipient's machine
|
|
63
|
+
regardless of which MCP client they use. Nobody else can say this sentence.
|
|
64
|
+
- Audit honesty: packs spawn commands. The docs say it plainly — read the `expose.json` of
|
|
65
|
+
anything you install.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 1. [HIGH] UI server binds off-loopback with no auth and no refusal
|
|
70
|
+
|
|
71
|
+
**Files:** `src/ui/server.js` (`createUiServer().start()`, `isLoopbackHost`), `bin/toolfunnel.js` (`--ui --host`)
|
|
72
|
+
|
|
73
|
+
**The asymmetry.** `src/mcp/http-transport.js` `start()` refuses to bind a non-loopback host
|
|
74
|
+
unless OAuth is enabled:
|
|
75
|
+
|
|
76
|
+
> `refusing to bind non-loopback host "<host>" without OAuth enabled — enable auth (and install jose) before exposing the gateway off localhost.`
|
|
77
|
+
|
|
78
|
+
The **UI server has no equivalent guard.** `createUiServer().start()` binds whatever `host`
|
|
79
|
+
it is handed, and `bin/toolfunnel.js` passes `--ui --host <anything>` straight through to it.
|
|
80
|
+
|
|
81
|
+
**Why the existing check does not cover it.** `isLoopbackHost()` inside the UI's `onRequest`
|
|
82
|
+
tests only the **`Host` request header**, which is fully attacker-controlled. It defends against
|
|
83
|
+
*browser* DNS-rebinding (the browser sets the Host to the attacker's domain), but does **nothing**
|
|
84
|
+
against a direct, non-browser client that simply sends `Host: localhost` / `Host: 127.0.0.1`.
|
|
85
|
+
|
|
86
|
+
**Why it matters more than the transport.** The config UI is the one surface that can *spawn
|
|
87
|
+
processes and mutate the security posture*:
|
|
88
|
+
- `POST /api/mcp/add` + `POST /api/mcp/discover` construct an `Aggregator` and **connect the
|
|
89
|
+
configured upstream — i.e. spawn the configured command** (`aggregator.discover` → `_connectOne`
|
|
90
|
+
→ `clientFactory` → `McpClient.connect`).
|
|
91
|
+
- `POST /api/tools/add` / `/api/tools/update` **write tool scripts** to `tools/scripts/`.
|
|
92
|
+
- `POST /api/tools/hook` / `/api/hooks/*` **toggle the gate hooks.**
|
|
93
|
+
|
|
94
|
+
So `toolfunnel --ui --host 0.0.0.0` (a plausible "let me reach the config UI from my other
|
|
95
|
+
machine" move) exposes an **unauthenticated, process-spawning, script-writing console to the
|
|
96
|
+
LAN**. The HTTP transport learned this exact lesson; the UI never got the same stitch.
|
|
97
|
+
|
|
98
|
+
**Fix.** The UI has no auth path at all, so the correct behaviour is a hard refusal, mirroring the
|
|
99
|
+
transport's guard. In `createUiServer().start()`, before `httpServer.listen(...)`:
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
if (!isLoopbackHost(host)) {
|
|
103
|
+
return Promise.reject(new Error(
|
|
104
|
+
`refusing to bind the config UI to non-loopback host "${host}" — the UI is ` +
|
|
105
|
+
`unauthenticated and can spawn processes / write scripts. It is loopback-only by design.`
|
|
106
|
+
));
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
(Note: `isLoopbackHost` currently returns `true` for an *empty/missing* value — fine for a Host
|
|
111
|
+
*header*, but for a *bind address* an empty host should not be treated as loopback. Use a stricter
|
|
112
|
+
check on the bind arg, or default-and-validate the bind host explicitly.)
|
|
113
|
+
|
|
114
|
+
**Test to add:** `createUiServer({ host: '0.0.0.0' }).start()` rejects; `'127.0.0.1'`, `'localhost'`,
|
|
115
|
+
`'::1'` resolve.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 2. [LOW] `winLaunch` cmd.exe metacharacter interpretation — comment overstates safety
|
|
120
|
+
|
|
121
|
+
**File:** `src/mcp/mcp-client.js` (`winLaunch`)
|
|
122
|
+
|
|
123
|
+
On Windows, a non-`.exe` upstream command is routed through `cmd.exe /c <command> <args…>` with
|
|
124
|
+
`shell:false` (the CVE-2024-27980 `.cmd` workaround). The code comment claims:
|
|
125
|
+
|
|
126
|
+
> "The args stay DISCRETE (Node escapes each one under shell:false) so nothing is shell-concatenated
|
|
127
|
+
> — no injection surface on this privileged path."
|
|
128
|
+
|
|
129
|
+
**That guarantee is not quite real.** With `shell:false` and the command being `cmd.exe` (an
|
|
130
|
+
`.exe`), Node applies its *standard argv quoting*, **not** cmd.exe metacharacter (`^`) escaping —
|
|
131
|
+
that special-casing only fires when the *command itself* is the `.cmd`/`.bat`. So a config arg
|
|
132
|
+
containing `& | > < "` can be reinterpreted by cmd.exe.
|
|
133
|
+
|
|
134
|
+
**Real-world risk: near-zero.** These args come from `expose.json`, authored by the operator — the
|
|
135
|
+
"attacker" would be the person who owns the config. But the comment claims a property the code does
|
|
136
|
+
not provide.
|
|
137
|
+
|
|
138
|
+
**Fix (pick one):**
|
|
139
|
+
- Downgrade the comment to state the residual cmd.exe-parsing caveat honestly, **or**
|
|
140
|
+
- Defensively quote each arg for cmd.exe, **or**
|
|
141
|
+
- Prefer resolving the real interpreter path (`where npx` → the `node` + `npm-cli.js` shape, as
|
|
142
|
+
`auth/install.js` already does for npm) and spawn that directly with `shell:false`, avoiding
|
|
143
|
+
`cmd.exe` entirely.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 3. [LOW] `_scheduleReconnect` spams `reconnect_slow` every 30s forever
|
|
148
|
+
|
|
149
|
+
**File:** `src/mcp/aggregator.js` (`_scheduleReconnect`)
|
|
150
|
+
|
|
151
|
+
Past `MAX_FAST_ATTEMPTS` (6) the method re-schedules with `Math.min(attempt + 1, MAX_FAST_ATTEMPTS)`
|
|
152
|
+
→ the attempt counter **sticks at 6**. The entry logs `if (attempt === MAX_FAST_ATTEMPTS)`, so a
|
|
153
|
+
permanently-dead-but-enabled upstream emits a `reconnect_slow` log line **every 30 seconds
|
|
154
|
+
indefinitely**, rather than once at the fast→slow transition.
|
|
155
|
+
|
|
156
|
+
**Fix.** Log the transition only. E.g. only log when entering the slow phase from the fast phase
|
|
157
|
+
(track the previous attempt, or log inside the `attempt + 1` escalation exactly when it crosses
|
|
158
|
+
`MAX_FAST_ATTEMPTS`, not on every subsequent capped re-entry).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 4. [LOW] Streaming over-cap body misses the clean `-32700` the declared-CL path produces
|
|
163
|
+
|
|
164
|
+
**File:** `src/mcp/http-transport.js` (`readBody`, `handleMcpPost`)
|
|
165
|
+
|
|
166
|
+
The **declared-Content-Length** over-cap path is carefully engineered (`sendJsonAndClose`: announce
|
|
167
|
+
`Connection: close`, flush the `-32700` body, *then* destroy the request in the `res.end` callback)
|
|
168
|
+
so the client receives a clean parse-error before the close.
|
|
169
|
+
|
|
170
|
+
The **streaming/chunked** over-cap path is not held to the same standard: `readBody` rejects and
|
|
171
|
+
immediately `req.destroy()`s. Because `req` and `res` share the socket, the follow-up
|
|
172
|
+
`sendJson(res, 200, makeError(...))` in `handleMcpPost`'s catch writes to a torn-down socket → the
|
|
173
|
+
client sees `ECONNRESET` instead of the tidy `-32700`.
|
|
174
|
+
|
|
175
|
+
**Not a crash** — every path here is defensive and the server survives. It is an inconsistency: two
|
|
176
|
+
over-cap paths that should behave identically don't.
|
|
177
|
+
|
|
178
|
+
**Fix.** Route the streaming over-cap case through the same flush-then-close sequence as
|
|
179
|
+
`sendJsonAndClose` (don't `req.destroy()` before the response body has flushed).
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 5. [DEFENSIVE] OIDC discovery follows redirects with no origin-check on discovered `jwks_uri`
|
|
184
|
+
|
|
185
|
+
**File:** `src/auth/resource-server.js` (`resolveJwksUri`, `fetchJson`)
|
|
186
|
+
|
|
187
|
+
`fetchJson` uses `redirect: 'follow'`, and `resolveJwksUri` accepts whatever `jwks_uri` the issuer's
|
|
188
|
+
discovery document advertises with no check that it shares the issuer's origin. The issuer is
|
|
189
|
+
operator-configured (trusted), and audience binding is still enforced on the verified token, so this
|
|
190
|
+
is standard OIDC behaviour and low-risk — but a defensive origin-pin on the discovered `jwks_uri`
|
|
191
|
+
(and/or `redirect: 'error'` on discovery) would tighten the SSRF-adjacent surface.
|
|
192
|
+
|
|
193
|
+
**Fix (optional hardening):** assert the discovered `jwks_uri` origin matches the issuer origin, or
|
|
194
|
+
make it opt-out-able. Document the decision either way.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## What is already solid (do NOT re-audit from scratch)
|
|
199
|
+
|
|
200
|
+
The review found the **gate architecture sound**. Confirmed on 2026-07-03:
|
|
201
|
+
|
|
202
|
+
- **`gatedRun` fails CLOSED on every bad-wiring path** — no engine / junk engine return / engine
|
|
203
|
+
throw all → `blocked:true`, `execute()` never called. The load-bearing invariant ("a PreToolUse
|
|
204
|
+
deny prevents `execute()` from ever running") is real and centralised in one auditable place
|
|
205
|
+
(`src/mcp/gated-run.js`).
|
|
206
|
+
- **The engine proxy reads `build.engine` live** (`src/mcp/server.js`), so a hot-reloaded hook gates
|
|
207
|
+
BOTH the `toolfunnel_run_tool`/lean path AND the curated-direct path — no stale-engine gap.
|
|
208
|
+
- **OAuth validator does the things that matter** (`src/auth/resource-server.js`): pins the algorithm
|
|
209
|
+
allowlist (blocks alg-confusion + `alg:none`), enforces `audience` (RFC 8707 confused-deputy
|
|
210
|
+
defence) and `issuer`, requires `exp` present, never reads the token header to choose the alg.
|
|
211
|
+
- **Isolation guard on path-shaped upstream args** (`aggregator.defaultClientFactory` / `isInside` /
|
|
212
|
+
`looksLikePath`) — a vendored upstream can't point outside the gateway root; the interpreter slot
|
|
213
|
+
is correctly exempt.
|
|
214
|
+
- **Path-escape defences** in `registry.writeScript` and `defaultRunScript` (basename + in-root
|
|
215
|
+
assertion), and **atomic temp+rename writes** across registry / tool-state / expose-store / auth
|
|
216
|
+
config / logger.
|
|
217
|
+
- **The hook matcher is correctly full-anchored** (`^(?:…)$` in `src/core/matcher.js`), so a gate on
|
|
218
|
+
`read` does NOT leak onto `read_file`; combined with the UI's `escapeRegex` on gate writes, that
|
|
219
|
+
path is right.
|
|
220
|
+
- **`McpClient` framing was fixed** in v0.3.0 to newline-delimited JSON (was LSP `Content-Length`),
|
|
221
|
+
with dual-drain read tolerance — proven against `server-everything` / `server-filesystem` + the
|
|
222
|
+
official SDK client. Zombie-guard tree-kill on close.
|
|
223
|
+
|
|
224
|
+
The v0.3.0 adversarial pass (backlog task #49) did real work on the OAuth/transport surface. Finding
|
|
225
|
+
#1 above is the one gap that pass did not close (it hardened the *transport*, not the *UI*).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Packaging: ship what you built
|
|
2
|
+
|
|
3
|
+
ToolFunnel's config is a folder. That single fact is the whole packaging story: everything you
|
|
4
|
+
build in a gateway — your tools and their scripts, which of them are promoted hot or hidden, the
|
|
5
|
+
upstream MCPs you attached and the tools you chose to expose from them, the policy hooks that gate
|
|
6
|
+
every call, and the identity the server introduces itself with — lives in one directory tree, the
|
|
7
|
+
**config home**. Ship the tree and you have shipped the product.
|
|
8
|
+
|
|
9
|
+
## The config home
|
|
10
|
+
|
|
11
|
+
By default the home is the package/clone root — a git clone works exactly as it always did. Point
|
|
12
|
+
the gateway elsewhere and the config lives there instead:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
toolfunnel --config-dir D:\my-setup # flag (wins)
|
|
16
|
+
TOOLFUNNEL_HOME=D:\my-setup toolfunnel # env var
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
An empty home is **seeded** on first use from the shipped defaults (the register + management
|
|
20
|
+
scripts, an empty `mcp/expose.json`, the default hooks manifest) and is **never overwritten**
|
|
21
|
+
afterwards — which is the point: with the home outside the package, `npm update toolfunnel`
|
|
22
|
+
can never eat your tools.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
<home>/
|
|
26
|
+
toolfunnel.json # identity (serverName/serverVersion), port defaults, `requires`
|
|
27
|
+
tools/
|
|
28
|
+
tools.register.json # your tool entries (incl. their inputSchema)
|
|
29
|
+
tools.state.json # the hot/hidden/enabled curation — part of the product
|
|
30
|
+
scripts/ # the tool scripts
|
|
31
|
+
mcp/expose.json # upstream references + curated exposure
|
|
32
|
+
hooks/ # the policy gate: manifest + hook scripts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## `tf_pack` — one call to a deployment artifact
|
|
36
|
+
|
|
37
|
+
The `tf_pack` management tool snapshots the LIVE setup into `<home>/dist/<name>/` — always a
|
|
38
|
+
separate location, never the live tree. `auth/` (environment-specific) and `logs/` never ship.
|
|
39
|
+
|
|
40
|
+
**`{ format: "home" }`** → a portable config home. Zip it, commit it to a repo, or run it as-is:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
toolfunnel --config-dir path/to/the-pack
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**`{ format: "npm", name: "my-mcp", version: "1.0.0" }`** → a publishable npm package:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
dist/my-mcp/
|
|
50
|
+
package.json # name, bin, files, dependencies: { "toolfunnel": "^0.4.0" }
|
|
51
|
+
bin/my-mcp.js # 2 lines: pin --config-dir at ../home, delegate to toolfunnel
|
|
52
|
+
home/ # the bundled config home (identity rewritten to my-mcp@1.0.0)
|
|
53
|
+
README.md # a stub to make your own
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Review it, then `cd dist/my-mcp && npm publish`. Your users run `npx my-mcp` and get **your**
|
|
57
|
+
server: your name in the MCP `initialize` handshake, your tools with your schemas, your curation,
|
|
58
|
+
and — the part nobody else can say — **your gate**. The hooks travel inside the pack and enforce
|
|
59
|
+
your policy on the recipient's machine regardless of which MCP client calls it.
|
|
60
|
+
|
|
61
|
+
### Depend, never copy
|
|
62
|
+
|
|
63
|
+
The generated package **depends on** `toolfunnel` (caret range) rather than vendoring it. Your own
|
|
64
|
+
runtime dependencies go in **your** `package.json` exactly as with any npm package — npm installs
|
|
65
|
+
the whole tree on `npx my-mcp`; toolfunnel itself adds zero. Why depending matters:
|
|
66
|
+
|
|
67
|
+
- security and bug fixes reach every wrapped MCP through a normal `npm update` — no stale forks
|
|
68
|
+
carrying old bugs;
|
|
69
|
+
- your package stays a few KB of config instead of a copied engine;
|
|
70
|
+
- (and honestly) every install of your MCP counts as a toolfunnel download, which keeps the thing
|
|
71
|
+
you depend on alive.
|
|
72
|
+
|
|
73
|
+
Your users can move the bundled home out of `node_modules` (so *their* edits survive *your*
|
|
74
|
+
updates) via the `<NAME>_HOME` env var the generated launcher supports.
|
|
75
|
+
|
|
76
|
+
## Declaring runtimes: `requires`
|
|
77
|
+
|
|
78
|
+
Your tools may need runtimes the gateway doesn't (python, git, ffmpeg…). Declare them in the
|
|
79
|
+
home's `toolfunnel.json` and they travel with every pack:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"serverName": "my-mcp",
|
|
84
|
+
"requires": [
|
|
85
|
+
{ "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
|
|
86
|
+
{ "command": "git" }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The gateway probes them once at startup and prints one clear stderr line per problem — found
|
|
92
|
+
version vs needed, plus your `why`. Advisory by design: a missing runtime breaks only the tools
|
|
93
|
+
that need it, so the server still starts and everything else keeps working.
|
|
94
|
+
|
|
95
|
+
## Composite packs
|
|
96
|
+
|
|
97
|
+
A pack is not just your own scripts. Because `mcp/expose.json` lives in the home, a pack carries
|
|
98
|
+
**upstream references too**: "attach `@example/weather-mcp` (pin the version) and expose exactly
|
|
99
|
+
these four tools under these names." The upstream itself travels as a *reference* — `npx` fetches
|
|
100
|
+
it on the recipient's machine — while your **curation** and your **gate** travel as config. One
|
|
101
|
+
pack = your tools + other people's servers, curated, renamed, and policed, as a single artifact.
|
|
102
|
+
|
|
103
|
+
## Audit honesty
|
|
104
|
+
|
|
105
|
+
Packs spawn commands — that is what they are for. Before you run anyone's pack (including one of
|
|
106
|
+
ours), read **three files** — every command it can run is declared in plain JSON:
|
|
107
|
+
|
|
108
|
+
- `tools/tools.register.json` — the tool invokes (scripts and shell commands);
|
|
109
|
+
- `mcp/expose.json` — the upstream server commands it will spawn;
|
|
110
|
+
- `toolfunnel.json` — the `requires` probes. Note these run **at gateway startup** (each declared
|
|
111
|
+
program is executed once with its version flag), which is why a `requires` command must be a
|
|
112
|
+
bare program name — the gateway rejects paths, spaces, and shell characters outright.
|
|
113
|
+
|
|
114
|
+
Ship your own packs expecting the same scrutiny, and say so in your README (the `tf_pack` stub
|
|
115
|
+
already does).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
The entire release surface is one command:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm run release # patch bump: test → commit → tag → push → GitHub Release → npm publish
|
|
7
|
+
npm run release -- minor # minor bump (or: major)
|
|
8
|
+
npm run release -- --notes "headline" # headline paragraph for the release notes
|
|
9
|
+
npm run release -- --no-npm # GitHub-only release (skip npm publish)
|
|
10
|
+
npm run release -- --dry-run # print the full plan, execute nothing
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
What it does, in order:
|
|
14
|
+
|
|
15
|
+
1. **Preflight** — refuses to run on a dirty tree, off `main`, or behind `origin/main`.
|
|
16
|
+
2. **Tests** — the full suite must pass or the release aborts before anything mutates.
|
|
17
|
+
3. **Bump** — rewrites `package.json` (the server's `serverInfo.version` follows it automatically).
|
|
18
|
+
4. **Commit + tag** — one commit (`vX.Y.Z: <headline>`), one annotated tag, both pushed.
|
|
19
|
+
5. **GitHub Release** — created via the API. Auth comes per-use from `git credential fill`;
|
|
20
|
+
the token is never written to disk or printed. Release body = your `--notes` headline
|
|
21
|
+
plus the commit subjects since the previous tag.
|
|
22
|
+
6. **npm publish** — interactive (you may be prompted for an OTP). If it fails, the GitHub
|
|
23
|
+
side is already complete; re-run `npm publish` by hand when ready.
|
|
24
|
+
|
|
25
|
+
Policy: releases only ever **add**. Existing tags, GitHub Releases and published npm
|
|
26
|
+
versions are never deleted, overwritten, or re-pointed. If a release is bad, ship a fixed
|
|
27
|
+
version on top.
|
|
28
|
+
|
|
29
|
+
Directory listings (Glama, awesome-mcp-servers and similar) crawl the GitHub side on
|
|
30
|
+
their own schedules — publishing a GitHub Release is the strongest signal they consume.
|
|
31
|
+
Nothing extra to do per release.
|
package/docs/setup.md
CHANGED
|
@@ -92,7 +92,7 @@ for you (section 5). A quick smoke test:
|
|
|
92
92
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | node bin/toolfunnel.js
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
You should get one JSON line back with `"serverInfo":{"name":"toolfunnel","version":"
|
|
95
|
+
You should get one JSON line back with `"serverInfo":{"name":"toolfunnel","version":"<current version>"}`
|
|
96
96
|
and `"protocolVersion":"2024-11-05"`.
|
|
97
97
|
|
|
98
98
|
> **Working directory doesn't matter.** ToolFunnel resolves its config (`tools/`, `mcp/`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolfunnel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -28,9 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"start": "node bin/toolfunnel.js",
|
|
31
|
+
"build": "echo toolfunnel has no build step - zero-dependency, runs from source",
|
|
31
32
|
"http": "node bin/toolfunnel.js --http",
|
|
32
33
|
"install-oauth": "node bin/toolfunnel.js install-oauth",
|
|
33
34
|
"test": "node test/run-all.js",
|
|
35
|
+
"release": "node scripts/release.js",
|
|
34
36
|
"test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
|
|
35
37
|
},
|
|
36
38
|
"dependencies": {},
|
package/src/auth/config.js
CHANGED
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
const fs = require('node:fs');
|
|
37
37
|
const path = require('node:path');
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
|
|
40
|
+
* src/core/config-home.js). Auth config is user-state, so it lives with the home. */
|
|
41
|
+
const { resolveConfigHome } = require('../core/config-home');
|
|
42
|
+
const ROOT = resolveConfigHome();
|
|
41
43
|
|
|
42
44
|
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
43
45
|
const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
|
|
@@ -112,7 +112,27 @@ async function resolveJwksUri(cfg, timeoutMs) {
|
|
|
112
112
|
for (const url of candidates) {
|
|
113
113
|
try {
|
|
114
114
|
const doc = await fetchJson(url, timeoutMs);
|
|
115
|
-
if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0)
|
|
115
|
+
if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) {
|
|
116
|
+
// ORIGIN-PIN the discovered value. The ISSUER is operator-trusted config, but the discovery
|
|
117
|
+
// DOCUMENT arrives over the network — it must not be able to point key fetching at a
|
|
118
|
+
// foreign origin (key-substitution + SSRF-adjacent surface). Cross-origin JWKS hosting is
|
|
119
|
+
// real (e.g. Google's issuer and JWKS live on different hosts), so the escape hatch is an
|
|
120
|
+
// EXPLICIT trust statement: set "jwksUri" in the auth config and discovery is skipped.
|
|
121
|
+
let sameOrigin = false;
|
|
122
|
+
try {
|
|
123
|
+
sameOrigin = new URL(doc.jwks_uri).origin === new URL(cfg.issuer).origin;
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
/* malformed URL on either side → treat as cross-origin → refuse */
|
|
126
|
+
}
|
|
127
|
+
if (!sameOrigin) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`discovered jwks_uri "${doc.jwks_uri}" is not on the issuer origin — refusing the ` +
|
|
130
|
+
'cross-origin discovery result. If your IdP intentionally hosts JWKS on a separate ' +
|
|
131
|
+
'origin, state that trust explicitly by setting "jwksUri" in auth/auth.config.json.'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return doc.jwks_uri;
|
|
135
|
+
}
|
|
116
136
|
lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
|
|
117
137
|
} catch (e) {
|
|
118
138
|
lastErr = e;
|
|
@@ -121,13 +141,18 @@ async function resolveJwksUri(cfg, timeoutMs) {
|
|
|
121
141
|
throw lastErr || new Error('OIDC discovery failed');
|
|
122
142
|
}
|
|
123
143
|
|
|
124
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* GET + parse JSON with a hard timeout. Throws on non-2xx, network error, bad JSON — or a REDIRECT:
|
|
146
|
+
* discovery metadata must come from the exact well-known URL derived from the trusted issuer
|
|
147
|
+
* (following a redirect would let a bounced endpoint serve the document from anywhere, hollowing
|
|
148
|
+
* out the origin pin above). Only discovery uses this helper; the JWKS fetch itself is jose's.
|
|
149
|
+
*/
|
|
125
150
|
async function fetchJson(url, timeoutMs) {
|
|
126
151
|
const ac = new AbortController();
|
|
127
152
|
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
|
|
128
153
|
const t = setTimeout(() => ac.abort(), ms);
|
|
129
154
|
try {
|
|
130
|
-
const res = await fetch(url, { signal: ac.signal, redirect: '
|
|
155
|
+
const res = await fetch(url, { signal: ac.signal, redirect: 'error' });
|
|
131
156
|
if (!res || !res.ok) throw new Error(`GET ${url} -> HTTP ${res ? res.status : 'no-response'}`);
|
|
132
157
|
return await res.json();
|
|
133
158
|
} finally {
|