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