mcp-authz 0.3.0 → 0.5.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/dist/cli.js CHANGED
@@ -1,9 +1,832 @@
1
1
  #!/usr/bin/env node
2
+ import { n as holdsInexactNumber, r as parseChecked, t as hasDuplicateKey } from "./strict-json-DLKOgsGE.js";
2
3
  import { i as reconcile, r as definePolicy } from "./policy-BBp3Jq6G.js";
3
- import { readFileSync, writeFileSync } from "node:fs";
4
- import { resolve } from "node:path";
4
+ import { c as suspicious, i as definitionOf, n as captureListings, o as missingDefinitions, r as changedFields, s as reveal, t as INSTRUCTIONS } from "./definitions-CyIy4YSZ.js";
5
+ import { n as screenResult, r as withNotice, t as checkArguments } from "./screen-DxoujEpO.js";
6
+ import { createRequire } from "node:module";
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
5
9
  import { pathToFileURL } from "node:url";
6
10
  import { parseArgs } from "node:util";
11
+ import { spawn } from "node:child_process";
12
+ import { createInterface } from "node:readline";
13
+ import { setTimeout } from "node:timers/promises";
14
+ import { tmpdir } from "node:os";
15
+ //#region src/windows-job.ts
16
+ /** Quote one argument for CreateProcessW's command line (not a shell). */
17
+ function quoteWindowsArgument(value) {
18
+ return `"${value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1")}"`;
19
+ }
20
+ /**
21
+ * A PowerShell supervisor owns a kill-on-close Job Object. A small Node
22
+ * launcher enters it while suspended, before it can create any descendants.
23
+ * The supervisor keeps its handle until the launcher exits; closing it kills
24
+ * every remaining descendant, even after their immediate parent has exited.
25
+ * Only the supervisor owns the handle, so terminating it also kills the job.
26
+ */
27
+ function windowsJobCommand(command, args) {
28
+ const crossSpawn = createRequire(import.meta.url).resolve("cross-spawn");
29
+ const directory = mkdtempSync(join(tmpdir(), "mcp-authz-job-"));
30
+ const specPath = join(directory, "spec.json");
31
+ writeFileSync(specPath, JSON.stringify({
32
+ command,
33
+ args
34
+ }), { mode: 384 });
35
+ const cleanup = () => rmSync(directory, {
36
+ recursive: true,
37
+ force: true
38
+ });
39
+ const launcher = [
40
+ `const fs = require('fs');`,
41
+ `const spawn = require(${JSON.stringify(crossSpawn)});`,
42
+ `const spec = JSON.parse(fs.readFileSync(${JSON.stringify(specPath)}, 'utf8'));`,
43
+ `fs.rmSync(${JSON.stringify(directory)}, { recursive: true, force: true });`,
44
+ `const child = spawn(spec.command, spec.args, { stdio: 'inherit' });`,
45
+ `child.on('error', error => { console.error(error.message); process.exit(1); });`,
46
+ `child.on('exit', code => process.exit(code ?? 1));`
47
+ ].join("\n");
48
+ const commandLine = [
49
+ process.execPath,
50
+ "--input-type=commonjs",
51
+ "-e",
52
+ launcher
53
+ ].map(quoteWindowsArgument).join(" ");
54
+ const encoded = Buffer.from(commandLine, "utf8").toString("base64");
55
+ const script = [
56
+ "$ErrorActionPreference = \"Stop\"",
57
+ "$ProgressPreference = 'SilentlyContinue'",
58
+ "Add-Type -TypeDefinition @'",
59
+ JOB_SUPERVISOR,
60
+ "'@",
61
+ `$line = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`,
62
+ "try { exit ([McpAuthzJob]::Run($line)) }",
63
+ "catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }"
64
+ ].join("\n");
65
+ return {
66
+ specPath,
67
+ /** Remove the spec if the launcher never got to; safe to call twice. */
68
+ cleanup,
69
+ command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
70
+ args: [
71
+ "-NoLogo",
72
+ "-NoProfile",
73
+ "-NonInteractive",
74
+ "-EncodedCommand",
75
+ Buffer.from(script, "utf16le").toString("base64")
76
+ ]
77
+ };
78
+ }
79
+ const JOB_SUPERVISOR = String.raw`
80
+ using System;
81
+ using System.ComponentModel;
82
+ using System.Runtime.InteropServices;
83
+ using System.Text;
84
+
85
+ public static class McpAuthzJob {
86
+ [StructLayout(LayoutKind.Sequential)]
87
+ struct BasicLimits {
88
+ public long ProcessTime, JobTime;
89
+ public uint Flags;
90
+ public UIntPtr MinimumWorkingSet, MaximumWorkingSet;
91
+ public uint ActiveProcessLimit;
92
+ public UIntPtr Affinity;
93
+ public uint PriorityClass, SchedulingClass;
94
+ }
95
+ [StructLayout(LayoutKind.Sequential)]
96
+ struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; }
97
+ [StructLayout(LayoutKind.Sequential)]
98
+ struct ExtendedLimits {
99
+ public BasicLimits Basic;
100
+ public IoCounters Io;
101
+ public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory;
102
+ }
103
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
104
+ struct StartupInfo {
105
+ public uint Size;
106
+ public string Reserved, Desktop, Title;
107
+ public uint X, Y, XSize, YSize, XChars, YChars, Fill, Flags;
108
+ public ushort Show, ReservedSize;
109
+ public IntPtr ReservedData, Input, Output, Error;
110
+ }
111
+ [StructLayout(LayoutKind.Sequential)]
112
+ struct ProcessInfo { public IntPtr Process, Thread; public uint ProcessId, ThreadId; }
113
+
114
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
115
+ static extern IntPtr CreateJobObjectW(IntPtr security, string name);
116
+ [DllImport("kernel32.dll", SetLastError = true)]
117
+ static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint size);
118
+ [DllImport("kernel32.dll", SetLastError = true)]
119
+ static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
120
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
121
+ static extern bool CreateProcessW(string app, StringBuilder line, IntPtr processSecurity,
122
+ IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string cwd,
123
+ ref StartupInfo startup, out ProcessInfo process);
124
+ [DllImport("kernel32.dll", SetLastError = true)]
125
+ static extern uint ResumeThread(IntPtr thread);
126
+ [DllImport("kernel32.dll", SetLastError = true)]
127
+ static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
128
+ [DllImport("kernel32.dll", SetLastError = true)]
129
+ static extern bool GetExitCodeProcess(IntPtr process, out uint code);
130
+ [DllImport("kernel32.dll")]
131
+ static extern IntPtr GetStdHandle(int handle);
132
+ [DllImport("kernel32.dll", SetLastError = true)]
133
+ static extern bool TerminateProcess(IntPtr process, uint code);
134
+ [DllImport("kernel32.dll")]
135
+ static extern bool CloseHandle(IntPtr handle);
136
+
137
+ static void Check(bool success) {
138
+ if (!success) throw new Win32Exception(Marshal.GetLastWin32Error());
139
+ }
140
+
141
+ public static int Run(string commandLine) {
142
+ IntPtr job = CreateJobObjectW(IntPtr.Zero, null);
143
+ Check(job != IntPtr.Zero);
144
+ ProcessInfo process = new ProcessInfo();
145
+ bool assigned = false;
146
+ try {
147
+ ExtendedLimits limits = new ExtendedLimits();
148
+ limits.Basic.Flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
149
+ Check(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(typeof(ExtendedLimits))));
150
+ StartupInfo startup = new StartupInfo();
151
+ startup.Size = (uint)Marshal.SizeOf(typeof(StartupInfo));
152
+ startup.Flags = 0x100; // STARTF_USESTDHANDLES
153
+ startup.Input = GetStdHandle(-10);
154
+ startup.Output = GetStdHandle(-11);
155
+ startup.Error = GetStdHandle(-12);
156
+ Check(CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero,
157
+ true, 0x4, IntPtr.Zero, null, ref startup, out process)); // CREATE_SUSPENDED
158
+ Check(AssignProcessToJobObject(job, process.Process));
159
+ assigned = true;
160
+ Check(ResumeThread(process.Thread) != UInt32.MaxValue);
161
+ Check(WaitForSingleObject(process.Process, UInt32.MaxValue) != UInt32.MaxValue);
162
+ uint code;
163
+ Check(GetExitCodeProcess(process.Process, out code));
164
+ return unchecked((int)code);
165
+ } finally {
166
+ // If assignment failed, the suspended launcher must also be reaped.
167
+ if (!assigned && process.Process != IntPtr.Zero) TerminateProcess(process.Process, 1);
168
+ CloseHandle(job);
169
+ if (process.Thread != IntPtr.Zero) CloseHandle(process.Thread);
170
+ if (process.Process != IntPtr.Zero) CloseHandle(process.Process);
171
+ }
172
+ }
173
+ }
174
+ `;
175
+ //#endregion
176
+ //#region src/wrap.ts
177
+ /**
178
+ * Roughly what a tool's definition costs in the model's context: its JSON, at
179
+ * four characters a token. A guide for choosing what to hide, not a bill.
180
+ */
181
+ function estimateTokens(tool) {
182
+ return Math.ceil(JSON.stringify(tool).length / 4);
183
+ }
184
+ function formatTokens(tokens) {
185
+ return tokens < 1e3 ? `~${tokens}` : `~${(tokens / 1e3).toFixed(1)}k`;
186
+ }
187
+ /** Resolves with the upstream's exit code. */
188
+ function wrap(options, io) {
189
+ const listed = (name) => options.allow ? options.allow.includes(name) : !options.deny?.includes(name);
190
+ const changed = /* @__PURE__ */ new Map();
191
+ const drift = (tool) => {
192
+ if (!options.pinned) return void 0;
193
+ const approved = options.pinned.get(tool.name);
194
+ if (!approved) return options.allow ? "it was not offered when you approved this list" : void 0;
195
+ const fields = changedFields(approved, definitionOf(tool));
196
+ return fields.length > 0 ? `its ${fields.join(", ")} changed since you approved it` : void 0;
197
+ };
198
+ const visible = (name) => listed(name) && !changed.has(name);
199
+ const verified = /* @__PURE__ */ new Set();
200
+ const check = (tool) => {
201
+ const reason = drift(tool);
202
+ if (reason === void 0) {
203
+ changed.delete(tool.name);
204
+ verified.add(tool.name);
205
+ return;
206
+ }
207
+ verified.delete(tool.name);
208
+ if (listed(tool.name) && changed.get(tool.name) !== reason) {
209
+ changed.set(tool.name, reason);
210
+ io.log(`mcp-authz wrap: hid ${tool.name}: ${reason}. Review it with mcp-authz tools --check <config>, then --refresh to approve.`);
211
+ }
212
+ };
213
+ const windows = process.platform === "win32";
214
+ const upstream = windows ? windowsJobCommand(options.command, options.args) : void 0;
215
+ const child = spawn(upstream?.command ?? options.command, [...upstream?.args ?? options.args], {
216
+ stdio: [
217
+ "pipe",
218
+ "pipe",
219
+ "pipe"
220
+ ],
221
+ detached: !windows,
222
+ windowsHide: true,
223
+ ...options.cwd ? { cwd: options.cwd } : {}
224
+ });
225
+ child.stdin.on("error", () => {});
226
+ const listing = /* @__PURE__ */ new Set();
227
+ const discovering = /* @__PURE__ */ new Set();
228
+ const held = [];
229
+ let verifying;
230
+ let verifications = 0;
231
+ let verifyMeta;
232
+ const requestList = (meta, cursor) => {
233
+ verifyMeta = meta;
234
+ verifying = `mcp-authz-wrap/verify/${++verifications}`;
235
+ const params = {
236
+ ...cursor ? { cursor } : {},
237
+ ...meta ? { _meta: meta } : {}
238
+ };
239
+ child.stdin.write(`${JSON.stringify({
240
+ jsonrpc: "2.0",
241
+ id: verifying,
242
+ method: "tools/list",
243
+ params
244
+ })}\n`);
245
+ };
246
+ const calls = /* @__PURE__ */ new Map();
247
+ const forward = (line, message) => {
248
+ const name = message.params.name;
249
+ const approved = options.pinned?.get(name);
250
+ const wrong = approved && checkArguments(name, approved, message.params.arguments);
251
+ if (wrong) {
252
+ const error = {
253
+ code: -32602,
254
+ message: `Invalid params: ${wrong}`
255
+ };
256
+ send(JSON.stringify({
257
+ jsonrpc: "2.0",
258
+ id: message.id ?? null,
259
+ error
260
+ }));
261
+ return;
262
+ }
263
+ if (message.id !== void 0) calls.set(message.id, name);
264
+ child.stdin.write(`${line}\n`);
265
+ };
266
+ const release = (failure) => {
267
+ verifying = void 0;
268
+ for (const { line, message } of held.splice(0)) {
269
+ const name = message.params.name;
270
+ if (!failure && visible(name) && verified.has(name)) {
271
+ forward(line, message);
272
+ continue;
273
+ }
274
+ const error = {
275
+ code: -32602,
276
+ message: `Tool "${name}" is blocked by mcp-authz wrap: ${failure ?? changed.get(name) ?? "the server did not list it, so its definition could not be checked"}`
277
+ };
278
+ send(JSON.stringify({
279
+ jsonrpc: "2.0",
280
+ id: message.id ?? null,
281
+ error
282
+ }));
283
+ }
284
+ };
285
+ const seen = /* @__PURE__ */ new Map();
286
+ let reported = false;
287
+ const report = () => {
288
+ if (reported) return;
289
+ reported = true;
290
+ const hidden = [...seen.keys()].filter((name) => !visible(name)).sort();
291
+ const total = [...seen.values()].reduce((sum, tokens) => sum + tokens, 0);
292
+ const shown = total - hidden.reduce((sum, name) => sum + seen.get(name), 0);
293
+ io.log(`mcp-authz wrap: ${seen.size - hidden.length}/${seen.size} tools exposed (${formatTokens(shown)} of ${formatTokens(total)} tokens)` + (hidden.length > 0 ? `, hidden: ${hidden.join(", ")}` : ""));
294
+ for (const name of options.allow ?? options.deny ?? []) if (!seen.has(name)) io.log(`mcp-authz wrap: no tool named ${name}`);
295
+ };
296
+ onLines(io.input, (line) => {
297
+ if (stopping || line.trim() === "") return;
298
+ let value;
299
+ const refuse = (code, reason) => {
300
+ const id = value?.id;
301
+ const replyTo = typeof id === "string" || typeof id === "number" || holdsInexactNumber(id) ? id : null;
302
+ send(JSON.stringify({
303
+ jsonrpc: "2.0",
304
+ id: replyTo,
305
+ error: {
306
+ code,
307
+ message: reason
308
+ }
309
+ }));
310
+ };
311
+ try {
312
+ value = parseChecked(line);
313
+ } catch {
314
+ refuse(-32700, "Parse error: mcp-authz wrap forwards only messages it can read");
315
+ return;
316
+ }
317
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
318
+ refuse(-32600, "Invalid request: mcp-authz wrap forwards single JSON-RPC messages, not batches");
319
+ return;
320
+ }
321
+ if (hasDuplicateKey(line)) {
322
+ refuse(-32600, "Invalid request: mcp-authz wrap refuses a message that repeats a key");
323
+ return;
324
+ }
325
+ const message = value;
326
+ if (message.method !== void 0 && holdsInexactNumber(message.id)) {
327
+ refuse(-32600, "Invalid request: mcp-authz wrap cannot track an id it cannot hold exactly");
328
+ return;
329
+ }
330
+ if (message.method === "tools/list" && message.id !== void 0) listing.add(message.id);
331
+ if ((message.method === "initialize" || message.method === "server/discover") && message.id !== void 0) discovering.add(message.id);
332
+ const name = message.method === "tools/call" ? message.params?.name : void 0;
333
+ if (message.method === "tools/call" && (typeof name !== "string" || !visible(name))) {
334
+ const error = {
335
+ code: -32602,
336
+ message: typeof name !== "string" ? "tools/call needs a tool name" : changed.has(name) ? `Tool "${name}" is blocked by mcp-authz wrap: ${changed.get(name)}` : `Tool "${name}" is blocked by mcp-authz wrap`
337
+ };
338
+ send(JSON.stringify({
339
+ jsonrpc: "2.0",
340
+ id: message.id ?? null,
341
+ error
342
+ }));
343
+ return;
344
+ }
345
+ if (message.method === "tools/call" && options.pinned && !verified.has(name)) {
346
+ held.push({
347
+ line,
348
+ message
349
+ });
350
+ const meta = Object.fromEntries(Object.entries(message.params?._meta ?? {}).filter(([key]) => key.startsWith("io.modelcontextprotocol/")));
351
+ if (verifying === void 0) requestList(Object.keys(meta).length > 0 ? meta : void 0);
352
+ return;
353
+ }
354
+ if (message.method === "tools/call") forward(line, message);
355
+ else child.stdin.write(`${line}\n`);
356
+ });
357
+ io.output.on("error", () => void stop());
358
+ const send = (line) => {
359
+ if (io.output.writable) io.output.write(`${line}\n`);
360
+ };
361
+ const treeAlive = () => {
362
+ if (child.pid === void 0) return false;
363
+ if (windows) return child.exitCode === null && child.signalCode === null;
364
+ try {
365
+ process.kill(-child.pid, 0);
366
+ return true;
367
+ } catch {
368
+ return false;
369
+ }
370
+ };
371
+ const signalTree = (signal) => {
372
+ if (child.pid === void 0) return;
373
+ if (windows) {
374
+ child.kill("SIGKILL");
375
+ return;
376
+ }
377
+ try {
378
+ process.kill(-child.pid, signal);
379
+ } catch {}
380
+ };
381
+ const goneWithin = async (ms) => {
382
+ for (const deadline = Date.now() + ms; Date.now() < deadline; await setTimeout(25)) if (!treeAlive()) return true;
383
+ return !treeAlive();
384
+ };
385
+ const grace = options.graceMs ?? 2e3;
386
+ let stopping;
387
+ const stop = () => stopping ??= (async () => {
388
+ child.stdin.end();
389
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
390
+ if (await goneWithin(grace)) return;
391
+ signalTree(signal);
392
+ }
393
+ await goneWithin(grace);
394
+ })();
395
+ io.input.on("end", stop);
396
+ if (io.signal?.aborted) stop();
397
+ else io.signal?.addEventListener("abort", stop, { once: true });
398
+ onLines(child.stdout, (line) => {
399
+ const message = parse(line);
400
+ const response = message?.method === void 0 && message?.id !== void 0;
401
+ if (response && message.id === verifying) {
402
+ if (!message.result?.tools) return release("the server would not list its tools to check it");
403
+ for (const tool of message.result.tools) check(tool);
404
+ if (message.result.nextCursor) requestList(verifyMeta, message.result.nextCursor);
405
+ else release();
406
+ return;
407
+ }
408
+ if (message?.method === "notifications/tools/list_changed") verified.clear();
409
+ if (response && listing.delete(message.id) && message.result?.tools) {
410
+ for (const tool of message.result.tools) {
411
+ seen.set(tool.name, estimateTokens(tool));
412
+ check(tool);
413
+ }
414
+ if (!message.result.nextCursor) report();
415
+ message.result.tools = message.result.tools.filter((tool) => visible(tool.name));
416
+ line = JSON.stringify(message);
417
+ }
418
+ if (response && discovering.delete(message.id) && options.pinned && message.result) {
419
+ const live = message.result.instructions;
420
+ const approved = options.pinned.get(INSTRUCTIONS)?.instructions;
421
+ if (live !== void 0 && live !== approved) {
422
+ delete message.result.instructions;
423
+ line = JSON.stringify(message);
424
+ io.log("mcp-authz wrap: removed the server's instructions: they changed since you approved them. Review with mcp-authz tools --check <config>, then --refresh to approve.");
425
+ }
426
+ }
427
+ const tool = response ? calls.get(message.id) : void 0;
428
+ if (message && tool !== void 0 && calls.delete(message.id) && message.result) {
429
+ const checked = parseChecked(line).result;
430
+ const screened = screenResult(tool, options.pinned?.get(tool) ?? {}, checked);
431
+ if (screened.verdict !== "pass") {
432
+ line = JSON.stringify(screened.verdict === "withhold" ? {
433
+ ...message,
434
+ result: screened.result
435
+ } : withNotice(line, screened.notice));
436
+ io.log(`mcp-authz wrap: ${screened.warning}`);
437
+ }
438
+ }
439
+ send(line);
440
+ });
441
+ createInterface({ input: child.stderr }).on("line", io.log);
442
+ return new Promise((resolve) => {
443
+ child.on("exit", (code) => resolve(code ?? 1));
444
+ child.on("error", (error) => {
445
+ io.log(`mcp-authz wrap: could not start ${options.command}: ${error.message}. Install it, or give its full path in your config.`);
446
+ resolve(1);
447
+ });
448
+ }).then(async (code) => {
449
+ await stop();
450
+ upstream?.cleanup();
451
+ return code;
452
+ });
453
+ }
454
+ /**
455
+ * Call `onLine` for each newline-terminated line, framed as the MCP SDKs frame
456
+ * stdio: split on `\n` alone, with a trailing `\r` dropped. node:readline also
457
+ * splits on `\r`, U+2028 and U+2029, which JSON allows unescaped inside a
458
+ * string, so it would read one message as two where the server reads one.
459
+ */
460
+ function onLines(stream, onLine) {
461
+ let buffered = "";
462
+ stream.setEncoding("utf8");
463
+ stream.on("data", (chunk) => {
464
+ buffered += chunk;
465
+ let newline;
466
+ while ((newline = buffered.indexOf("\n")) !== -1) {
467
+ onLine(buffered.slice(0, newline).replace(/\r$/, ""));
468
+ buffered = buffered.slice(newline + 1);
469
+ }
470
+ });
471
+ stream.on("end", () => {
472
+ if (buffered !== "") onLine(buffered.replace(/\r$/, ""));
473
+ buffered = "";
474
+ });
475
+ }
476
+ function parse(line) {
477
+ try {
478
+ const value = JSON.parse(line);
479
+ return typeof value === "object" && value !== null ? value : void 0;
480
+ } catch {
481
+ return;
482
+ }
483
+ }
484
+ //#endregion
485
+ //#region src/wrap-config.ts
486
+ /** Where the schema keeps the definitions; editors ignore an `x-` keyword. */
487
+ const RECORDED = "x-mcp-authz-tools";
488
+ /** Ask a stdio server for its tools, the way a client would. */
489
+ async function discover$1(command, args, cwd) {
490
+ const { Client } = await import("@modelcontextprotocol/client");
491
+ const { StdioClientTransport } = await import("@modelcontextprotocol/client/stdio");
492
+ const client = new Client({
493
+ name: "mcp-authz-tools",
494
+ version: "1.0.0"
495
+ }, { versionNegotiation: { mode: "auto" } });
496
+ const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== void 0));
497
+ const transport = new StdioClientTransport({
498
+ command,
499
+ args: [...args],
500
+ env,
501
+ ...cwd ? { cwd } : {}
502
+ });
503
+ const raw = captureListings(transport);
504
+ await client.connect(transport);
505
+ try {
506
+ const tools = [];
507
+ let cursor;
508
+ do {
509
+ const page = await client.listTools(cursor ? { cursor } : void 0);
510
+ for (const tool of page.tools) tools.push({
511
+ name: tool.name,
512
+ hint: tool.annotations?.destructiveHint ? "destructive" : tool.annotations?.readOnlyHint ? "read-only" : "unknown",
513
+ description: reveal(tool.description?.split("\n")[0]?.trim() ?? ""),
514
+ params: Object.keys(tool.inputSchema.properties ?? {}).map((param) => tool.inputSchema.required?.includes(param) ? `${param} (required)` : param),
515
+ tokens: estimateTokens(raw.get(tool.name) ?? tool),
516
+ pin: definitionOf(raw.get(tool.name) ?? tool),
517
+ warnings: suspicious(definitionOf(raw.get(tool.name) ?? tool))
518
+ });
519
+ cursor = page.nextCursor;
520
+ } while (cursor);
521
+ const instructions = client.getInstructions();
522
+ return {
523
+ tools: tools.sort((a, b) => a.name.localeCompare(b.name)),
524
+ ...typeof instructions === "string" ? { instructions } : {}
525
+ };
526
+ } finally {
527
+ await client.close();
528
+ }
529
+ }
530
+ /** Where the schema for a config lives: beside it, named after it. */
531
+ function schemaPathFor(configPath) {
532
+ return join(dirname(configPath), `${basename(configPath, extname(configPath))}.schema.json`);
533
+ }
534
+ /**
535
+ * Write the config and its schema.
536
+ *
537
+ * In a fresh config, only tools the server marks read-only start switched on,
538
+ * and not those flagged for a closer read. Over an existing config, the choices
539
+ * in it are kept, and a tool the server added or changed since starts commented
540
+ * out, so a refresh keeps your edits and waits for you to approve the rest.
541
+ * With no record to compare against, every tool counts as new.
542
+ */
543
+ function writeWrapConfig(configPath, server, { tools, instructions }, previous) {
544
+ const schemaPath = schemaPathFor(configPath);
545
+ const isNew = (tool) => previous !== void 0 && !previous.recorded?.has(tool.name);
546
+ const isChanged = (tool) => {
547
+ const approved = previous?.recorded?.get(tool.name);
548
+ return approved !== void 0 && changedFields(approved, tool.pin).length > 0;
549
+ };
550
+ const chosen = (tool) => {
551
+ if (!previous) return tool.hint === "read-only" && tool.warnings.length === 0;
552
+ if (isNew(tool) || isChanged(tool)) return false;
553
+ const { allow, deny } = previous.options;
554
+ return allow ? allow.includes(tool.name) : !deny?.includes(tool.name);
555
+ };
556
+ const on = tools.filter(chosen);
557
+ const off = tools.filter((tool) => !chosen(tool));
558
+ const note = (tool) => ` //${tool.warnings.map((warning) => ` ⚠ ${warning} ·`).join("")} ${tool.hint} · ${formatTokens(tool.tokens)} tokens${tool.description ? ` · ${clip(tool.description)}` : ""}`;
559
+ const entries = [...on.map((tool) => ` ${JSON.stringify(tool.name)},${note(tool)}`), ...off.map((tool) => ` // ${JSON.stringify(tool.name)},${note(tool)}`)];
560
+ const text = [
561
+ "{",
562
+ ` "$schema": ${JSON.stringify(`./${basename(schemaPath)}`)},`,
563
+ " // The server `mcp-authz wrap` runs. Its env comes from your client config.",
564
+ ` "server": {`,
565
+ ` "command": ${JSON.stringify(server.command)},`,
566
+ ` "args": [${server.args.map((arg) => JSON.stringify(arg)).join(", ")}],`,
567
+ " // Where the server runs, relative to this file, so relative paths in",
568
+ " // \"args\" mean the same wherever the client starts it.",
569
+ ` "cwd": ${JSON.stringify(storedCwd(configPath, server.cwd))}`,
570
+ " },",
571
+ " // The tools this session may see. Anything not listed is hidden, including",
572
+ " // tools the server adds later. Uncomment a line to switch a tool on.",
573
+ " \"allow\": [",
574
+ ...entries,
575
+ " ]",
576
+ "}",
577
+ ""
578
+ ].join("\n");
579
+ writeFileSync(configPath, text);
580
+ writeFileSync(schemaPath, `${JSON.stringify(schemaFor(tools, instructions), null, 2)}\n`);
581
+ return {
582
+ allowed: on.length,
583
+ commented: off.length,
584
+ added: tools.filter(isNew).length,
585
+ changed: tools.filter(isChanged).length,
586
+ tokens: {
587
+ allowed: sum(on),
588
+ total: sum(tools)
589
+ }
590
+ };
591
+ }
592
+ function sum(tools) {
593
+ return tools.reduce((total, tool) => total + tool.tokens, 0);
594
+ }
595
+ function schemaFor(tools, instructions) {
596
+ const names = {
597
+ type: "array",
598
+ items: { $ref: "#/definitions/tool" },
599
+ uniqueItems: true
600
+ };
601
+ return {
602
+ $schema: "http://json-schema.org/draft-07/schema#",
603
+ title: "mcp-authz wrap config",
604
+ type: "object",
605
+ required: ["server"],
606
+ additionalProperties: false,
607
+ not: { required: ["allow", "deny"] },
608
+ properties: {
609
+ $schema: { type: "string" },
610
+ server: {
611
+ type: "object",
612
+ required: ["command"],
613
+ additionalProperties: false,
614
+ properties: {
615
+ command: { type: "string" },
616
+ args: {
617
+ type: "array",
618
+ items: { type: "string" }
619
+ },
620
+ cwd: {
621
+ type: "string",
622
+ description: "Where the server runs, relative to this file or absolute."
623
+ }
624
+ }
625
+ },
626
+ allow: {
627
+ ...names,
628
+ description: "Only these tools are shown."
629
+ },
630
+ deny: {
631
+ ...names,
632
+ description: "Every tool but these is shown."
633
+ }
634
+ },
635
+ definitions: { tool: tools.length > 0 ? { anyOf: tools.map((tool) => ({
636
+ const: tool.name,
637
+ description: note(tool)
638
+ })) } : { not: {} } },
639
+ [RECORDED]: Object.fromEntries([...tools.map((tool) => [tool.name, tool.pin]), ...instructions === void 0 ? [] : [[INSTRUCTIONS, { instructions }]]])
640
+ };
641
+ function note(tool) {
642
+ const head = `${tool.hint} · ${formatTokens(tool.tokens)} tokens`;
643
+ const said = tool.description ? `${head} · ${tool.description}` : head;
644
+ return tool.params.length > 0 ? `${said}\n\nTakes: ${tool.params.join(", ")}` : said;
645
+ }
646
+ }
647
+ /**
648
+ * What the schema beside a config recorded: each tool's definition, and the
649
+ * server's instructions under `server:instructions`. Throws when the record is
650
+ * missing or damaged, because wrap without it would filter by name alone while
651
+ * looking exactly as protected as before.
652
+ */
653
+ function recordedTools(configPath) {
654
+ const schemaPath = schemaPathFor(configPath);
655
+ const fail = (problem) => {
656
+ throw new Error(`${schemaPath}: ${problem}. It records what you approved, and wrap will not run without it. Run mcp-authz tools --refresh ${configPath} to record the server again; every tool starts switched off.`);
657
+ };
658
+ let schema;
659
+ try {
660
+ schema = JSON.parse(readFileSync(schemaPath, "utf8"));
661
+ } catch (error) {
662
+ return fail(error.code === "ENOENT" ? "missing" : "not valid JSON");
663
+ }
664
+ const recorded = schema?.[RECORDED];
665
+ if (typeof recorded !== "object" || recorded === null || Array.isArray(recorded)) return fail(`no "${RECORDED}" record in it`);
666
+ for (const [name, definition] of Object.entries(recorded)) if (typeof definition !== "object" || definition === null || Array.isArray(definition)) fail(`the record for "${name}" is not a definition`);
667
+ return new Map(Object.entries(recorded));
668
+ }
669
+ /** The record, or nothing when there is none to read: for a refresh, which writes a new one. */
670
+ function recordedToolsIfAny(configPath) {
671
+ try {
672
+ return recordedTools(configPath);
673
+ } catch {
674
+ return;
675
+ }
676
+ }
677
+ /**
678
+ * Read a config into what `wrap` takes, saying which file is wrong and how.
679
+ * Without `record: false`, the record beside it must be there and must cover
680
+ * every tool the config names.
681
+ */
682
+ function readWrapConfig(configPath, { record = true } = {}) {
683
+ const fail = (problem) => {
684
+ throw new Error(`${configPath}: ${problem}`);
685
+ };
686
+ let text;
687
+ try {
688
+ text = readFileSync(configPath, "utf8");
689
+ } catch {
690
+ return fail(`cannot read it. Create it with: mcp-authz tools --out ${configPath} -- <server command>`);
691
+ }
692
+ let value;
693
+ try {
694
+ value = parseJsonc(text);
695
+ } catch (error) {
696
+ 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>`);
697
+ }
698
+ const config = value;
699
+ const unknown = (object, known, prefix = "") => {
700
+ if (typeof object !== "object" || object === null) return;
701
+ for (const key of Object.keys(object)) if (!known.includes(key)) fail(`unknown key "${prefix}${key}"; expected one of: ${known.join(", ")}.`);
702
+ };
703
+ unknown(config, [
704
+ "$schema",
705
+ "server",
706
+ "allow",
707
+ "deny"
708
+ ]);
709
+ unknown(config?.server, [
710
+ "command",
711
+ "args",
712
+ "cwd"
713
+ ], "server.");
714
+ const strings = (list) => Array.isArray(list) && list.every((item) => typeof item === "string");
715
+ if (typeof config?.server?.command !== "string") fail("\"server.command\" must be a string.");
716
+ const cwd = config.server.cwd ?? ".";
717
+ if (typeof cwd !== "string") fail("\"server.cwd\" must be a path.");
718
+ const args = config.server.args ?? [];
719
+ if (!strings(args)) fail("\"server.args\" must be a list of strings.");
720
+ if (config.allow !== void 0 && config.deny !== void 0) fail("use \"allow\" or \"deny\", not both.");
721
+ for (const key of ["allow", "deny"]) if (config[key] !== void 0 && !strings(config[key])) fail(`"${key}" must be a list of tool names.`);
722
+ const pinned = record ? recordedTools(configPath) : void 0;
723
+ const unrecorded = (config.allow ?? config.deny ?? []).filter((name) => !pinned?.has(name));
724
+ if (pinned && unrecorded.length > 0) fail(`${unrecorded.map((name) => `"${name}"`).join(", ")} not in the record beside it. Fix the name, or run mcp-authz tools --refresh ${configPath} to record the server again.`);
725
+ return {
726
+ command: config.server.command,
727
+ args,
728
+ cwd: resolve(dirname(resolve(configPath)), cwd),
729
+ ...pinned ? { pinned } : {},
730
+ ...config.allow ? { allow: config.allow } : {},
731
+ ...config.deny ? { deny: config.deny } : {}
732
+ };
733
+ }
734
+ function entryFor(configPath) {
735
+ return {
736
+ name: basename(configPath, extname(configPath)),
737
+ entry: {
738
+ command: "npx",
739
+ args: [
740
+ "-y",
741
+ "mcp-authz",
742
+ "wrap",
743
+ resolve(configPath)
744
+ ]
745
+ }
746
+ };
747
+ }
748
+ /**
749
+ * Add the entry to a client's MCP config file, creating it if need be. Only how
750
+ * the server starts changes. Other servers, and every other setting on this one
751
+ * (`env`, `disabled`, `timeout`, whatever the client supports), stay as they
752
+ * were, so a rerun keeps your API key and your on/off choice.
753
+ */
754
+ function writeClientConfig(clientPath, configPath) {
755
+ const { name, entry } = entryFor(configPath);
756
+ let existing = {};
757
+ if (existsSync(clientPath)) try {
758
+ existing = parseJsonc(readFileSync(clientPath, "utf8"));
759
+ } catch (error) {
760
+ const reason = error instanceof Error ? error.message : String(error);
761
+ throw new Error(`${clientPath}: not valid JSON (${reason}). Fix it, or pass --client-out a new file.`, { cause: error });
762
+ }
763
+ const kept = { ...existing.mcpServers?.[name] };
764
+ delete kept.url;
765
+ if (kept.type !== void 0 && kept.type !== "stdio") delete kept.type;
766
+ const merged = {
767
+ ...existing,
768
+ mcpServers: {
769
+ ...existing.mcpServers,
770
+ [name]: {
771
+ ...kept,
772
+ ...entry
773
+ }
774
+ }
775
+ };
776
+ writeFileSync(clientPath, `${JSON.stringify(merged, null, 2)}\n`);
777
+ }
778
+ /** The `mcpServers` entry that runs a config, ready to paste. */
779
+ function clientEntry(configPath) {
780
+ const { name, entry } = entryFor(configPath);
781
+ const args = entry.args.map((arg) => JSON.stringify(arg));
782
+ return [
783
+ `${JSON.stringify(name)}: {`,
784
+ " \"command\": \"npx\",",
785
+ ` "args": [${args.join(", ")}]`,
786
+ "}"
787
+ ].join("\n");
788
+ }
789
+ /**
790
+ * JSON with comments and trailing commas, which is what a person editing the
791
+ * file will produce. Strings are copied whole, so `//` inside one is safe.
792
+ */
793
+ function parseJsonc(text) {
794
+ let out = "";
795
+ for (let i = 0; i < text.length; i++) {
796
+ const char = text[i];
797
+ if (char === "\"") {
798
+ let end = i + 1;
799
+ while (end < text.length && text[end] !== "\"") end += text[end] === "\\" ? 2 : 1;
800
+ out += text.slice(i, end + 1);
801
+ i = end;
802
+ } else if (char === "/" && text[i + 1] === "/") {
803
+ while (i < text.length && text[i] !== "\n") i++;
804
+ out += "\n";
805
+ } else if (char === "/" && text[i + 1] === "*") {
806
+ const end = text.indexOf("*/", i + 2);
807
+ i = end === -1 ? text.length : end + 1;
808
+ } else {
809
+ if (char === "]" || char === "}") out = out.replace(/,\s*$/, "");
810
+ out += char;
811
+ }
812
+ }
813
+ return JSON.parse(out);
814
+ }
815
+ /**
816
+ * Relative to the config when that is the shorter way to say it, as it is for
817
+ * a config kept beside its server in a repo; otherwise absolute, so a copied
818
+ * file still finds the server.
819
+ */
820
+ function storedCwd(configPath, cwd) {
821
+ const relativeTo = relative(dirname(resolve(configPath)), cwd) || ".";
822
+ return relativeTo.length <= cwd.length ? relativeTo : cwd;
823
+ }
824
+ /** The first sentence, and no more than a line's worth of it. */
825
+ function clip(text, max = 80) {
826
+ const sentence = /^.*?[.!?](?=\s|$)/.exec(text)?.[0] ?? text;
827
+ return sentence.length > max ? `${sentence.slice(0, max - 1)}…` : sentence;
828
+ }
829
+ //#endregion
7
830
  //#region src/cli.ts
8
831
  /**
9
832
  * Two questions a policy file cannot answer by being read.
@@ -14,6 +837,10 @@ import { parseArgs } from "node:util";
14
837
  * decision carries the permissions, and only `policy.explain` carries the rules
15
838
  * that produced them.
16
839
  *
840
+ * `tools` and `wrap` answer a third: which of a server's tools should this
841
+ * session see. `wrap` reads JSON lines and nothing more, so it works in front of
842
+ * any stdio server; `tools` is the one command that speaks MCP as a client.
843
+ *
17
844
  * Deliberately no colour library and no argument parser. `node:util` has one,
18
845
  * and a dependency here would be a dependency in every install of the package.
19
846
  */
@@ -24,6 +851,12 @@ const USAGE = `mcp-authz — inspect a policy without running a server
24
851
  mcp-authz record --upstream <url> [--token <bearer>] [--out <permissions.ts>]
25
852
  mcp-authz record <connector.ts|--upstream <url>> --check <permissions.ts>
26
853
  mcp-authz explain <policy.json> --identity <identity.json>|- [--capabilities <map.json>]
854
+ mcp-authz tools -- <command> [args...]
855
+ mcp-authz tools --out <name.jsonc> [--client-out <mcp.json>] -- <command> [args...]
856
+ mcp-authz tools --check <name.jsonc>
857
+ mcp-authz tools --refresh <name.jsonc>
858
+ mcp-authz wrap <name.jsonc>
859
+ mcp-authz wrap [--allow <a,b> | --deny <a,b>] -- <command> [args...]
27
860
 
28
861
  Files
29
862
  <policy.json> the object you would hand definePolicy
@@ -32,6 +865,12 @@ Files
32
865
  --identity an Identity, or a decoded token payload (iss, sub, email,
33
866
  email_verified, hd). Use - to read it from stdin.
34
867
 
868
+ wrap
869
+ Runs a stdio MCP server and hides tools from whoever connects. Put it in
870
+ front of the server in your client's MCP config. With neither flag every
871
+ tool passes through; names are exact and comma-separated. For a remote
872
+ server, wrap the bridge: -- npx -y mcp-remote <url>
873
+
35
874
  Exit codes
36
875
  0 fine, warnings included
37
876
  1 the policy is invalid, or a capability no role can reach
@@ -47,6 +886,10 @@ function main(argv) {
47
886
  upstream: { type: "string" },
48
887
  token: { type: "string" },
49
888
  check: { type: "string" },
889
+ allow: { type: "string" },
890
+ deny: { type: "string" },
891
+ refresh: { type: "string" },
892
+ "client-out": { type: "string" },
50
893
  help: {
51
894
  type: "boolean",
52
895
  short: "h"
@@ -54,10 +897,21 @@ function main(argv) {
54
897
  }
55
898
  });
56
899
  const [command, policyPath] = positionals;
900
+ const dashes = argv.indexOf("--");
901
+ const upstream = dashes === -1 ? [] : argv.slice(dashes + 1);
902
+ const [, ownPath] = positionals.slice(0, positionals.length - upstream.length);
57
903
  if (values.help || !command) {
58
904
  process.stdout.write(USAGE);
59
905
  return values.help ? 0 : 1;
60
906
  }
907
+ if (command === "tools") {
908
+ if (values.check) return checkWrapConfig(values.check, upstream);
909
+ return listTools(upstream, values);
910
+ }
911
+ if (command === "wrap") return runWrap(upstream, {
912
+ ...values,
913
+ config: ownPath
914
+ });
61
915
  if (command === "record") {
62
916
  if (values.upstream) return record({
63
917
  upstream: values.upstream,
@@ -73,6 +927,10 @@ function main(argv) {
73
927
  process.stderr.write(`${command} needs a path to a policy file.\n`);
74
928
  return 1;
75
929
  }
930
+ if (command === "check" && isWrapConfig(policyPath)) {
931
+ 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`);
932
+ return 1;
933
+ }
76
934
  const policy = definePolicy(readJson(policyPath));
77
935
  const run = (capabilities) => {
78
936
  if (command === "check") return check(policy, capabilities);
@@ -90,6 +948,180 @@ function main(argv) {
90
948
  if (values.capabilities.endsWith(".json")) return run(new Map(Object.entries(readJson(values.capabilities))));
91
949
  return importCapabilities(values.capabilities).then(run);
92
950
  }
951
+ /**
952
+ * Print the names `wrap` takes, with the hints that help choose between them,
953
+ * and with --out or --refresh save them as a config `wrap <file>` runs.
954
+ */
955
+ function listTools(upstream, flags) {
956
+ const refuse = (message) => (process.stderr.write(`${message}\n`), 1);
957
+ if (flags.refresh && upstream.length > 0) return refuse("tools --refresh reruns the server its config names, so it takes no command after --.");
958
+ if (flags.refresh && flags.out) return refuse("tools takes --out or --refresh, not both.");
959
+ const saved = flags.refresh ? readWrapConfig(flags.refresh, { record: false }) : void 0;
960
+ const [command, ...args] = saved ? [saved.command, ...saved.args] : upstream;
961
+ const cwd = saved?.cwd ?? process.cwd();
962
+ if (saved) announce(saved, flags.refresh);
963
+ if (!command) return refuse("tools needs the server command after --, e.g. tools -- npx -y some-mcp");
964
+ const target = flags.refresh ?? flags.out;
965
+ return discover(command, args, cwd).then((discovered) => {
966
+ if (discovered === void 0) return 1;
967
+ const { tools, instructions } = discovered;
968
+ const width = Math.max(...tools.map((tool) => tool.name.length));
969
+ const cost = tools.map((tool) => `${formatTokens(tool.tokens)} tokens`);
970
+ const costWidth = Math.max(...cost.map((text) => text.length));
971
+ tools.forEach((tool, i) => {
972
+ process.stdout.write(`${tool.name.padEnd(width)} ${tool.hint.padEnd(11)} ${cost[i].padStart(costWidth)} ${tool.description}`.trimEnd() + "\n");
973
+ });
974
+ for (const tool of tools.filter((t) => t.warnings.length > 0)) process.stderr.write(`⚠ ${tool.name} ${tool.warnings.join(" and ")}: read its definition before you allow it.\n`);
975
+ if (instructions !== void 0) {
976
+ process.stderr.write(`\nThe server's instructions to the model, which wrap holds to this record:\n ${reveal(instructions)}\n`);
977
+ for (const warning of suspicious({ instructions })) process.stderr.write(`⚠ the instructions ${warning}: read them before you use this server.\n`);
978
+ }
979
+ if (!target) {
980
+ process.stderr.write("\nSave these as a config wrap can run: tools --out <name>.jsonc -- ...\n");
981
+ return 0;
982
+ }
983
+ const recorded = existsSync(target) ? recordedToolsIfAny(target) : void 0;
984
+ const approved = recorded?.get("server:instructions") ?? {};
985
+ const live = instructions === void 0 ? {} : { instructions };
986
+ if (recorded && changedFields(approved, live).length > 0) process.stderr.write([
987
+ "",
988
+ ...describeChange("server instructions", approved, live, "recorded by this refresh").map((line) => line.replace("until you approve it", "and approved by saving: read it")),
989
+ ""
990
+ ].join("\n"));
991
+ const previous = existsSync(target) ? {
992
+ options: readWrapConfig(target, { record: false }),
993
+ ...recorded ? { recorded } : {}
994
+ } : void 0;
995
+ const { allowed, commented, added, changed, tokens } = writeWrapConfig(target, {
996
+ command,
997
+ args,
998
+ cwd
999
+ }, discovered, previous);
1000
+ const summary = !previous ? `${allowed} read-only allowed, ${commented} commented out for you to choose` : recorded ? `${allowed} allowed, ${commented} commented out, ${added} new and ${changed} changed since last saved, left commented out` : `no record to compare against, so all ${commented} commented out for you to approve again`;
1001
+ const lines = [
1002
+ "",
1003
+ `Saved ${target} (${summary}) and ${schemaPathFor(target)}.`,
1004
+ `The model sees ${formatTokens(tokens.allowed)} of ${formatTokens(tokens.total)} tokens of tool definitions.`
1005
+ ];
1006
+ if (flags["client-out"]) {
1007
+ writeClientConfig(flags["client-out"], target);
1008
+ lines.push(`Added it to ${flags["client-out"]}; give it the env the server needs there.`);
1009
+ }
1010
+ lines.push("Add this to your client's mcpServers, with the env the server needs:", "", clientEntry(target), "");
1011
+ process.stdout.write(lines.join("\n"));
1012
+ return 0;
1013
+ });
1014
+ }
1015
+ /**
1016
+ * Discovery with a next step for the two common stops: a command that is not
1017
+ * installed, and a server that exits at once, which usually means it wants an
1018
+ * API key. The server's own message is already on stderr above this one.
1019
+ */
1020
+ async function discover(command, args, cwd) {
1021
+ try {
1022
+ return await discover$1(command, args, cwd);
1023
+ } catch (error) {
1024
+ const code = error.code;
1025
+ 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");
1026
+ return;
1027
+ }
1028
+ }
1029
+ /** Say what is about to run when the command comes from a file. */
1030
+ function announce(options, path) {
1031
+ process.stderr.write(`Running ${[options.command, ...options.args].join(" ")} from ${path}\n`);
1032
+ }
1033
+ function isWrapConfig(path) {
1034
+ if (path === "-") return false;
1035
+ try {
1036
+ const value = parseJsonc(readFileSync(path, "utf8"));
1037
+ return typeof value === "object" && value !== null && "server" in value;
1038
+ } catch {
1039
+ return false;
1040
+ }
1041
+ }
1042
+ /**
1043
+ * Ask the server what it offers now, and compare it with the config and with
1044
+ * the catalogue recorded beside it. Exits 1 on any difference, the way
1045
+ * `record --check` does, so CI notices an upgrade that renamed a tool.
1046
+ */
1047
+ async function checkWrapConfig(path, upstream) {
1048
+ if (upstream.length > 0) {
1049
+ process.stderr.write("tools --check runs the server its config names, so it takes no command after --.\n");
1050
+ return 1;
1051
+ }
1052
+ const options = readWrapConfig(path);
1053
+ announce(options, path);
1054
+ const found = await discover(options.command, options.args, options.cwd);
1055
+ if (found === void 0) return 1;
1056
+ const discovered = found.tools;
1057
+ const live = discovered.map((tool) => tool.name);
1058
+ const recorded = options.pinned;
1059
+ const recordedNames = [...recorded.keys()].filter((name) => name !== INSTRUCTIONS);
1060
+ const missing = (options.allow ?? options.deny ?? []).filter((name) => !live.includes(name));
1061
+ const added = live.filter((name) => !recorded.has(name));
1062
+ const removed = recordedNames.filter((name) => !live.includes(name));
1063
+ const changed = discovered.flatMap((tool) => {
1064
+ const approved = recorded.get(tool.name);
1065
+ const fields = approved ? changedFields(approved, tool.pin) : [];
1066
+ return fields.length > 0 ? [{
1067
+ name: tool.name,
1068
+ approved,
1069
+ live: tool.pin,
1070
+ fields
1071
+ }] : [];
1072
+ });
1073
+ const approvedInstructions = recorded.get("server:instructions") ?? {};
1074
+ const liveInstructions = found.instructions === void 0 ? {} : { instructions: found.instructions };
1075
+ const instructionsChanged = changedFields(approvedInstructions, liveInstructions).length > 0;
1076
+ if (missing.length === 0 && added.length === 0 && removed.length === 0 && changed.length === 0 && !instructionsChanged) {
1077
+ process.stdout.write(`${live.length} tools, as recorded in ${schemaPathFor(path)}\n`);
1078
+ return 0;
1079
+ }
1080
+ const lines = [`${path} does not match the server:`, ""];
1081
+ for (const name of missing) lines.push(` ? ${name}`, ` in "${options.allow ? "allow" : "deny"}", but the server has no such tool`);
1082
+ for (const name of added) lines.push(` + ${name}`, ` new since last saved; ${options.allow ? "hidden, since it is not in \"allow\"" : "shown"}`);
1083
+ for (const name of removed) lines.push(` - ${name}`, " recorded, but the server no longer offers it");
1084
+ for (const tool of changed) {
1085
+ const state = options.allow?.includes(tool.name) ? "hidden by wrap" : "not in use";
1086
+ lines.push(...describeChange(tool.name, tool.approved, tool.live, state));
1087
+ }
1088
+ if (instructionsChanged) lines.push(...describeChange("server instructions", approvedInstructions, liveInstructions, "removed by wrap"));
1089
+ lines.push("", missing.length > 0 ? `Fix or remove the "?" names in ${path}, then run tools --refresh ${path} to record the rest.` : `Run tools --refresh ${path} to record the change. Your choices are kept; new and changed tools stay off until you switch them on.`, "");
1090
+ process.stdout.write(lines.join("\n"));
1091
+ return 1;
1092
+ }
1093
+ function runWrap(upstream, { allow, deny, config }) {
1094
+ const refuse = (message) => (process.stderr.write(`${message}\n`), 1);
1095
+ if (allow !== void 0 && deny !== void 0) return refuse("wrap takes --allow or --deny, not both.");
1096
+ if (config !== void 0) {
1097
+ if (upstream.length > 0) return refuse("wrap takes a config or a command after --, not both.");
1098
+ if (allow !== void 0 || deny !== void 0) return refuse("wrap takes a config or --allow/--deny, not both: the list lives in the file.");
1099
+ }
1100
+ const names = (list) => list?.split(",").map((name) => name.trim()).filter(Boolean);
1101
+ const [command, ...args] = upstream;
1102
+ if (config === void 0 && !command) return refuse("wrap needs a config, e.g. wrap cases.jsonc, or the server command after --, e.g. wrap --deny x -- npx -y some-mcp");
1103
+ const options = config ? readWrapConfig(config) : {
1104
+ command,
1105
+ args,
1106
+ allow: names(allow),
1107
+ deny: names(deny)
1108
+ };
1109
+ const stopped = new AbortController();
1110
+ for (const signal of [
1111
+ "SIGINT",
1112
+ "SIGTERM",
1113
+ "SIGHUP"
1114
+ ]) process.once(signal, () => stopped.abort());
1115
+ return wrap(options, {
1116
+ input: process.stdin,
1117
+ output: process.stdout,
1118
+ log: (line) => process.stderr.write(`${line}\n`),
1119
+ signal: stopped.signal
1120
+ }).then((code) => {
1121
+ process.stdin.destroy();
1122
+ return code;
1123
+ });
1124
+ }
93
1125
  /** Read the map out of a module `record` produced, or one written by hand. */
94
1126
  async function importCapabilities(path) {
95
1127
  const loaded = await import(pathToFileURL(resolve(path)).href);
@@ -98,13 +1130,7 @@ async function importCapabilities(path) {
98
1130
  return new Map(Object.entries(map));
99
1131
  }
100
1132
  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
- }
1133
+ const toolkit = await import("./testing.js");
108
1134
  let capabilities;
109
1135
  if ("upstream" in source) capabilities = await toolkit.recordUpstream(source.upstream, { bearer: source.token });
110
1136
  else {
@@ -130,24 +1156,42 @@ async function record(source, out, against) {
130
1156
  async function drift(live, path) {
131
1157
  const loaded = await import(pathToFileURL(resolve(path)).href);
132
1158
  const priced = Object.keys(loaded.PERMISSIONS ?? {});
133
- const recorded = loaded.FINGERPRINTS ?? {};
1159
+ const recorded = new Map(Object.entries(loaded.DEFINITIONS ?? {}));
134
1160
  const added = live.names.filter((name) => !priced.includes(name));
135
1161
  const removed = priced.filter((name) => !live.names.includes(name));
136
- const changed = live.names.filter((name) => priced.includes(name) && recorded[name] && recorded[name] !== live.fingerprints[name]);
137
- const unbaselined = Object.keys(recorded).length === 0 ? ` — ${path} carries no FINGERPRINTS, so definitions were not compared; re-record to add one` : "";
138
- if (added.length === 0 && removed.length === 0 && changed.length === 0) {
139
- process.stdout.write(`${live.names.length} capabilities, names unchanged since ${path}${unbaselined}\n`);
1162
+ const unrecorded = missingDefinitions(priced.filter((name) => live.names.includes(name)), recorded);
1163
+ const changed = live.names.filter((name) => priced.includes(name) && recorded.has(name) && changedFields(recorded.get(name), live.definitions[name]).length > 0);
1164
+ const approvedInstructions = recorded.get("server:instructions") ?? {};
1165
+ const liveInstructions = live.definitions["server:instructions"] ?? {};
1166
+ const instructionsChanged = changedFields(approvedInstructions, liveInstructions).length > 0;
1167
+ if (added.length === 0 && removed.length === 0 && changed.length === 0 && unrecorded.length === 0 && !instructionsChanged) {
1168
+ process.stdout.write(`${live.names.length} capabilities, unchanged since ${path}\n`);
140
1169
  return 0;
141
1170
  }
142
1171
  const lines = ["The server no longer matches the recorded capabilities:", ""];
143
1172
  for (const name of added) lines.push(` + ${name}`, " never priced, so nobody decided who may reach it");
144
1173
  for (const name of removed) lines.push(` - ${name}`, " priced here, but the server no longer offers it");
145
- for (const name of changed) lines.push(` ~ ${name}`, " same name, different definition than the one recorded");
146
- if (unbaselined) lines.push("", `Note:${unbaselined.slice(3)}`);
1174
+ for (const name of unrecorded) lines.push(` ? ${name}`, " priced, but DEFINITIONS has no record of it, so a change would go unseen");
1175
+ for (const name of changed) lines.push(...describeChange(name, recorded.get(name), live.definitions[name], "hidden by createMcpProxy"));
1176
+ if (instructionsChanged) lines.push(...describeChange("server instructions", approvedInstructions, liveInstructions, "removed by createMcpProxy"));
147
1177
  lines.push("", "Re-record when the change is expected, and review the diff.", "");
148
1178
  process.stdout.write(lines.join("\n"));
149
1179
  return 1;
150
1180
  }
1181
+ /**
1182
+ * A changed definition, with the words themselves: a changed description is
1183
+ * how a server steers the model, and "definition changed" alone gives you
1184
+ * nothing to judge.
1185
+ */
1186
+ function describeChange(name, recorded, live, state) {
1187
+ const fields = changedFields(recorded, live);
1188
+ return [
1189
+ ` ~ ${name}`,
1190
+ ` ${fields.join(", ")} changed since recorded; ${state} until you approve it`,
1191
+ ...fields.flatMap((field) => [` ${field} was: ${reveal(JSON.stringify(recorded[field]) ?? "(absent)")}`, ` ${field} now: ${reveal(JSON.stringify(live[field]) ?? "(absent)")}`]),
1192
+ ...suspicious(live).map((warning) => ` ⚠ now ${warning}`)
1193
+ ];
1194
+ }
151
1195
  function check(policy, capabilities) {
152
1196
  const lines = [`${policy.roles.size} role${policy.roles.size === 1 ? "" : "s"}`, `${policy.permissions.length} permission${policy.permissions.length === 1 ? "" : "s"}`];
153
1197
  if (capabilities) lines.push(`${capabilities.size} capabilities`);