mcp-authz 0.4.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,5 +1,8 @@
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";
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";
3
6
  import { createRequire } from "node:module";
4
7
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
8
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
@@ -171,9 +174,42 @@ public static class McpAuthzJob {
171
174
  `;
172
175
  //#endregion
173
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
+ }
174
187
  /** Resolves with the upstream's exit code. */
175
188
  function wrap(options, io) {
176
- const visible = (name) => options.allow ? options.allow.includes(name) : !options.deny?.includes(name);
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
+ };
177
213
  const windows = process.platform === "win32";
178
214
  const upstream = windows ? windowsJobCommand(options.command, options.args) : void 0;
179
215
  const child = spawn(upstream?.command ?? options.command, [...upstream?.args ?? options.args], {
@@ -188,13 +224,73 @@ function wrap(options, io) {
188
224
  });
189
225
  child.stdin.on("error", () => {});
190
226
  const listing = /* @__PURE__ */ new Set();
191
- const seen = /* @__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();
192
286
  let reported = false;
193
287
  const report = () => {
194
288
  if (reported) return;
195
289
  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(", ")}` : ""));
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(", ")}` : ""));
198
294
  for (const name of options.allow ?? options.deny ?? []) if (!seen.has(name)) io.log(`mcp-authz wrap: no tool named ${name}`);
199
295
  };
200
296
  onLines(io.input, (line) => {
@@ -202,9 +298,10 @@ function wrap(options, io) {
202
298
  let value;
203
299
  const refuse = (code, reason) => {
204
300
  const id = value?.id;
301
+ const replyTo = typeof id === "string" || typeof id === "number" || holdsInexactNumber(id) ? id : null;
205
302
  send(JSON.stringify({
206
303
  jsonrpc: "2.0",
207
- id: typeof id === "string" || typeof id === "number" ? id : null,
304
+ id: replyTo,
208
305
  error: {
209
306
  code,
210
307
  message: reason
@@ -212,7 +309,7 @@ function wrap(options, io) {
212
309
  }));
213
310
  };
214
311
  try {
215
- value = JSON.parse(line);
312
+ value = parseChecked(line);
216
313
  } catch {
217
314
  refuse(-32700, "Parse error: mcp-authz wrap forwards only messages it can read");
218
315
  return;
@@ -226,12 +323,17 @@ function wrap(options, io) {
226
323
  return;
227
324
  }
228
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
+ }
229
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);
230
332
  const name = message.method === "tools/call" ? message.params?.name : void 0;
231
333
  if (message.method === "tools/call" && (typeof name !== "string" || !visible(name))) {
232
334
  const error = {
233
335
  code: -32602,
234
- message: typeof name === "string" ? `Tool "${name}" is blocked by mcp-authz wrap` : "tools/call needs a tool name"
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`
235
337
  };
236
338
  send(JSON.stringify({
237
339
  jsonrpc: "2.0",
@@ -240,7 +342,17 @@ function wrap(options, io) {
240
342
  }));
241
343
  return;
242
344
  }
243
- child.stdin.write(`${line}\n`);
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`);
244
356
  });
245
357
  io.output.on("error", () => void stop());
246
358
  const send = (line) => {
@@ -285,12 +397,45 @@ function wrap(options, io) {
285
397
  else io.signal?.addEventListener("abort", stop, { once: true });
286
398
  onLines(child.stdout, (line) => {
287
399
  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);
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
+ }
290
414
  if (!message.result.nextCursor) report();
291
415
  message.result.tools = message.result.tools.filter((tool) => visible(tool.name));
292
416
  line = JSON.stringify(message);
293
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
+ }
294
439
  send(line);
295
440
  });
296
441
  createInterface({ input: child.stderr }).on("line", io.log);
@@ -328,34 +473,6 @@ function onLines(stream, onLine) {
328
473
  buffered = "";
329
474
  });
330
475
  }
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
476
  function parse(line) {
360
477
  try {
361
478
  const value = JSON.parse(line);
@@ -366,6 +483,8 @@ function parse(line) {
366
483
  }
367
484
  //#endregion
368
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";
369
488
  /** Ask a stdio server for its tools, the way a client would. */
370
489
  async function discover$1(command, args, cwd) {
371
490
  const { Client } = await import("@modelcontextprotocol/client");
@@ -373,14 +492,16 @@ async function discover$1(command, args, cwd) {
373
492
  const client = new Client({
374
493
  name: "mcp-authz-tools",
375
494
  version: "1.0.0"
376
- });
495
+ }, { versionNegotiation: { mode: "auto" } });
377
496
  const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== void 0));
378
- await client.connect(new StdioClientTransport({
497
+ const transport = new StdioClientTransport({
379
498
  command,
380
499
  args: [...args],
381
500
  env,
382
501
  ...cwd ? { cwd } : {}
383
- }));
502
+ });
503
+ const raw = captureListings(transport);
504
+ await client.connect(transport);
384
505
  try {
385
506
  const tools = [];
386
507
  let cursor;
@@ -388,13 +509,20 @@ async function discover$1(command, args, cwd) {
388
509
  const page = await client.listTools(cursor ? { cursor } : void 0);
389
510
  for (const tool of page.tools) tools.push({
390
511
  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)
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))
394
518
  });
395
519
  cursor = page.nextCursor;
396
520
  } while (cursor);
397
- return tools.sort((a, b) => a.name.localeCompare(b.name));
521
+ const instructions = client.getInstructions();
522
+ return {
523
+ tools: tools.sort((a, b) => a.name.localeCompare(b.name)),
524
+ ...typeof instructions === "string" ? { instructions } : {}
525
+ };
398
526
  } finally {
399
527
  await client.close();
400
528
  }
@@ -406,23 +534,28 @@ function schemaPathFor(configPath) {
406
534
  /**
407
535
  * Write the config and its schema.
408
536
  *
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.
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.
413
542
  */
414
- function writeWrapConfig(configPath, server, tools, previous) {
543
+ function writeWrapConfig(configPath, server, { tools, instructions }, previous) {
415
544
  const schemaPath = schemaPathFor(configPath);
416
- const isNew = (name) => previous?.recorded !== void 0 && !previous.recorded.includes(name);
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
+ };
417
550
  const chosen = (tool) => {
418
- if (!previous) return tool.hint !== "destructive";
419
- if (isNew(tool.name)) return false;
551
+ if (!previous) return tool.hint === "read-only" && tool.warnings.length === 0;
552
+ if (isNew(tool) || isChanged(tool)) return false;
420
553
  const { allow, deny } = previous.options;
421
554
  return allow ? allow.includes(tool.name) : !deny?.includes(tool.name);
422
555
  };
423
556
  const on = tools.filter(chosen);
424
557
  const off = tools.filter((tool) => !chosen(tool));
425
- const note = (tool) => ` // ${tool.hint}${tool.description ? ` · ${clip(tool.description)}` : ""}`;
558
+ const note = (tool) => ` //${tool.warnings.map((warning) => ` ⚠ ${warning} ·`).join("")} ${tool.hint} · ${formatTokens(tool.tokens)} tokens${tool.description ? ` · ${clip(tool.description)}` : ""}`;
426
559
  const entries = [...on.map((tool) => ` ${JSON.stringify(tool.name)},${note(tool)}`), ...off.map((tool) => ` // ${JSON.stringify(tool.name)},${note(tool)}`)];
427
560
  const text = [
428
561
  "{",
@@ -444,14 +577,22 @@ function writeWrapConfig(configPath, server, tools, previous) {
444
577
  ""
445
578
  ].join("\n");
446
579
  writeFileSync(configPath, text);
447
- writeFileSync(schemaPath, `${JSON.stringify(schemaFor(tools), null, 2)}\n`);
580
+ writeFileSync(schemaPath, `${JSON.stringify(schemaFor(tools, instructions), null, 2)}\n`);
448
581
  return {
449
582
  allowed: on.length,
450
583
  commented: off.length,
451
- added: tools.filter((tool) => isNew(tool.name)).length
584
+ added: tools.filter(isNew).length,
585
+ changed: tools.filter(isChanged).length,
586
+ tokens: {
587
+ allowed: sum(on),
588
+ total: sum(tools)
589
+ }
452
590
  };
453
591
  }
454
- function schemaFor(tools) {
592
+ function sum(tools) {
593
+ return tools.reduce((total, tool) => total + tool.tokens, 0);
594
+ }
595
+ function schemaFor(tools, instructions) {
455
596
  const names = {
456
597
  type: "array",
457
598
  items: { $ref: "#/definitions/tool" },
@@ -494,24 +635,51 @@ function schemaFor(tools) {
494
635
  definitions: { tool: tools.length > 0 ? { anyOf: tools.map((tool) => ({
495
636
  const: tool.name,
496
637
  description: note(tool)
497
- })) } : { not: {} } }
638
+ })) } : { not: {} } },
639
+ [RECORDED]: Object.fromEntries([...tools.map((tool) => [tool.name, tool.pin]), ...instructions === void 0 ? [] : [[INSTRUCTIONS, { instructions }]]])
498
640
  };
499
641
  function note(tool) {
500
- const said = tool.description ? `${tool.hint} · ${tool.description}` : tool.hint;
642
+ const head = `${tool.hint} · ${formatTokens(tool.tokens)} tokens`;
643
+ const said = tool.description ? `${head} · ${tool.description}` : head;
501
644
  return tool.params.length > 0 ? `${said}\n\nTakes: ${tool.params.join(", ")}` : said;
502
645
  }
503
646
  }
504
- /** Every tool name the schema beside a config recorded, if it is there. */
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
+ */
505
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;
506
659
  try {
507
- const tool = JSON.parse(readFileSync(schemaPathFor(configPath), "utf8")).definitions?.tool;
508
- return tool ? (tool.anyOf ?? []).map((entry) => entry.const) : void 0;
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);
509
673
  } catch {
510
674
  return;
511
675
  }
512
676
  }
513
- /** Read a config into what `wrap` takes, saying which file is wrong and how. */
514
- function readWrapConfig(configPath) {
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 } = {}) {
515
683
  const fail = (problem) => {
516
684
  throw new Error(`${configPath}: ${problem}`);
517
685
  };
@@ -551,10 +719,14 @@ function readWrapConfig(configPath) {
551
719
  if (!strings(args)) fail("\"server.args\" must be a list of strings.");
552
720
  if (config.allow !== void 0 && config.deny !== void 0) fail("use \"allow\" or \"deny\", not both.");
553
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.`);
554
725
  return {
555
726
  command: config.server.command,
556
727
  args,
557
728
  cwd: resolve(dirname(resolve(configPath)), cwd),
729
+ ...pinned ? { pinned } : {},
558
730
  ...config.allow ? { allow: config.allow } : {},
559
731
  ...config.deny ? { deny: config.deny } : {}
560
732
  };
@@ -568,7 +740,6 @@ function entryFor(configPath) {
568
740
  "-y",
569
741
  "mcp-authz",
570
742
  "wrap",
571
- "--config",
572
743
  resolve(configPath)
573
744
  ]
574
745
  }
@@ -682,10 +853,10 @@ const USAGE = `mcp-authz — inspect a policy without running a server
682
853
  mcp-authz explain <policy.json> --identity <identity.json>|- [--capabilities <map.json>]
683
854
  mcp-authz tools -- <command> [args...]
684
855
  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
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...]
689
860
 
690
861
  Files
691
862
  <policy.json> the object you would hand definePolicy
@@ -697,7 +868,8 @@ Files
697
868
  wrap
698
869
  Runs a stdio MCP server and hides tools from whoever connects. Put it in
699
870
  front of the server in your client's MCP config. With neither flag every
700
- tool passes through; names are exact and comma-separated.
871
+ tool passes through; names are exact and comma-separated. For a remote
872
+ server, wrap the bridge: -- npx -y mcp-remote <url>
701
873
 
702
874
  Exit codes
703
875
  0 fine, warnings included
@@ -716,8 +888,7 @@ function main(argv) {
716
888
  check: { type: "string" },
717
889
  allow: { type: "string" },
718
890
  deny: { type: "string" },
719
- config: { type: "string" },
720
- refresh: { type: "boolean" },
891
+ refresh: { type: "string" },
721
892
  "client-out": { type: "string" },
722
893
  help: {
723
894
  type: "boolean",
@@ -728,6 +899,7 @@ function main(argv) {
728
899
  const [command, policyPath] = positionals;
729
900
  const dashes = argv.indexOf("--");
730
901
  const upstream = dashes === -1 ? [] : argv.slice(dashes + 1);
902
+ const [, ownPath] = positionals.slice(0, positionals.length - upstream.length);
731
903
  if (values.help || !command) {
732
904
  process.stdout.write(USAGE);
733
905
  return values.help ? 0 : 1;
@@ -736,7 +908,10 @@ function main(argv) {
736
908
  if (values.check) return checkWrapConfig(values.check, upstream);
737
909
  return listTools(upstream, values);
738
910
  }
739
- if (command === "wrap") return runWrap(upstream, values);
911
+ if (command === "wrap") return runWrap(upstream, {
912
+ ...values,
913
+ config: ownPath
914
+ });
740
915
  if (command === "record") {
741
916
  if (values.upstream) return record({
742
917
  upstream: values.upstream,
@@ -775,37 +950,59 @@ function main(argv) {
775
950
  }
776
951
  /**
777
952
  * 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.
953
+ * and with --out or --refresh save them as a config `wrap <file>` runs.
779
954
  */
780
955
  function listTools(upstream, flags) {
781
956
  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;
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;
785
960
  const [command, ...args] = saved ? [saved.command, ...saved.args] : upstream;
786
961
  const cwd = saved?.cwd ?? process.cwd();
787
- if (saved) announce(saved, flags.config);
962
+ if (saved) announce(saved, flags.refresh);
788
963
  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;
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;
792
968
  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");
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
+ }
794
979
  if (!target) {
795
980
  process.stderr.write("\nSave these as a config wrap can run: tools --out <name>.jsonc -- ...\n");
796
981
  return 0;
797
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"));
798
991
  const previous = existsSync(target) ? {
799
- options: readWrapConfig(target),
800
- recorded: recordedTools(target)
992
+ options: readWrapConfig(target, { record: false }),
993
+ ...recorded ? { recorded } : {}
801
994
  } : void 0;
802
- const { allowed, commented, added } = writeWrapConfig(target, {
995
+ const { allowed, commented, added, changed, tokens } = writeWrapConfig(target, {
803
996
  command,
804
997
  args,
805
998
  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)}.`];
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
+ ];
809
1006
  if (flags["client-out"]) {
810
1007
  writeClientConfig(flags["client-out"], target);
811
1008
  lines.push(`Added it to ${flags["client-out"]}; give it the env the server needs there.`);
@@ -854,23 +1051,42 @@ async function checkWrapConfig(path, upstream) {
854
1051
  }
855
1052
  const options = readWrapConfig(path);
856
1053
  announce(options, path);
857
- const discovered = await discover(options.command, options.args, options.cwd);
858
- if (discovered === void 0) return 1;
1054
+ const found = await discover(options.command, options.args, options.cwd);
1055
+ if (found === void 0) return 1;
1056
+ const discovered = found.tools;
859
1057
  const live = discovered.map((tool) => tool.name);
860
- const recorded = recordedTools(path);
1058
+ const recorded = options.pinned;
1059
+ const recordedNames = [...recorded.keys()].filter((name) => name !== INSTRUCTIONS);
861
1060
  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`);
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`);
867
1078
  return 0;
868
1079
  }
869
1080
  const lines = [`${path} does not match the server:`, ""];
870
1081
  for (const name of missing) lines.push(` ? ${name}`, ` in "${options.allow ? "allow" : "deny"}", but the server has no such tool`);
871
1082
  for (const name of added) lines.push(` + ${name}`, ` new since last saved; ${options.allow ? "hidden, since it is not in \"allow\"" : "shown"}`);
872
1083
  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.`, "");
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.`, "");
874
1090
  process.stdout.write(lines.join("\n"));
875
1091
  return 1;
876
1092
  }
@@ -878,12 +1094,12 @@ function runWrap(upstream, { allow, deny, config }) {
878
1094
  const refuse = (message) => (process.stderr.write(`${message}\n`), 1);
879
1095
  if (allow !== void 0 && deny !== void 0) return refuse("wrap takes --allow or --deny, not both.");
880
1096
  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.");
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.");
883
1099
  }
884
1100
  const names = (list) => list?.split(",").map((name) => name.trim()).filter(Boolean);
885
1101
  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");
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");
887
1103
  const options = config ? readWrapConfig(config) : {
888
1104
  command,
889
1105
  args,
@@ -940,24 +1156,42 @@ async function record(source, out, against) {
940
1156
  async function drift(live, path) {
941
1157
  const loaded = await import(pathToFileURL(resolve(path)).href);
942
1158
  const priced = Object.keys(loaded.PERMISSIONS ?? {});
943
- const recorded = loaded.FINGERPRINTS ?? {};
1159
+ const recorded = new Map(Object.entries(loaded.DEFINITIONS ?? {}));
944
1160
  const added = live.names.filter((name) => !priced.includes(name));
945
1161
  const removed = priced.filter((name) => !live.names.includes(name));
946
- const changed = live.names.filter((name) => priced.includes(name) && recorded[name] && recorded[name] !== live.fingerprints[name]);
947
- const unbaselined = Object.keys(recorded).length === 0 ? ` — ${path} carries no FINGERPRINTS, so definitions were not compared; re-record to add one` : "";
948
- if (added.length === 0 && removed.length === 0 && changed.length === 0) {
949
- 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`);
950
1169
  return 0;
951
1170
  }
952
1171
  const lines = ["The server no longer matches the recorded capabilities:", ""];
953
1172
  for (const name of added) lines.push(` + ${name}`, " never priced, so nobody decided who may reach it");
954
1173
  for (const name of removed) lines.push(` - ${name}`, " priced here, but the server no longer offers it");
955
- for (const name of changed) lines.push(` ~ ${name}`, " same name, different definition than the one recorded");
956
- 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"));
957
1177
  lines.push("", "Re-record when the change is expected, and review the diff.", "");
958
1178
  process.stdout.write(lines.join("\n"));
959
1179
  return 1;
960
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
+ }
961
1195
  function check(policy, capabilities) {
962
1196
  const lines = [`${policy.roles.size} role${policy.roles.size === 1 ? "" : "s"}`, `${policy.permissions.length} permission${policy.permissions.length === 1 ? "" : "s"}`];
963
1197
  if (capabilities) lines.push(`${capabilities.size} capabilities`);