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/README.md +69 -20
- package/dist/cli.js +340 -106
- package/dist/definitions-CZVk-j1C.d.ts +5 -0
- package/dist/definitions-CyIy4YSZ.js +132 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +16 -7
- package/dist/{ladder-CUzOKudC.js → ladder-6TnD3hTJ.js} +3 -1
- package/dist/openapi.d.ts +2 -2
- package/dist/{permissions-module-Cr0K1a7x.d.ts → permissions-module-TOpt20D4.d.ts} +2 -2
- package/dist/proxy.d.ts +13 -0
- package/dist/proxy.js +287 -34
- package/dist/screen-DxoujEpO.js +79 -0
- package/dist/strict-json-DLKOgsGE.js +100 -0
- package/dist/testing.d.ts +8 -3
- package/dist/testing.js +54 -62
- package/dist/{tools-BQE1O-7P.d.ts → tools-C1dZESYK.d.ts} +9 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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,
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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 = (
|
|
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
|
|
419
|
-
if (isNew(tool
|
|
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) => `
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
/**
|
|
514
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 &&
|
|
783
|
-
if (flags.
|
|
784
|
-
const saved = flags.
|
|
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.
|
|
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
|
|
790
|
-
return discover(command, args, cwd).then((
|
|
791
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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
|
-
},
|
|
807
|
-
const summary = previous ? `${allowed} allowed, ${commented} commented out, ${added} new since last saved, left commented out` :
|
|
808
|
-
const lines = [
|
|
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
|
|
858
|
-
if (
|
|
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 =
|
|
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 =
|
|
863
|
-
const removed =
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
|
|
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
|
-
|
|
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
|
|
882
|
-
if (allow !== void 0 || deny !== void 0) return refuse("wrap takes
|
|
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.
|
|
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
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
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
|
|
956
|
-
|
|
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`);
|