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/README.md +98 -16
- package/dist/cli.d.ts +2 -3
- package/dist/cli.js +1060 -16
- package/dist/definitions-CZVk-j1C.d.ts +5 -0
- package/dist/definitions-CyIy4YSZ.js +132 -0
- package/dist/index.d.ts +12 -8
- package/dist/index.js +16 -7
- package/dist/{ladder-CUzOKudC.js → ladder-6TnD3hTJ.js} +3 -1
- package/dist/node.d.ts +3 -4
- package/dist/openapi.d.ts +10 -11
- package/dist/{permissions-module-DxCHuE-N.d.ts → permissions-module-TOpt20D4.d.ts} +5 -5
- package/dist/proxy.d.ts +16 -4
- 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 +12 -7
- package/dist/testing.js +54 -62
- package/dist/{tools-BQE1O-7P.d.ts → tools-C1dZESYK.d.ts} +9 -2
- package/package.json +10 -12
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
146
|
-
|
|
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`);
|