mcp-authz 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 CHANGED
@@ -22,7 +22,7 @@ Plenty of good tools solve the neighbouring problems. Take one of them when its
22
22
  | OAuth plumbing | official SDK, mcp-auth | Authentication and resource-server mechanics, with no permission model |
23
23
  | Embedded dependency | **mcp-authz** | One package, and a policy engine that stays small by design |
24
24
 
25
- Two cases need none of this. A stdio server on one laptop already has the OS account as its boundary. A server where every caller gets identical access wants one service credential and no policy.
25
+ Two cases need none of this. A stdio server on one laptop already has the OS account as its boundary. A server where every caller gets identical access wants one service credential and no policy. To have that stdio server show the model fewer tools, [`mcp-authz wrap`](#mcp-authz-wrap-fewer-tools-from-a-stdio-server) does that with no policy ([walkthrough](https://jagreehal.github.io/mcp-authz/wrap/)).
26
26
 
27
27
  ### You need one integration seam
28
28
 
@@ -592,8 +592,6 @@ as served, so a snapshot turns that into a diff on the pull request. Nothing is
592
592
  enforced at boot: a digest in production is a second source of truth, and would
593
593
  make a description edit an outage.
594
594
 
595
- `@modelcontextprotocol/client` is an optional peer, needed only by this subpath.
596
-
597
595
  ### `mcp-authz/openapi` — the same bet on an HTTP API
598
596
 
599
597
  An OpenAPI document is the other catalogue an agent reads. A tool that is never
@@ -863,6 +861,41 @@ and on an invalid policy, and 0 on an unused permission, which stays a warning
863
861
  because granting a role ahead of the tool that will use it is how a staged
864
862
  rollout works.
865
863
 
864
+ ### `mcp-authz wrap`: fewer tools from a stdio server
865
+
866
+ `wrap` sits in front of any stdio MCP server and hides the tools you leave out,
867
+ so a model reaches less than your API key allows.
868
+
869
+ ```bash
870
+ npx -y mcp-authz tools --out cases.jsonc -- npx -y @acme/cases-mcp
871
+ ```
872
+
873
+ `tools` saves the server's tools to `cases.jsonc`, one line each with what the
874
+ tool does and what the server says about it, destructive ones commented out.
875
+ A schema beside it gives your editor completion and typo checks. `tools` also
876
+ prints the `mcpServers` entry to paste, or writes it into a client config file
877
+ with `--client-out .mcp.json`:
878
+
879
+ ```json
880
+ "cases": {
881
+ "command": "npx",
882
+ "args": ["-y", "mcp-authz", "wrap", "--config", "/Users/you/mcp/cases.jsonc"],
883
+ "env": { "CASES_API_KEY": "..." }
884
+ }
885
+ ```
886
+
887
+ `wrap` drops unlisted tools from `tools/list` and answers a call to one with an
888
+ error that names it, so the server never receives it. Tools the server adds
889
+ later stay hidden until you list them. `mcp-authz tools --check cases.jsonc` reports
890
+ what changed on the server since you saved, and `tools --config cases.jsonc
891
+ --refresh` records it, keeping your choices. For a quick trial, `wrap --deny
892
+ a,b -- <command>` takes the list as arguments.
893
+
894
+ `wrap` limits one session; scope the key itself where the service supports it.
895
+ When the client disconnects, `wrap` stops the whole process tree, `npx` and the
896
+ server it started. The [walkthrough](https://jagreehal.github.io/mcp-authz/wrap/)
897
+ covers the rest.
898
+
866
899
  ### `mcp-authz/policy`
867
900
 
868
901
  The policy half on its own (`definePolicy`, `definePermissions`,
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  //#region src/cli.d.ts
2
- declare function main(argv: readonly string[]): number | Promise<number>;
3
- //#endregion
4
- export { main };
2
+ export declare function main(argv: readonly string[]): number | Promise<number>;
3
+ //#endregion
package/dist/cli.js CHANGED
@@ -1,9 +1,661 @@
1
1
  #!/usr/bin/env node
2
2
  import { i as reconcile, r as definePolicy } from "./policy-BBp3Jq6G.js";
3
- import { readFileSync, writeFileSync } from "node:fs";
4
- import { resolve } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
5
6
  import { pathToFileURL } from "node:url";
6
7
  import { parseArgs } from "node:util";
8
+ import { spawn } from "node:child_process";
9
+ import { createInterface } from "node:readline";
10
+ import { setTimeout } from "node:timers/promises";
11
+ import { tmpdir } from "node:os";
12
+ //#region src/windows-job.ts
13
+ /** Quote one argument for CreateProcessW's command line (not a shell). */
14
+ function quoteWindowsArgument(value) {
15
+ return `"${value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1")}"`;
16
+ }
17
+ /**
18
+ * A PowerShell supervisor owns a kill-on-close Job Object. A small Node
19
+ * launcher enters it while suspended, before it can create any descendants.
20
+ * The supervisor keeps its handle until the launcher exits; closing it kills
21
+ * every remaining descendant, even after their immediate parent has exited.
22
+ * Only the supervisor owns the handle, so terminating it also kills the job.
23
+ */
24
+ function windowsJobCommand(command, args) {
25
+ const crossSpawn = createRequire(import.meta.url).resolve("cross-spawn");
26
+ const directory = mkdtempSync(join(tmpdir(), "mcp-authz-job-"));
27
+ const specPath = join(directory, "spec.json");
28
+ writeFileSync(specPath, JSON.stringify({
29
+ command,
30
+ args
31
+ }), { mode: 384 });
32
+ const cleanup = () => rmSync(directory, {
33
+ recursive: true,
34
+ force: true
35
+ });
36
+ const launcher = [
37
+ `const fs = require('fs');`,
38
+ `const spawn = require(${JSON.stringify(crossSpawn)});`,
39
+ `const spec = JSON.parse(fs.readFileSync(${JSON.stringify(specPath)}, 'utf8'));`,
40
+ `fs.rmSync(${JSON.stringify(directory)}, { recursive: true, force: true });`,
41
+ `const child = spawn(spec.command, spec.args, { stdio: 'inherit' });`,
42
+ `child.on('error', error => { console.error(error.message); process.exit(1); });`,
43
+ `child.on('exit', code => process.exit(code ?? 1));`
44
+ ].join("\n");
45
+ const commandLine = [
46
+ process.execPath,
47
+ "--input-type=commonjs",
48
+ "-e",
49
+ launcher
50
+ ].map(quoteWindowsArgument).join(" ");
51
+ const encoded = Buffer.from(commandLine, "utf8").toString("base64");
52
+ const script = [
53
+ "$ErrorActionPreference = \"Stop\"",
54
+ "$ProgressPreference = 'SilentlyContinue'",
55
+ "Add-Type -TypeDefinition @'",
56
+ JOB_SUPERVISOR,
57
+ "'@",
58
+ `$line = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`,
59
+ "try { exit ([McpAuthzJob]::Run($line)) }",
60
+ "catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }"
61
+ ].join("\n");
62
+ return {
63
+ specPath,
64
+ /** Remove the spec if the launcher never got to; safe to call twice. */
65
+ cleanup,
66
+ command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
67
+ args: [
68
+ "-NoLogo",
69
+ "-NoProfile",
70
+ "-NonInteractive",
71
+ "-EncodedCommand",
72
+ Buffer.from(script, "utf16le").toString("base64")
73
+ ]
74
+ };
75
+ }
76
+ const JOB_SUPERVISOR = String.raw`
77
+ using System;
78
+ using System.ComponentModel;
79
+ using System.Runtime.InteropServices;
80
+ using System.Text;
81
+
82
+ public static class McpAuthzJob {
83
+ [StructLayout(LayoutKind.Sequential)]
84
+ struct BasicLimits {
85
+ public long ProcessTime, JobTime;
86
+ public uint Flags;
87
+ public UIntPtr MinimumWorkingSet, MaximumWorkingSet;
88
+ public uint ActiveProcessLimit;
89
+ public UIntPtr Affinity;
90
+ public uint PriorityClass, SchedulingClass;
91
+ }
92
+ [StructLayout(LayoutKind.Sequential)]
93
+ struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; }
94
+ [StructLayout(LayoutKind.Sequential)]
95
+ struct ExtendedLimits {
96
+ public BasicLimits Basic;
97
+ public IoCounters Io;
98
+ public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory;
99
+ }
100
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
101
+ struct StartupInfo {
102
+ public uint Size;
103
+ public string Reserved, Desktop, Title;
104
+ public uint X, Y, XSize, YSize, XChars, YChars, Fill, Flags;
105
+ public ushort Show, ReservedSize;
106
+ public IntPtr ReservedData, Input, Output, Error;
107
+ }
108
+ [StructLayout(LayoutKind.Sequential)]
109
+ struct ProcessInfo { public IntPtr Process, Thread; public uint ProcessId, ThreadId; }
110
+
111
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
112
+ static extern IntPtr CreateJobObjectW(IntPtr security, string name);
113
+ [DllImport("kernel32.dll", SetLastError = true)]
114
+ static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint size);
115
+ [DllImport("kernel32.dll", SetLastError = true)]
116
+ static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
117
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
118
+ static extern bool CreateProcessW(string app, StringBuilder line, IntPtr processSecurity,
119
+ IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string cwd,
120
+ ref StartupInfo startup, out ProcessInfo process);
121
+ [DllImport("kernel32.dll", SetLastError = true)]
122
+ static extern uint ResumeThread(IntPtr thread);
123
+ [DllImport("kernel32.dll", SetLastError = true)]
124
+ static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
125
+ [DllImport("kernel32.dll", SetLastError = true)]
126
+ static extern bool GetExitCodeProcess(IntPtr process, out uint code);
127
+ [DllImport("kernel32.dll")]
128
+ static extern IntPtr GetStdHandle(int handle);
129
+ [DllImport("kernel32.dll", SetLastError = true)]
130
+ static extern bool TerminateProcess(IntPtr process, uint code);
131
+ [DllImport("kernel32.dll")]
132
+ static extern bool CloseHandle(IntPtr handle);
133
+
134
+ static void Check(bool success) {
135
+ if (!success) throw new Win32Exception(Marshal.GetLastWin32Error());
136
+ }
137
+
138
+ public static int Run(string commandLine) {
139
+ IntPtr job = CreateJobObjectW(IntPtr.Zero, null);
140
+ Check(job != IntPtr.Zero);
141
+ ProcessInfo process = new ProcessInfo();
142
+ bool assigned = false;
143
+ try {
144
+ ExtendedLimits limits = new ExtendedLimits();
145
+ limits.Basic.Flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
146
+ Check(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(typeof(ExtendedLimits))));
147
+ StartupInfo startup = new StartupInfo();
148
+ startup.Size = (uint)Marshal.SizeOf(typeof(StartupInfo));
149
+ startup.Flags = 0x100; // STARTF_USESTDHANDLES
150
+ startup.Input = GetStdHandle(-10);
151
+ startup.Output = GetStdHandle(-11);
152
+ startup.Error = GetStdHandle(-12);
153
+ Check(CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero,
154
+ true, 0x4, IntPtr.Zero, null, ref startup, out process)); // CREATE_SUSPENDED
155
+ Check(AssignProcessToJobObject(job, process.Process));
156
+ assigned = true;
157
+ Check(ResumeThread(process.Thread) != UInt32.MaxValue);
158
+ Check(WaitForSingleObject(process.Process, UInt32.MaxValue) != UInt32.MaxValue);
159
+ uint code;
160
+ Check(GetExitCodeProcess(process.Process, out code));
161
+ return unchecked((int)code);
162
+ } finally {
163
+ // If assignment failed, the suspended launcher must also be reaped.
164
+ if (!assigned && process.Process != IntPtr.Zero) TerminateProcess(process.Process, 1);
165
+ CloseHandle(job);
166
+ if (process.Thread != IntPtr.Zero) CloseHandle(process.Thread);
167
+ if (process.Process != IntPtr.Zero) CloseHandle(process.Process);
168
+ }
169
+ }
170
+ }
171
+ `;
172
+ //#endregion
173
+ //#region src/wrap.ts
174
+ /** Resolves with the upstream's exit code. */
175
+ function wrap(options, io) {
176
+ const visible = (name) => options.allow ? options.allow.includes(name) : !options.deny?.includes(name);
177
+ const windows = process.platform === "win32";
178
+ const upstream = windows ? windowsJobCommand(options.command, options.args) : void 0;
179
+ const child = spawn(upstream?.command ?? options.command, [...upstream?.args ?? options.args], {
180
+ stdio: [
181
+ "pipe",
182
+ "pipe",
183
+ "pipe"
184
+ ],
185
+ detached: !windows,
186
+ windowsHide: true,
187
+ ...options.cwd ? { cwd: options.cwd } : {}
188
+ });
189
+ child.stdin.on("error", () => {});
190
+ const listing = /* @__PURE__ */ new Set();
191
+ const seen = /* @__PURE__ */ new Set();
192
+ let reported = false;
193
+ const report = () => {
194
+ if (reported) return;
195
+ reported = true;
196
+ const hidden = [...seen].filter((name) => !visible(name)).sort();
197
+ io.log(`mcp-authz wrap: ${seen.size - hidden.length}/${seen.size} tools exposed` + (hidden.length > 0 ? `, hidden: ${hidden.join(", ")}` : ""));
198
+ for (const name of options.allow ?? options.deny ?? []) if (!seen.has(name)) io.log(`mcp-authz wrap: no tool named ${name}`);
199
+ };
200
+ onLines(io.input, (line) => {
201
+ if (stopping || line.trim() === "") return;
202
+ let value;
203
+ const refuse = (code, reason) => {
204
+ const id = value?.id;
205
+ send(JSON.stringify({
206
+ jsonrpc: "2.0",
207
+ id: typeof id === "string" || typeof id === "number" ? id : null,
208
+ error: {
209
+ code,
210
+ message: reason
211
+ }
212
+ }));
213
+ };
214
+ try {
215
+ value = JSON.parse(line);
216
+ } catch {
217
+ refuse(-32700, "Parse error: mcp-authz wrap forwards only messages it can read");
218
+ return;
219
+ }
220
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
221
+ refuse(-32600, "Invalid request: mcp-authz wrap forwards single JSON-RPC messages, not batches");
222
+ return;
223
+ }
224
+ if (hasDuplicateKey(line)) {
225
+ refuse(-32600, "Invalid request: mcp-authz wrap refuses a message that repeats a key");
226
+ return;
227
+ }
228
+ const message = value;
229
+ if (message.method === "tools/list" && message.id !== void 0) listing.add(message.id);
230
+ const name = message.method === "tools/call" ? message.params?.name : void 0;
231
+ if (message.method === "tools/call" && (typeof name !== "string" || !visible(name))) {
232
+ const error = {
233
+ code: -32602,
234
+ message: typeof name === "string" ? `Tool "${name}" is blocked by mcp-authz wrap` : "tools/call needs a tool name"
235
+ };
236
+ send(JSON.stringify({
237
+ jsonrpc: "2.0",
238
+ id: message.id ?? null,
239
+ error
240
+ }));
241
+ return;
242
+ }
243
+ child.stdin.write(`${line}\n`);
244
+ });
245
+ io.output.on("error", () => void stop());
246
+ const send = (line) => {
247
+ if (io.output.writable) io.output.write(`${line}\n`);
248
+ };
249
+ const treeAlive = () => {
250
+ if (child.pid === void 0) return false;
251
+ if (windows) return child.exitCode === null && child.signalCode === null;
252
+ try {
253
+ process.kill(-child.pid, 0);
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ };
259
+ const signalTree = (signal) => {
260
+ if (child.pid === void 0) return;
261
+ if (windows) {
262
+ child.kill("SIGKILL");
263
+ return;
264
+ }
265
+ try {
266
+ process.kill(-child.pid, signal);
267
+ } catch {}
268
+ };
269
+ const goneWithin = async (ms) => {
270
+ for (const deadline = Date.now() + ms; Date.now() < deadline; await setTimeout(25)) if (!treeAlive()) return true;
271
+ return !treeAlive();
272
+ };
273
+ const grace = options.graceMs ?? 2e3;
274
+ let stopping;
275
+ const stop = () => stopping ??= (async () => {
276
+ child.stdin.end();
277
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
278
+ if (await goneWithin(grace)) return;
279
+ signalTree(signal);
280
+ }
281
+ await goneWithin(grace);
282
+ })();
283
+ io.input.on("end", stop);
284
+ if (io.signal?.aborted) stop();
285
+ else io.signal?.addEventListener("abort", stop, { once: true });
286
+ onLines(child.stdout, (line) => {
287
+ const message = parse(line);
288
+ if (message?.method === void 0 && message?.id !== void 0 && listing.delete(message.id) && message.result?.tools) {
289
+ for (const tool of message.result.tools) seen.add(tool.name);
290
+ if (!message.result.nextCursor) report();
291
+ message.result.tools = message.result.tools.filter((tool) => visible(tool.name));
292
+ line = JSON.stringify(message);
293
+ }
294
+ send(line);
295
+ });
296
+ createInterface({ input: child.stderr }).on("line", io.log);
297
+ return new Promise((resolve) => {
298
+ child.on("exit", (code) => resolve(code ?? 1));
299
+ child.on("error", (error) => {
300
+ io.log(`mcp-authz wrap: could not start ${options.command}: ${error.message}. Install it, or give its full path in your config.`);
301
+ resolve(1);
302
+ });
303
+ }).then(async (code) => {
304
+ await stop();
305
+ upstream?.cleanup();
306
+ return code;
307
+ });
308
+ }
309
+ /**
310
+ * Call `onLine` for each newline-terminated line, framed as the MCP SDKs frame
311
+ * stdio: split on `\n` alone, with a trailing `\r` dropped. node:readline also
312
+ * splits on `\r`, U+2028 and U+2029, which JSON allows unescaped inside a
313
+ * string, so it would read one message as two where the server reads one.
314
+ */
315
+ function onLines(stream, onLine) {
316
+ let buffered = "";
317
+ stream.setEncoding("utf8");
318
+ stream.on("data", (chunk) => {
319
+ buffered += chunk;
320
+ let newline;
321
+ while ((newline = buffered.indexOf("\n")) !== -1) {
322
+ onLine(buffered.slice(0, newline).replace(/\r$/, ""));
323
+ buffered = buffered.slice(newline + 1);
324
+ }
325
+ });
326
+ stream.on("end", () => {
327
+ if (buffered !== "") onLine(buffered.replace(/\r$/, ""));
328
+ buffered = "";
329
+ });
330
+ }
331
+ /**
332
+ * Whether any object repeats a key, compared after unescaping, so `"name"` and
333
+ * `"na\u006de"` are the same key. Only called on text JSON.parse accepted,
334
+ * which is what lets it skip validating anything else.
335
+ */
336
+ function hasDuplicateKey(text) {
337
+ const scopes = [];
338
+ for (let i = 0; i < text.length; i++) {
339
+ const char = text[i];
340
+ if (char === "{") scopes.push(/* @__PURE__ */ new Set());
341
+ else if (char === "[") scopes.push(void 0);
342
+ else if (char === "}" || char === "]") scopes.pop();
343
+ else if (char === "\"") {
344
+ let end = i + 1;
345
+ while (text[end] !== "\"") end += text[end] === "\\" ? 2 : 1;
346
+ let next = end + 1;
347
+ while (text[next] === " " || text[next] === " " || text[next] === "\r" || text[next] === "\n") next++;
348
+ const scope = scopes.at(-1);
349
+ if (scope && text[next] === ":") {
350
+ const key = JSON.parse(text.slice(i, end + 1));
351
+ if (scope.has(key)) return true;
352
+ scope.add(key);
353
+ }
354
+ i = end;
355
+ }
356
+ }
357
+ return false;
358
+ }
359
+ function parse(line) {
360
+ try {
361
+ const value = JSON.parse(line);
362
+ return typeof value === "object" && value !== null ? value : void 0;
363
+ } catch {
364
+ return;
365
+ }
366
+ }
367
+ //#endregion
368
+ //#region src/wrap-config.ts
369
+ /** Ask a stdio server for its tools, the way a client would. */
370
+ async function discover$1(command, args, cwd) {
371
+ const { Client } = await import("@modelcontextprotocol/client");
372
+ const { StdioClientTransport } = await import("@modelcontextprotocol/client/stdio");
373
+ const client = new Client({
374
+ name: "mcp-authz-tools",
375
+ version: "1.0.0"
376
+ });
377
+ const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== void 0));
378
+ await client.connect(new StdioClientTransport({
379
+ command,
380
+ args: [...args],
381
+ env,
382
+ ...cwd ? { cwd } : {}
383
+ }));
384
+ try {
385
+ const tools = [];
386
+ let cursor;
387
+ do {
388
+ const page = await client.listTools(cursor ? { cursor } : void 0);
389
+ for (const tool of page.tools) tools.push({
390
+ name: tool.name,
391
+ hint: tool.annotations?.readOnlyHint ? "read-only" : tool.annotations?.destructiveHint ? "destructive" : "unknown",
392
+ description: tool.description?.split("\n")[0]?.trim() ?? "",
393
+ params: Object.keys(tool.inputSchema.properties ?? {}).map((param) => tool.inputSchema.required?.includes(param) ? `${param} (required)` : param)
394
+ });
395
+ cursor = page.nextCursor;
396
+ } while (cursor);
397
+ return tools.sort((a, b) => a.name.localeCompare(b.name));
398
+ } finally {
399
+ await client.close();
400
+ }
401
+ }
402
+ /** Where the schema for a config lives: beside it, named after it. */
403
+ function schemaPathFor(configPath) {
404
+ return join(dirname(configPath), `${basename(configPath, extname(configPath))}.schema.json`);
405
+ }
406
+ /**
407
+ * Write the config and its schema.
408
+ *
409
+ * In a fresh config, destructive tools start commented out. Over an existing
410
+ * config, the choices in it are kept and a tool the server added since starts
411
+ * commented out, so a refresh keeps your edits and waits for you to switch new
412
+ * tools on.
413
+ */
414
+ function writeWrapConfig(configPath, server, tools, previous) {
415
+ const schemaPath = schemaPathFor(configPath);
416
+ const isNew = (name) => previous?.recorded !== void 0 && !previous.recorded.includes(name);
417
+ const chosen = (tool) => {
418
+ if (!previous) return tool.hint !== "destructive";
419
+ if (isNew(tool.name)) return false;
420
+ const { allow, deny } = previous.options;
421
+ return allow ? allow.includes(tool.name) : !deny?.includes(tool.name);
422
+ };
423
+ const on = tools.filter(chosen);
424
+ const off = tools.filter((tool) => !chosen(tool));
425
+ const note = (tool) => ` // ${tool.hint}${tool.description ? ` · ${clip(tool.description)}` : ""}`;
426
+ const entries = [...on.map((tool) => ` ${JSON.stringify(tool.name)},${note(tool)}`), ...off.map((tool) => ` // ${JSON.stringify(tool.name)},${note(tool)}`)];
427
+ const text = [
428
+ "{",
429
+ ` "$schema": ${JSON.stringify(`./${basename(schemaPath)}`)},`,
430
+ " // The server `mcp-authz wrap` runs. Its env comes from your client config.",
431
+ ` "server": {`,
432
+ ` "command": ${JSON.stringify(server.command)},`,
433
+ ` "args": [${server.args.map((arg) => JSON.stringify(arg)).join(", ")}],`,
434
+ " // Where the server runs, relative to this file, so relative paths in",
435
+ " // \"args\" mean the same wherever the client starts it.",
436
+ ` "cwd": ${JSON.stringify(storedCwd(configPath, server.cwd))}`,
437
+ " },",
438
+ " // The tools this session may see. Anything not listed is hidden, including",
439
+ " // tools the server adds later. Uncomment a line to switch a tool on.",
440
+ " \"allow\": [",
441
+ ...entries,
442
+ " ]",
443
+ "}",
444
+ ""
445
+ ].join("\n");
446
+ writeFileSync(configPath, text);
447
+ writeFileSync(schemaPath, `${JSON.stringify(schemaFor(tools), null, 2)}\n`);
448
+ return {
449
+ allowed: on.length,
450
+ commented: off.length,
451
+ added: tools.filter((tool) => isNew(tool.name)).length
452
+ };
453
+ }
454
+ function schemaFor(tools) {
455
+ const names = {
456
+ type: "array",
457
+ items: { $ref: "#/definitions/tool" },
458
+ uniqueItems: true
459
+ };
460
+ return {
461
+ $schema: "http://json-schema.org/draft-07/schema#",
462
+ title: "mcp-authz wrap config",
463
+ type: "object",
464
+ required: ["server"],
465
+ additionalProperties: false,
466
+ not: { required: ["allow", "deny"] },
467
+ properties: {
468
+ $schema: { type: "string" },
469
+ server: {
470
+ type: "object",
471
+ required: ["command"],
472
+ additionalProperties: false,
473
+ properties: {
474
+ command: { type: "string" },
475
+ args: {
476
+ type: "array",
477
+ items: { type: "string" }
478
+ },
479
+ cwd: {
480
+ type: "string",
481
+ description: "Where the server runs, relative to this file or absolute."
482
+ }
483
+ }
484
+ },
485
+ allow: {
486
+ ...names,
487
+ description: "Only these tools are shown."
488
+ },
489
+ deny: {
490
+ ...names,
491
+ description: "Every tool but these is shown."
492
+ }
493
+ },
494
+ definitions: { tool: tools.length > 0 ? { anyOf: tools.map((tool) => ({
495
+ const: tool.name,
496
+ description: note(tool)
497
+ })) } : { not: {} } }
498
+ };
499
+ function note(tool) {
500
+ const said = tool.description ? `${tool.hint} · ${tool.description}` : tool.hint;
501
+ return tool.params.length > 0 ? `${said}\n\nTakes: ${tool.params.join(", ")}` : said;
502
+ }
503
+ }
504
+ /** Every tool name the schema beside a config recorded, if it is there. */
505
+ function recordedTools(configPath) {
506
+ try {
507
+ const tool = JSON.parse(readFileSync(schemaPathFor(configPath), "utf8")).definitions?.tool;
508
+ return tool ? (tool.anyOf ?? []).map((entry) => entry.const) : void 0;
509
+ } catch {
510
+ return;
511
+ }
512
+ }
513
+ /** Read a config into what `wrap` takes, saying which file is wrong and how. */
514
+ function readWrapConfig(configPath) {
515
+ const fail = (problem) => {
516
+ throw new Error(`${configPath}: ${problem}`);
517
+ };
518
+ let text;
519
+ try {
520
+ text = readFileSync(configPath, "utf8");
521
+ } catch {
522
+ return fail(`cannot read it. Create it with: mcp-authz tools --out ${configPath} -- <server command>`);
523
+ }
524
+ let value;
525
+ try {
526
+ value = parseJsonc(text);
527
+ } catch (error) {
528
+ return fail(`not valid JSONC (${error instanceof Error ? error.message : String(error)}). Look for a missing comma or quote, or start again: move it aside (mv ${configPath} ${configPath}.bak), then run mcp-authz tools --out ${configPath} -- <server command>`);
529
+ }
530
+ const config = value;
531
+ const unknown = (object, known, prefix = "") => {
532
+ if (typeof object !== "object" || object === null) return;
533
+ for (const key of Object.keys(object)) if (!known.includes(key)) fail(`unknown key "${prefix}${key}"; expected one of: ${known.join(", ")}.`);
534
+ };
535
+ unknown(config, [
536
+ "$schema",
537
+ "server",
538
+ "allow",
539
+ "deny"
540
+ ]);
541
+ unknown(config?.server, [
542
+ "command",
543
+ "args",
544
+ "cwd"
545
+ ], "server.");
546
+ const strings = (list) => Array.isArray(list) && list.every((item) => typeof item === "string");
547
+ if (typeof config?.server?.command !== "string") fail("\"server.command\" must be a string.");
548
+ const cwd = config.server.cwd ?? ".";
549
+ if (typeof cwd !== "string") fail("\"server.cwd\" must be a path.");
550
+ const args = config.server.args ?? [];
551
+ if (!strings(args)) fail("\"server.args\" must be a list of strings.");
552
+ if (config.allow !== void 0 && config.deny !== void 0) fail("use \"allow\" or \"deny\", not both.");
553
+ for (const key of ["allow", "deny"]) if (config[key] !== void 0 && !strings(config[key])) fail(`"${key}" must be a list of tool names.`);
554
+ return {
555
+ command: config.server.command,
556
+ args,
557
+ cwd: resolve(dirname(resolve(configPath)), cwd),
558
+ ...config.allow ? { allow: config.allow } : {},
559
+ ...config.deny ? { deny: config.deny } : {}
560
+ };
561
+ }
562
+ function entryFor(configPath) {
563
+ return {
564
+ name: basename(configPath, extname(configPath)),
565
+ entry: {
566
+ command: "npx",
567
+ args: [
568
+ "-y",
569
+ "mcp-authz",
570
+ "wrap",
571
+ "--config",
572
+ resolve(configPath)
573
+ ]
574
+ }
575
+ };
576
+ }
577
+ /**
578
+ * Add the entry to a client's MCP config file, creating it if need be. Only how
579
+ * the server starts changes. Other servers, and every other setting on this one
580
+ * (`env`, `disabled`, `timeout`, whatever the client supports), stay as they
581
+ * were, so a rerun keeps your API key and your on/off choice.
582
+ */
583
+ function writeClientConfig(clientPath, configPath) {
584
+ const { name, entry } = entryFor(configPath);
585
+ let existing = {};
586
+ if (existsSync(clientPath)) try {
587
+ existing = parseJsonc(readFileSync(clientPath, "utf8"));
588
+ } catch (error) {
589
+ const reason = error instanceof Error ? error.message : String(error);
590
+ throw new Error(`${clientPath}: not valid JSON (${reason}). Fix it, or pass --client-out a new file.`, { cause: error });
591
+ }
592
+ const kept = { ...existing.mcpServers?.[name] };
593
+ delete kept.url;
594
+ if (kept.type !== void 0 && kept.type !== "stdio") delete kept.type;
595
+ const merged = {
596
+ ...existing,
597
+ mcpServers: {
598
+ ...existing.mcpServers,
599
+ [name]: {
600
+ ...kept,
601
+ ...entry
602
+ }
603
+ }
604
+ };
605
+ writeFileSync(clientPath, `${JSON.stringify(merged, null, 2)}\n`);
606
+ }
607
+ /** The `mcpServers` entry that runs a config, ready to paste. */
608
+ function clientEntry(configPath) {
609
+ const { name, entry } = entryFor(configPath);
610
+ const args = entry.args.map((arg) => JSON.stringify(arg));
611
+ return [
612
+ `${JSON.stringify(name)}: {`,
613
+ " \"command\": \"npx\",",
614
+ ` "args": [${args.join(", ")}]`,
615
+ "}"
616
+ ].join("\n");
617
+ }
618
+ /**
619
+ * JSON with comments and trailing commas, which is what a person editing the
620
+ * file will produce. Strings are copied whole, so `//` inside one is safe.
621
+ */
622
+ function parseJsonc(text) {
623
+ let out = "";
624
+ for (let i = 0; i < text.length; i++) {
625
+ const char = text[i];
626
+ if (char === "\"") {
627
+ let end = i + 1;
628
+ while (end < text.length && text[end] !== "\"") end += text[end] === "\\" ? 2 : 1;
629
+ out += text.slice(i, end + 1);
630
+ i = end;
631
+ } else if (char === "/" && text[i + 1] === "/") {
632
+ while (i < text.length && text[i] !== "\n") i++;
633
+ out += "\n";
634
+ } else if (char === "/" && text[i + 1] === "*") {
635
+ const end = text.indexOf("*/", i + 2);
636
+ i = end === -1 ? text.length : end + 1;
637
+ } else {
638
+ if (char === "]" || char === "}") out = out.replace(/,\s*$/, "");
639
+ out += char;
640
+ }
641
+ }
642
+ return JSON.parse(out);
643
+ }
644
+ /**
645
+ * Relative to the config when that is the shorter way to say it, as it is for
646
+ * a config kept beside its server in a repo; otherwise absolute, so a copied
647
+ * file still finds the server.
648
+ */
649
+ function storedCwd(configPath, cwd) {
650
+ const relativeTo = relative(dirname(resolve(configPath)), cwd) || ".";
651
+ return relativeTo.length <= cwd.length ? relativeTo : cwd;
652
+ }
653
+ /** The first sentence, and no more than a line's worth of it. */
654
+ function clip(text, max = 80) {
655
+ const sentence = /^.*?[.!?](?=\s|$)/.exec(text)?.[0] ?? text;
656
+ return sentence.length > max ? `${sentence.slice(0, max - 1)}…` : sentence;
657
+ }
658
+ //#endregion
7
659
  //#region src/cli.ts
8
660
  /**
9
661
  * Two questions a policy file cannot answer by being read.
@@ -14,6 +666,10 @@ import { parseArgs } from "node:util";
14
666
  * decision carries the permissions, and only `policy.explain` carries the rules
15
667
  * that produced them.
16
668
  *
669
+ * `tools` and `wrap` answer a third: which of a server's tools should this
670
+ * session see. `wrap` reads JSON lines and nothing more, so it works in front of
671
+ * any stdio server; `tools` is the one command that speaks MCP as a client.
672
+ *
17
673
  * Deliberately no colour library and no argument parser. `node:util` has one,
18
674
  * and a dependency here would be a dependency in every install of the package.
19
675
  */
@@ -24,6 +680,12 @@ const USAGE = `mcp-authz — inspect a policy without running a server
24
680
  mcp-authz record --upstream <url> [--token <bearer>] [--out <permissions.ts>]
25
681
  mcp-authz record <connector.ts|--upstream <url>> --check <permissions.ts>
26
682
  mcp-authz explain <policy.json> --identity <identity.json>|- [--capabilities <map.json>]
683
+ mcp-authz tools -- <command> [args...]
684
+ mcp-authz tools --out <name.jsonc> [--client-out <mcp.json>] -- <command> [args...]
685
+ mcp-authz tools --config <name.jsonc> --refresh
686
+ mcp-authz wrap [--allow <a,b> | --deny <a,b>] -- <command> [args...]
687
+ mcp-authz wrap --config <name.jsonc>
688
+ mcp-authz tools --check <name.jsonc>
27
689
 
28
690
  Files
29
691
  <policy.json> the object you would hand definePolicy
@@ -32,6 +694,11 @@ Files
32
694
  --identity an Identity, or a decoded token payload (iss, sub, email,
33
695
  email_verified, hd). Use - to read it from stdin.
34
696
 
697
+ wrap
698
+ Runs a stdio MCP server and hides tools from whoever connects. Put it in
699
+ front of the server in your client's MCP config. With neither flag every
700
+ tool passes through; names are exact and comma-separated.
701
+
35
702
  Exit codes
36
703
  0 fine, warnings included
37
704
  1 the policy is invalid, or a capability no role can reach
@@ -47,6 +714,11 @@ function main(argv) {
47
714
  upstream: { type: "string" },
48
715
  token: { type: "string" },
49
716
  check: { type: "string" },
717
+ allow: { type: "string" },
718
+ deny: { type: "string" },
719
+ config: { type: "string" },
720
+ refresh: { type: "boolean" },
721
+ "client-out": { type: "string" },
50
722
  help: {
51
723
  type: "boolean",
52
724
  short: "h"
@@ -54,10 +726,17 @@ function main(argv) {
54
726
  }
55
727
  });
56
728
  const [command, policyPath] = positionals;
729
+ const dashes = argv.indexOf("--");
730
+ const upstream = dashes === -1 ? [] : argv.slice(dashes + 1);
57
731
  if (values.help || !command) {
58
732
  process.stdout.write(USAGE);
59
733
  return values.help ? 0 : 1;
60
734
  }
735
+ if (command === "tools") {
736
+ if (values.check) return checkWrapConfig(values.check, upstream);
737
+ return listTools(upstream, values);
738
+ }
739
+ if (command === "wrap") return runWrap(upstream, values);
61
740
  if (command === "record") {
62
741
  if (values.upstream) return record({
63
742
  upstream: values.upstream,
@@ -73,6 +752,10 @@ function main(argv) {
73
752
  process.stderr.write(`${command} needs a path to a policy file.\n`);
74
753
  return 1;
75
754
  }
755
+ if (command === "check" && isWrapConfig(policyPath)) {
756
+ process.stderr.write(`${policyPath} is a wrap config. To compare it with its server, which starts the command it names, run: mcp-authz tools --check ${policyPath}\n`);
757
+ return 1;
758
+ }
76
759
  const policy = definePolicy(readJson(policyPath));
77
760
  const run = (capabilities) => {
78
761
  if (command === "check") return check(policy, capabilities);
@@ -90,6 +773,139 @@ function main(argv) {
90
773
  if (values.capabilities.endsWith(".json")) return run(new Map(Object.entries(readJson(values.capabilities))));
91
774
  return importCapabilities(values.capabilities).then(run);
92
775
  }
776
+ /**
777
+ * Print the names `wrap` takes, with the hints that help choose between them,
778
+ * and with --out or --refresh save them as a config `wrap --config` runs.
779
+ */
780
+ function listTools(upstream, flags) {
781
+ const refuse = (message) => (process.stderr.write(`${message}\n`), 1);
782
+ if (flags.refresh && !flags.config) return refuse("--refresh rewrites a saved config: tools --config <name>.jsonc --refresh");
783
+ if (flags.config && upstream.length > 0) return refuse("tools takes --config or a command after --, not both: the config names its server.");
784
+ const saved = flags.config ? readWrapConfig(flags.config) : void 0;
785
+ const [command, ...args] = saved ? [saved.command, ...saved.args] : upstream;
786
+ const cwd = saved?.cwd ?? process.cwd();
787
+ if (saved) announce(saved, flags.config);
788
+ if (!command) return refuse("tools needs the server command after --, e.g. tools -- npx -y some-mcp");
789
+ const target = flags.refresh ? flags.config : flags.out;
790
+ return discover(command, args, cwd).then((tools) => {
791
+ if (tools === void 0) return 1;
792
+ const width = Math.max(...tools.map((tool) => tool.name.length));
793
+ for (const tool of tools) process.stdout.write(`${tool.name.padEnd(width)} ${tool.hint.padEnd(11)} ${tool.description}`.trimEnd() + "\n");
794
+ if (!target) {
795
+ process.stderr.write("\nSave these as a config wrap can run: tools --out <name>.jsonc -- ...\n");
796
+ return 0;
797
+ }
798
+ const previous = existsSync(target) ? {
799
+ options: readWrapConfig(target),
800
+ recorded: recordedTools(target)
801
+ } : void 0;
802
+ const { allowed, commented, added } = writeWrapConfig(target, {
803
+ command,
804
+ args,
805
+ cwd
806
+ }, tools, previous);
807
+ const summary = previous ? `${allowed} allowed, ${commented} commented out, ${added} new since last saved, left commented out` : `${allowed} allowed, ${commented} destructive commented out`;
808
+ const lines = ["", `Saved ${target} (${summary}) and ${schemaPathFor(target)}.`];
809
+ if (flags["client-out"]) {
810
+ writeClientConfig(flags["client-out"], target);
811
+ lines.push(`Added it to ${flags["client-out"]}; give it the env the server needs there.`);
812
+ }
813
+ lines.push("Add this to your client's mcpServers, with the env the server needs:", "", clientEntry(target), "");
814
+ process.stdout.write(lines.join("\n"));
815
+ return 0;
816
+ });
817
+ }
818
+ /**
819
+ * Discovery with a next step for the two common stops: a command that is not
820
+ * installed, and a server that exits at once, which usually means it wants an
821
+ * API key. The server's own message is already on stderr above this one.
822
+ */
823
+ async function discover(command, args, cwd) {
824
+ try {
825
+ return await discover$1(command, args, cwd);
826
+ } catch (error) {
827
+ const code = error.code;
828
+ process.stderr.write(code === "ENOENT" ? `Could not start ${command}: command not found. Install it, or give its full path.\n` : "The server exited before listing its tools. Its own error, if it printed one, is above.\nIf it needs credentials, export them in this shell first: the env in your client\nconfig is not seen here.\n");
829
+ return;
830
+ }
831
+ }
832
+ /** Say what is about to run when the command comes from a file. */
833
+ function announce(options, path) {
834
+ process.stderr.write(`Running ${[options.command, ...options.args].join(" ")} from ${path}\n`);
835
+ }
836
+ function isWrapConfig(path) {
837
+ if (path === "-") return false;
838
+ try {
839
+ const value = parseJsonc(readFileSync(path, "utf8"));
840
+ return typeof value === "object" && value !== null && "server" in value;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }
845
+ /**
846
+ * Ask the server what it offers now, and compare it with the config and with
847
+ * the catalogue recorded beside it. Exits 1 on any difference, the way
848
+ * `record --check` does, so CI notices an upgrade that renamed a tool.
849
+ */
850
+ async function checkWrapConfig(path, upstream) {
851
+ if (upstream.length > 0) {
852
+ process.stderr.write("tools --check runs the server its config names, so it takes no command after --.\n");
853
+ return 1;
854
+ }
855
+ const options = readWrapConfig(path);
856
+ announce(options, path);
857
+ const discovered = await discover(options.command, options.args, options.cwd);
858
+ if (discovered === void 0) return 1;
859
+ const live = discovered.map((tool) => tool.name);
860
+ const recorded = recordedTools(path);
861
+ const missing = (options.allow ?? options.deny ?? []).filter((name) => !live.includes(name));
862
+ const added = recorded ? live.filter((name) => !recorded.includes(name)) : [];
863
+ const removed = recorded ? recorded.filter((name) => !live.includes(name)) : [];
864
+ if (missing.length === 0 && added.length === 0 && removed.length === 0) {
865
+ const against = recorded ? `as recorded in ${schemaPathFor(path)}` : `every name in ${path} found`;
866
+ process.stdout.write(`${live.length} tools, ${against}\n`);
867
+ return 0;
868
+ }
869
+ const lines = [`${path} does not match the server:`, ""];
870
+ for (const name of missing) lines.push(` ? ${name}`, ` in "${options.allow ? "allow" : "deny"}", but the server has no such tool`);
871
+ for (const name of added) lines.push(` + ${name}`, ` new since last saved; ${options.allow ? "hidden, since it is not in \"allow\"" : "shown"}`);
872
+ for (const name of removed) lines.push(` - ${name}`, " recorded, but the server no longer offers it");
873
+ lines.push("", missing.length > 0 ? `Fix or remove the "?" names in ${path}, then run tools --config ${path} --refresh to record the rest.` : `Run tools --config ${path} --refresh to record the change. Your choices are kept; new tools stay off.`, "");
874
+ process.stdout.write(lines.join("\n"));
875
+ return 1;
876
+ }
877
+ function runWrap(upstream, { allow, deny, config }) {
878
+ const refuse = (message) => (process.stderr.write(`${message}\n`), 1);
879
+ if (allow !== void 0 && deny !== void 0) return refuse("wrap takes --allow or --deny, not both.");
880
+ if (config !== void 0) {
881
+ if (upstream.length > 0) return refuse("wrap takes --config or a command after --, not both.");
882
+ if (allow !== void 0 || deny !== void 0) return refuse("wrap takes --config or --allow/--deny, not both: the list lives in the file.");
883
+ }
884
+ const names = (list) => list?.split(",").map((name) => name.trim()).filter(Boolean);
885
+ const [command, ...args] = upstream;
886
+ if (config === void 0 && !command) return refuse("wrap needs the server command after --, e.g. wrap --deny x -- npx -y some-mcp");
887
+ const options = config ? readWrapConfig(config) : {
888
+ command,
889
+ args,
890
+ allow: names(allow),
891
+ deny: names(deny)
892
+ };
893
+ const stopped = new AbortController();
894
+ for (const signal of [
895
+ "SIGINT",
896
+ "SIGTERM",
897
+ "SIGHUP"
898
+ ]) process.once(signal, () => stopped.abort());
899
+ return wrap(options, {
900
+ input: process.stdin,
901
+ output: process.stdout,
902
+ log: (line) => process.stderr.write(`${line}\n`),
903
+ signal: stopped.signal
904
+ }).then((code) => {
905
+ process.stdin.destroy();
906
+ return code;
907
+ });
908
+ }
93
909
  /** Read the map out of a module `record` produced, or one written by hand. */
94
910
  async function importCapabilities(path) {
95
911
  const loaded = await import(pathToFileURL(resolve(path)).href);
@@ -98,13 +914,7 @@ async function importCapabilities(path) {
98
914
  return new Map(Object.entries(map));
99
915
  }
100
916
  async function record(source, out, against) {
101
- let toolkit;
102
- try {
103
- toolkit = await import("./testing.js");
104
- } catch {
105
- process.stderr.write("record needs @modelcontextprotocol/client, which is an optional peer.\n npm install -D @modelcontextprotocol/client\n");
106
- return 1;
107
- }
917
+ const toolkit = await import("./testing.js");
108
918
  let capabilities;
109
919
  if ("upstream" in source) capabilities = await toolkit.recordUpstream(source.upstream, { bearer: source.token });
110
920
  else {
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ type DiscoverOptions = {
8
8
  /** Swap in for tests, or to add a timeout or proxy. Defaults to global fetch. */
9
9
  fetch?: typeof globalThis.fetch;
10
10
  };
11
- declare function discoverOAuth(issuer: string, options?: DiscoverOptions): Promise<OAuthMetadata>;
11
+ export declare function discoverOAuth(issuer: string, options?: DiscoverOptions): Promise<OAuthMetadata>;
12
12
  //#endregion
13
13
  //#region src/gate.d.ts
14
14
  /**
@@ -60,7 +60,7 @@ type GateOptions = {
60
60
  };
61
61
  /** Bare name for a tool, `prompt:`/`resource:` prefixed for the rest. */
62
62
  type PermissionMap<P extends string> = Readonly<Record<string, P>> | ReadonlyMap<string, P>;
63
- declare function gate<P extends string>(server: McpServer, principal: Principal<P>, permissions: PermissionMap<P>, options?: GateOptions): McpServer;
63
+ export declare function gate<P extends string>(server: McpServer, principal: Principal<P>, permissions: PermissionMap<P>, options?: GateOptions): McpServer;
64
64
  //#endregion
65
65
  //#region src/handler.d.ts
66
66
  /**
@@ -189,20 +189,20 @@ type McpFetchOptions<TContext, P extends string = string> = {
189
189
  * policy ran first and already refused anyone it grants nothing. Saying so here
190
190
  * saves every caller the same non-null assertion.
191
191
  */
192
- declare function createMcpFetch<TContext = Principal<string>, P extends string = string>(options: Omit<McpFetchOptions<TContext, P>, 'policy' | 'authorize' | 'resolve'> & {
192
+ export declare function createMcpFetch<TContext = Principal<string>, P extends string = string>(options: Omit<McpFetchOptions<TContext, P>, 'policy' | 'authorize' | 'resolve'> & {
193
193
  policy: Policy<P>;
194
194
  authorize?: never;
195
195
  resolve?: (identity: Identity, principal: Principal<P>) => Promise<TContext> | TContext;
196
196
  }): (request: Request) => Promise<Response>;
197
- declare function createMcpFetch<TContext = Principal<string>, P extends string = string>(options: Omit<McpFetchOptions<TContext, P>, 'policy' | 'authorize' | 'resolve'> & {
197
+ export declare function createMcpFetch<TContext = Principal<string>, P extends string = string>(options: Omit<McpFetchOptions<TContext, P>, 'policy' | 'authorize' | 'resolve'> & {
198
198
  policy?: never;
199
199
  authorize: (identity: Identity) => Promise<Principal<P>> | Principal<P>;
200
200
  resolve?: (identity: Identity, principal: Principal<P>) => Promise<TContext> | TContext;
201
201
  }): (request: Request) => Promise<Response>;
202
- declare function createMcpFetch<TContext = Principal<string>, P extends string = string>(options: Omit<McpFetchOptions<TContext, P>, 'policy' | 'authorize' | 'resolve'> & {
202
+ export declare function createMcpFetch<TContext = Principal<string>, P extends string = string>(options: Omit<McpFetchOptions<TContext, P>, 'policy' | 'authorize' | 'resolve'> & {
203
203
  policy?: never;
204
204
  authorize?: never;
205
205
  resolve: (identity: Identity, principal: undefined) => Promise<TContext> | TContext;
206
206
  }): (request: Request) => Promise<Response>;
207
207
  //#endregion
208
- export { AccessDeniedError, type ApprovalDecision, ApprovalRefusedError, type ApprovalRequest, type ApprovalSink, type AuditDeliveryFailure, type AuditErrorSink, type AuditEvent, type AuditSink, type AuthorizationDecisionEvent, type AuthorizationDecisionSink, type Capability, type CapabilityScopeMap, type Definition, type DenialReason, type DiscoverOptions, type Explanation, type GateOptions, type Identity, type Match, type MatchedRule, type McpFetchOptions, type PermissionCatalog, type PermissionMap, type PermissionOf, type Policy, type PolicySpec, type Principal, type PromptConfig, type ResourceConfig, type Rule, type ScopeRequirement, type ServerOptions, type ToolConfig, type ToolScopeMap, type TrustedMcpRoute, type VerifierOptions, authz, createMcpFetch, createPrincipal, decodeMcpNameHeader, definePermissions, definePolicy, discoverOAuth, gate, identityFromAuth, jwksVerifier, reconcile, scopesForCapability, scopesFromMcpHeaders };
208
+ export { AccessDeniedError, type ApprovalDecision, ApprovalRefusedError, type ApprovalRequest, type ApprovalSink, type AuditDeliveryFailure, type AuditErrorSink, type AuditEvent, type AuditSink, type AuthorizationDecisionEvent, type AuthorizationDecisionSink, type Capability, type CapabilityScopeMap, type Definition, type DenialReason, type DiscoverOptions, type Explanation, type GateOptions, type Identity, type Match, type MatchedRule, type McpFetchOptions, type PermissionCatalog, type PermissionMap, type PermissionOf, type Policy, type PolicySpec, type Principal, type PromptConfig, type ResourceConfig, type Rule, type ScopeRequirement, type ServerOptions, type ToolConfig, type ToolScopeMap, type TrustedMcpRoute, type VerifierOptions, authz, createPrincipal, decodeMcpNameHeader, definePermissions, definePolicy, identityFromAuth, jwksVerifier, reconcile, scopesForCapability, scopesFromMcpHeaders };
package/dist/node.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Server } from "node:http";
2
2
  //#region src/node.d.ts
3
- type ListenMcpOptions = {
3
+ export type ListenMcpOptions = {
4
4
  port: number;
5
5
  /** Log label. Defaults to `mcp-authz`. */
6
6
  name?: string;
@@ -11,6 +11,5 @@ type ListenMcpOptions = {
11
11
  * Bind a `createMcpFetch` handler to every interface. Safe because the
12
12
  * bearer gate is the security boundary, not the bind address.
13
13
  */
14
- declare function listenMcp(fetch: (request: Request) => Promise<Response>, options: ListenMcpOptions): Promise<Server>;
15
- //#endregion
16
- export { ListenMcpOptions, listenMcp };
14
+ export declare function listenMcp(fetch: (request: Request) => Promise<Response>, options: ListenMcpOptions): Promise<Server>;
15
+ //#endregion
package/dist/openapi.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { _ as Identity, l as Principal, s as Policy } from "./policy-DuZbwrKf.js";
2
2
  import { c as AuditSink, o as AuditErrorSink } from "./tools-BQE1O-7P.js";
3
3
  import { a as AuthorizationDecisionSink, t as VerifierOptions } from "./verifier-DF6gUMQ6.js";
4
- import { t as PermissionMapRecord } from "./permissions-module-DxCHuE-N.js";
4
+ import { t as PermissionMapRecord } from "./permissions-module-Cr0K1a7x.js";
5
5
  import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
6
6
  //#region src/openapi.d.ts
7
7
  /**
@@ -22,11 +22,11 @@ import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotoc
22
22
  * rides along untouched, so a document with `components`, `webhooks` or a
23
23
  * vendor extension comes back out the way it went in.
24
24
  */
25
- type OpenApiDocument = {
25
+ export type OpenApiDocument = {
26
26
  paths?: Record<string, Record<string, unknown> | undefined>;
27
27
  [key: string]: unknown;
28
28
  };
29
- type OpenApiOperation = {
29
+ export type OpenApiOperation = {
30
30
  /** The document's own `operationId`, which is what the permission map is keyed by. */
31
31
  operationId: string;
32
32
  /** Lowercased HTTP method. */
@@ -34,7 +34,7 @@ type OpenApiOperation = {
34
34
  /** The templated path, e.g. `/cases/{id}`. */
35
35
  path: string;
36
36
  };
37
- type OperationRecord = PermissionMapRecord & {
37
+ export type OperationRecord = PermissionMapRecord & {
38
38
  /** Every operation the document describes, in document order. */
39
39
  operations: OpenApiOperation[];
40
40
  };
@@ -47,7 +47,7 @@ type OperationRecord = PermissionMapRecord & {
47
47
  * role grants and the boot refuses until somebody decides what each operation
48
48
  * costs.
49
49
  */
50
- declare function recordOperations(spec: OpenApiDocument): OperationRecord;
50
+ export declare function recordOperations(spec: OpenApiDocument): OperationRecord;
51
51
  /**
52
52
  * The document as this caller should see it: their operations, and nothing else.
53
53
  *
@@ -56,8 +56,8 @@ declare function recordOperations(spec: OpenApiDocument): OperationRecord;
56
56
  * means walking the `$ref` graph, and a schema nobody references costs a few
57
57
  * hundred tokens where a wrongly-pruned one breaks the document.
58
58
  */
59
- declare function filterSpec<P extends string>(spec: OpenApiDocument, principal: Principal<P>, permissions: Readonly<Record<string, string>>): OpenApiDocument;
60
- type OpenApiFetchOptions<P extends string = string> = {
59
+ export declare function filterSpec<P extends string>(spec: OpenApiDocument, principal: Principal<P>, permissions: Readonly<Record<string, string>>): OpenApiDocument;
60
+ export type OpenApiFetchOptions<P extends string = string> = {
61
61
  /** The document describing this API. Also the catalogue served to callers. */
62
62
  spec: OpenApiDocument;
63
63
  /** `operationId` to the permission it costs. Every operation needs an entry. */
@@ -107,6 +107,5 @@ type OpenApiFetchOptions<P extends string = string> = {
107
107
  * out of the spec (a health check, static files) belong outside this wrapper
108
108
  * rather than behind it.
109
109
  */
110
- declare function createOpenApiFetch<P extends string = string>(options: OpenApiFetchOptions<P>): (request: Request) => Promise<Response>;
111
- //#endregion
112
- export { OpenApiDocument, OpenApiFetchOptions, OpenApiOperation, OperationRecord, createOpenApiFetch, filterSpec, recordOperations };
110
+ export declare function createOpenApiFetch<P extends string = string>(options: OpenApiFetchOptions<P>): (request: Request) => Promise<Response>;
111
+ //#endregion
@@ -3,9 +3,9 @@
3
3
  * Rendering a permission map as source, shared by everything that records a
4
4
  * catalogue.
5
5
  *
6
- * Its own module because the MCP recorder needs an optional peer dependency to
7
- * talk to a server, and the OpenAPI one only needs a file it was handed. A
8
- * caller after the scaffold should not have to install a client to get it.
6
+ * Its own module because the MCP recorder loads a client to talk to a server,
7
+ * and the OpenAPI one only needs a file it was handed. A caller after the
8
+ * scaffold should not have to load a client to get it.
9
9
  */
10
10
  type PermissionMapRecord = {
11
11
  /** Every capability, sorted, labelled the way the gate labels it. */
package/dist/proxy.d.ts CHANGED
@@ -11,7 +11,7 @@ type UpstreamConfig = {
11
11
  };
12
12
  //#endregion
13
13
  //#region src/proxy.d.ts
14
- type McpProxyOptions<P extends string = string> = {
14
+ export type McpProxyOptions<P extends string = string> = {
15
15
  /** This proxy's public URL, e.g. `https://mcp.acme.com/mcp`. */
16
16
  resourceServerUrl: URL;
17
17
  /** RFC 8414 metadata for the authorization server in front of the proxy. */
@@ -41,6 +41,5 @@ type McpProxyOptions<P extends string = string> = {
41
41
  healthPath?: string;
42
42
  maxRequestBytes?: number;
43
43
  };
44
- declare function createMcpProxy<P extends string = string>(options: McpProxyOptions<P>): (request: Request) => Promise<Response>;
45
- //#endregion
46
- export { McpProxyOptions, createMcpProxy };
44
+ export declare function createMcpProxy<P extends string = string>(options: McpProxyOptions<P>): (request: Request) => Promise<Response>;
45
+ //#endregion
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as UNASSIGNED, r as toPermissionsModule, t as PermissionMapRecord } from "./permissions-module-DxCHuE-N.js";
1
+ import { n as UNASSIGNED, r as toPermissionsModule, t as PermissionMapRecord } from "./permissions-module-Cr0K1a7x.js";
2
2
  import { McpServer } from "@modelcontextprotocol/server";
3
3
  //#region src/testing.d.ts
4
4
  /**
@@ -13,7 +13,7 @@ import { McpServer } from "@modelcontextprotocol/server";
13
13
  * listing one would hand you a map missing exactly the capabilities that most
14
14
  * need a price.
15
15
  */
16
- type CapabilityRecord = {
16
+ export type CapabilityRecord = {
17
17
  /** Every capability, labelled as `gate()` labels them, sorted. */
18
18
  names: string[];
19
19
  /** A digest per capability, so a snapshot can catch one changing under you. */
@@ -27,7 +27,7 @@ type CapabilityRecord = {
27
27
  */
28
28
  resourceUris: Record<string, string>;
29
29
  };
30
- declare function recordCapabilities(factory: () => McpServer | Promise<McpServer>): Promise<CapabilityRecord>;
30
+ export declare function recordCapabilities(factory: () => McpServer | Promise<McpServer>): Promise<CapabilityRecord>;
31
31
  /**
32
32
  * Record a server you can only reach by URL.
33
33
  *
@@ -36,9 +36,9 @@ declare function recordCapabilities(factory: () => McpServer | Promise<McpServer
36
36
  * the map is complete. Pass `fetch` to drive a handler directly instead of a
37
37
  * socket.
38
38
  */
39
- declare function recordUpstream(url: string | URL, options?: {
39
+ export declare function recordUpstream(url: string | URL, options?: {
40
40
  bearer?: string;
41
41
  fetch?: typeof fetch;
42
42
  }): Promise<CapabilityRecord>;
43
43
  //#endregion
44
- export { CapabilityRecord, type PermissionMapRecord, UNASSIGNED, recordCapabilities, recordUpstream, toPermissionsModule };
44
+ export { type PermissionMapRecord, UNASSIGNED, toPermissionsModule };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-authz",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Authorization for MCP servers: OAuth 2.1 resource server, roles/permissions policy, permission-gated tools (2026-07-28)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,31 +59,29 @@
59
59
  "author": "Jag Reehal <jag@jagreehal.com> (https://jagreehal.com)",
60
60
  "license": "MIT",
61
61
  "dependencies": {
62
- "@modelcontextprotocol/server": "^2.1.0",
62
+ "@modelcontextprotocol/client": "^2.3.0",
63
+ "@modelcontextprotocol/server": "^2.3.0",
63
64
  "awaitly": "6.2.0",
65
+ "cross-spawn": "^7.0.6",
64
66
  "jose": "^6.2.12"
65
67
  },
66
68
  "peerDependencies": {
67
- "@modelcontextprotocol/client": "^2.0.0",
68
69
  "@modelcontextprotocol/node": "^2.0.0"
69
70
  },
70
71
  "peerDependenciesMeta": {
71
72
  "@modelcontextprotocol/node": {
72
73
  "optional": true
73
- },
74
- "@modelcontextprotocol/client": {
75
- "optional": true
76
74
  }
77
75
  },
78
76
  "devDependencies": {
79
- "@modelcontextprotocol/client": "^2.0.0",
80
- "@modelcontextprotocol/node": "^2.1.0",
81
- "@types/node": "^26.2.0",
77
+ "@modelcontextprotocol/node": "^2.1.1",
78
+ "@types/node": "^26.6.4",
82
79
  "@typescript/native": "npm:typescript@^7.0.2",
83
- "executable-stories-vitest": "8.8.0",
84
- "tsdown": "^0.22.14",
80
+ "ajv": "^8.20.0",
81
+ "executable-stories-vitest": "8.10.1",
82
+ "tsdown": "^0.23.0",
85
83
  "typescript": "npm:@typescript/typescript6@^6.0.2",
86
- "vitest": "^4.1.11",
84
+ "vitest": "^5.0.3",
87
85
  "zod": "^4.6.5"
88
86
  },
89
87
  "engines": {