@odingard/guardian-mcp 0.3.2 → 0.3.4
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 +138 -9
- package/dist/index.js +766 -150
- package/package.json +7 -5
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
5
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
7
|
import {
|
|
@@ -18,10 +16,21 @@ var USAGE = `guardian-mcp \u2014 route an agent's tool calls through Guardian (A
|
|
|
18
16
|
Usage:
|
|
19
17
|
guardian-mcp Guardian tools (guardian_invoke_tool, guardian_claim, guardian_receipt)
|
|
20
18
|
guardian-mcp proxy -- <command> [args...] Govern every tool of an existing stdio MCP server
|
|
19
|
+
guardian-mcp proxy --url <https-url> [--transport streamable-http|sse] [--header "Name: value"]...
|
|
20
|
+
Govern every tool of a remote MCP server (Streamable
|
|
21
|
+
HTTP by default, or SSE). The agent still talks stdio.
|
|
21
22
|
guardian-mcp tools [--json] -- <command> [args...]
|
|
23
|
+
guardian-mcp tools [--json] --url <https-url> [--transport ...] [--header ...]
|
|
22
24
|
List a server's tools with a suggested Guardian mode
|
|
23
25
|
for each (no Guardian credential needed)
|
|
24
26
|
|
|
27
|
+
Remote upstreams:
|
|
28
|
+
Write secrets as \${ENV_VAR} references, e.g. --header 'x-api-key: \${COMPOSIO_API_KEY}'
|
|
29
|
+
(single quotes in a shell, so the key never appears in the process list; JSON configs need
|
|
30
|
+
no quoting). guardian-mcp expands them itself, refuses a missing variable and any
|
|
31
|
+
GUARDIAN_* variable, and never prints the URL or header values. The URL must be https://
|
|
32
|
+
(plain http:// only for localhost).
|
|
33
|
+
|
|
25
34
|
Environment:
|
|
26
35
|
GUARDIAN_BASE_URL required, e.g. https://guardian-api.odingard.com
|
|
27
36
|
GUARDIAN_AGENT_CREDENTIAL required, the agent credential (gac1.\u2026) from the Guardian console
|
|
@@ -31,8 +40,9 @@ Environment:
|
|
|
31
40
|
use (mcp.install). Default off: register the tools once in the
|
|
32
41
|
console as "My own code (record-only)" and allow them for the agent
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
Examples (proxy):
|
|
35
44
|
guardian-mcp proxy -- npx -y @modelcontextprotocol/server-filesystem ./data
|
|
45
|
+
guardian-mcp proxy --url https://mcp.example.com/mcp --header 'Authorization: Bearer \${REMOTE_MCP_API_KEY}'
|
|
36
46
|
`;
|
|
37
47
|
function parseCli(argv, env) {
|
|
38
48
|
let mode = { kind: "dispatch" };
|
|
@@ -41,39 +51,22 @@ function parseCli(argv, env) {
|
|
|
41
51
|
}
|
|
42
52
|
if (argv[0] === "tools") {
|
|
43
53
|
const json = argv[1] === "--json";
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const [command, ...args] = rest;
|
|
47
|
-
if (!command) {
|
|
48
|
-
return {
|
|
49
|
-
ok: false,
|
|
50
|
-
message: `guardian-mcp tools: missing the MCP server command.
|
|
51
|
-
|
|
52
|
-
${USAGE}`
|
|
53
|
-
};
|
|
54
|
-
}
|
|
54
|
+
const target = parseTarget("tools", argv.slice(json ? 2 : 1), env);
|
|
55
|
+
if (!target.ok) return target;
|
|
55
56
|
return {
|
|
56
57
|
ok: true,
|
|
57
58
|
config: {
|
|
58
59
|
baseUrl: "",
|
|
59
60
|
credential: "",
|
|
60
61
|
autoRegister: false,
|
|
61
|
-
mode: { kind: "tools",
|
|
62
|
+
mode: { kind: "tools", json, ...target.target }
|
|
62
63
|
}
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
if (argv[0] === "proxy") {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
ok: false,
|
|
71
|
-
message: `guardian-mcp proxy: missing the upstream MCP server command.
|
|
72
|
-
|
|
73
|
-
${USAGE}`
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
mode = { kind: "proxy", command, args };
|
|
67
|
+
const target = parseTarget("proxy", argv.slice(1), env);
|
|
68
|
+
if (!target.ok) return target;
|
|
69
|
+
mode = { kind: "proxy", ...target.target };
|
|
77
70
|
} else if (argv.length > 0) {
|
|
78
71
|
return {
|
|
79
72
|
ok: false,
|
|
@@ -103,7 +96,7 @@ ${USAGE}`
|
|
|
103
96
|
credential: env.GUARDIAN_AGENT_CREDENTIAL,
|
|
104
97
|
...env.GUARDIAN_CONNECTION_ID ? { connectionId: env.GUARDIAN_CONNECTION_ID } : {},
|
|
105
98
|
...env.GUARDIAN_ADAPTER_ID ? { adapterId: env.GUARDIAN_ADAPTER_ID } : {},
|
|
106
|
-
//
|
|
99
|
+
// Off by default — production denies first-use registration
|
|
107
100
|
// unless the grant pins mcp.install. Opt in with GUARDIAN_AUTO_REGISTER=true.
|
|
108
101
|
autoRegister: /^(true|1|yes|on)$/i.test(env.GUARDIAN_AUTO_REGISTER ?? ""),
|
|
109
102
|
mode
|
|
@@ -118,6 +111,168 @@ function upstreamEnv(env) {
|
|
|
118
111
|
}
|
|
119
112
|
return out;
|
|
120
113
|
}
|
|
114
|
+
var ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
115
|
+
var HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
116
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
117
|
+
function expandEnv(cmd, value, env, where) {
|
|
118
|
+
for (const match of value.matchAll(ENV_REF)) {
|
|
119
|
+
const name = match[1];
|
|
120
|
+
if (name.startsWith("GUARDIAN_")) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
message: `guardian-mcp ${cmd}: ${where} refers to \${${name}}; Guardian settings are never sent to the upstream server.`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (!env[name]) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
message: `guardian-mcp ${cmd}: ${where} refers to \${${name}}, which is not set.`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
value: value.replace(ENV_REF, (_, name) => env[name])
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function parseTarget(cmd, argv, env) {
|
|
139
|
+
const fail = (message) => ({
|
|
140
|
+
ok: false,
|
|
141
|
+
message: `guardian-mcp ${cmd}: ${message}
|
|
142
|
+
|
|
143
|
+
${USAGE}`
|
|
144
|
+
});
|
|
145
|
+
if (argv[0] === "--") argv = argv.slice(1);
|
|
146
|
+
else if (argv[0]?.startsWith("--")) return parseRemote(cmd, argv, env, fail);
|
|
147
|
+
const [command, ...args] = argv;
|
|
148
|
+
if (!command) {
|
|
149
|
+
return fail(
|
|
150
|
+
cmd === "proxy" ? "missing the upstream MCP server command (or --url for a remote server)." : "missing the MCP server command (or --url for a remote server)."
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, target: { command, args } };
|
|
154
|
+
}
|
|
155
|
+
function parseRemote(cmd, argv, env, fail) {
|
|
156
|
+
let rawUrl;
|
|
157
|
+
let transport = "streamable-http";
|
|
158
|
+
const rawHeaders = [];
|
|
159
|
+
for (let i = 0; i < argv.length; i++) {
|
|
160
|
+
const arg = argv[i];
|
|
161
|
+
const eq = arg.indexOf("=");
|
|
162
|
+
const flag = arg.startsWith("--") && eq > 0 ? arg.slice(0, eq) : arg;
|
|
163
|
+
let value;
|
|
164
|
+
if (flag !== arg) value = arg.slice(eq + 1);
|
|
165
|
+
else if (flag === "--url" || flag === "--transport" || flag === "--header") {
|
|
166
|
+
value = argv[++i];
|
|
167
|
+
}
|
|
168
|
+
if (flag === "--url") {
|
|
169
|
+
if (rawUrl !== void 0) return fail("--url given more than once.");
|
|
170
|
+
if (!value) return fail("--url needs a value.");
|
|
171
|
+
rawUrl = value;
|
|
172
|
+
} else if (flag === "--transport") {
|
|
173
|
+
if (value !== "streamable-http" && value !== "sse" && value !== "http") {
|
|
174
|
+
return fail('--transport must be "streamable-http" or "sse".');
|
|
175
|
+
}
|
|
176
|
+
transport = value === "sse" ? "sse" : "streamable-http";
|
|
177
|
+
} else if (flag === "--header") {
|
|
178
|
+
if (!value) return fail("--header needs a value.");
|
|
179
|
+
rawHeaders.push(value);
|
|
180
|
+
} else if (flag === "--") {
|
|
181
|
+
return fail("give either --url or a command after --, not both.");
|
|
182
|
+
} else {
|
|
183
|
+
return fail(
|
|
184
|
+
`unknown option at position ${i + 1} (expected --url, --transport or --header).`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!rawUrl) return fail("--url is required with --transport or --header.");
|
|
189
|
+
const expandedUrl = expandEnv(cmd, rawUrl, env, "--url");
|
|
190
|
+
if (!expandedUrl.ok) return expandedUrl;
|
|
191
|
+
let url;
|
|
192
|
+
try {
|
|
193
|
+
url = new URL(expandedUrl.value);
|
|
194
|
+
} catch {
|
|
195
|
+
return fail("--url is not a valid URL.");
|
|
196
|
+
}
|
|
197
|
+
if (url.username || url.password) {
|
|
198
|
+
return fail(
|
|
199
|
+
"--url must not carry a user name or password; send credentials with --header."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
|
|
203
|
+
return fail(
|
|
204
|
+
"--url must be https:// (plain http:// is accepted only for localhost)."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const headers = {};
|
|
208
|
+
for (const [n, raw] of rawHeaders.entries()) {
|
|
209
|
+
const colon = raw.indexOf(":");
|
|
210
|
+
const name = colon > 0 ? raw.slice(0, colon).trim() : "";
|
|
211
|
+
if (!name || !HEADER_NAME.test(name)) {
|
|
212
|
+
return fail(`--header #${n + 1} must look like "Name: value".`);
|
|
213
|
+
}
|
|
214
|
+
const expanded = expandEnv(
|
|
215
|
+
cmd,
|
|
216
|
+
raw.slice(colon + 1).trim(),
|
|
217
|
+
env,
|
|
218
|
+
`--header ${name}`
|
|
219
|
+
);
|
|
220
|
+
if (!expanded.ok) return expanded;
|
|
221
|
+
if (!expanded.value) return fail(`--header ${name} has an empty value.`);
|
|
222
|
+
if (/[\r\n]/.test(expanded.value))
|
|
223
|
+
return fail(`--header ${name} contains a line break.`);
|
|
224
|
+
headers[name] = expanded.value;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
target: { remote: { url: url.href, transport, headers } }
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function secretsOf(target) {
|
|
232
|
+
if (!("remote" in target)) return [];
|
|
233
|
+
const { url, headers } = target.remote;
|
|
234
|
+
const out = /* @__PURE__ */ new Set([url]);
|
|
235
|
+
const parsed2 = new URL(url);
|
|
236
|
+
if (parsed2.search) out.add(parsed2.search.slice(1));
|
|
237
|
+
for (const v of parsed2.searchParams.values()) if (v.length >= 4) out.add(v);
|
|
238
|
+
for (const pair of parsed2.search.slice(1).split("&")) {
|
|
239
|
+
const raw = pair.slice(pair.indexOf("=") + 1);
|
|
240
|
+
if (pair.indexOf("=") < 0 || raw.length < 4) continue;
|
|
241
|
+
const forms = [raw, raw.replace(/\+/g, " ")];
|
|
242
|
+
for (const form of [...forms]) {
|
|
243
|
+
try {
|
|
244
|
+
forms.push(decodeURIComponent(form));
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const form of forms) if (form.length >= 4) out.add(form);
|
|
249
|
+
}
|
|
250
|
+
for (const segment of parsed2.pathname.split("/")) {
|
|
251
|
+
if (segment.length >= 16 || segment.length >= 8 && /\d/.test(segment)) {
|
|
252
|
+
out.add(segment);
|
|
253
|
+
out.add(decodeURIComponent(segment));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const value of Object.values(headers)) {
|
|
257
|
+
out.add(value);
|
|
258
|
+
const parts = value.split(/\s+/);
|
|
259
|
+
for (const part of parts) if (part.length >= 8) out.add(part);
|
|
260
|
+
}
|
|
261
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
262
|
+
}
|
|
263
|
+
function redact(text, secrets) {
|
|
264
|
+
let out = text;
|
|
265
|
+
for (const secret of secrets) {
|
|
266
|
+
if (secret) out = out.split(secret).join("[redacted]");
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
function describeTarget(target) {
|
|
271
|
+
if ("remote" in target) {
|
|
272
|
+
return `${new URL(target.remote.url).origin} (${target.remote.transport})`;
|
|
273
|
+
}
|
|
274
|
+
return [target.command, ...target.args].join(" ");
|
|
275
|
+
}
|
|
121
276
|
|
|
122
277
|
// src/dispatch.ts
|
|
123
278
|
var TOOLS = [
|
|
@@ -165,11 +320,26 @@ var TOOLS = [
|
|
|
165
320
|
}
|
|
166
321
|
];
|
|
167
322
|
function jsonResult(payload) {
|
|
168
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
325
|
+
};
|
|
169
326
|
}
|
|
170
327
|
function errorResult(message) {
|
|
171
328
|
return { isError: true, content: [{ type: "text", text: message }] };
|
|
172
329
|
}
|
|
330
|
+
function jsonError(payload) {
|
|
331
|
+
return { ...jsonResult(payload), isError: true };
|
|
332
|
+
}
|
|
333
|
+
var CHECK_FIRST = "Call guardian_receipt with this requestId, or check the target system, before retrying.";
|
|
334
|
+
function denied(reasonCode, requestId) {
|
|
335
|
+
const code = reasonCode ?? "DENIED";
|
|
336
|
+
return jsonError({
|
|
337
|
+
decision: "DENY",
|
|
338
|
+
reasonCode: code,
|
|
339
|
+
...requestId ? { requestId } : {},
|
|
340
|
+
message: code === "SERVICE_UNAVAILABLE" ? "Guardian could not be reached, so nothing was done. Try again later." : "Guardian denied this call. Nothing was done. Do not retry the same call."
|
|
341
|
+
});
|
|
342
|
+
}
|
|
173
343
|
async function requestAndExecute(client2, result) {
|
|
174
344
|
if (!result.ok) {
|
|
175
345
|
const heldRequestId = result.requestId;
|
|
@@ -181,33 +351,62 @@ async function requestAndExecute(client2, result) {
|
|
|
181
351
|
next: "Held for human approval. Call guardian_claim with this requestId after approval."
|
|
182
352
|
});
|
|
183
353
|
}
|
|
184
|
-
return
|
|
354
|
+
return denied(
|
|
355
|
+
result.reasonCode,
|
|
356
|
+
typeof heldRequestId === "string" && heldRequestId.length > 0 ? heldRequestId : void 0
|
|
357
|
+
);
|
|
185
358
|
}
|
|
186
359
|
if (result.decision === "ALLOW" && result.capabilityToken) {
|
|
187
360
|
const executed = await client2.execute({
|
|
188
361
|
requestId: result.requestId,
|
|
189
362
|
capabilityToken: result.capabilityToken
|
|
190
363
|
});
|
|
191
|
-
|
|
192
|
-
|
|
364
|
+
if (!executed.ok) {
|
|
365
|
+
return jsonError({
|
|
366
|
+
decision: "ALLOW",
|
|
367
|
+
requestId: result.requestId,
|
|
368
|
+
outcome: "unknown",
|
|
369
|
+
executionFailed: executed.reasonCode,
|
|
370
|
+
message: `Guardian could not confirm the outcome (${executed.reasonCode}). The call may have been done. ${CHECK_FIRST}`
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const status = executed.receipt?.status;
|
|
374
|
+
if (status === "sent-verified") {
|
|
375
|
+
return jsonResult({
|
|
193
376
|
decision: "ALLOW",
|
|
194
377
|
requestId: result.requestId,
|
|
195
378
|
receipt: executed.receipt
|
|
196
|
-
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (status === "not-sent") {
|
|
382
|
+
return jsonError({
|
|
197
383
|
decision: "ALLOW",
|
|
198
384
|
requestId: result.requestId,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
385
|
+
outcome: "not-done",
|
|
386
|
+
receipt: executed.receipt,
|
|
387
|
+
message: "Guardian did not send the call (receipt status not-sent). It was not done."
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return jsonError({
|
|
391
|
+
decision: "ALLOW",
|
|
392
|
+
requestId: result.requestId,
|
|
393
|
+
outcome: "unknown",
|
|
394
|
+
receipt: executed.receipt,
|
|
395
|
+
message: `The outcome is unknown (receipt status ${typeof status === "string" ? status : "missing"}). The call may have been done. ${CHECK_FIRST}`
|
|
396
|
+
});
|
|
202
397
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
398
|
+
if (result.decision === "ASK") {
|
|
399
|
+
return jsonResult({
|
|
400
|
+
decision: "ASK",
|
|
401
|
+
requestId: result.requestId,
|
|
402
|
+
...result.reasonCode ? { reasonCode: result.reasonCode } : {},
|
|
208
403
|
message: "Held for human approval. Call guardian_claim with this requestId after approval."
|
|
209
|
-
}
|
|
210
|
-
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return denied(
|
|
407
|
+
result.reasonCode ?? (result.decision === "DENY" ? void 0 : "MISSING_CAPABILITY"),
|
|
408
|
+
result.requestId
|
|
409
|
+
);
|
|
211
410
|
}
|
|
212
411
|
async function handleToolCall(options, name, arguments_) {
|
|
213
412
|
const { client: client2, defaultConnectionId, adapterId } = options;
|
|
@@ -267,12 +466,85 @@ var READ_VERBS = /* @__PURE__ */ new Set([
|
|
|
267
466
|
"find",
|
|
268
467
|
"lookup",
|
|
269
468
|
"count",
|
|
270
|
-
"inspect"
|
|
271
|
-
|
|
272
|
-
|
|
469
|
+
"inspect"
|
|
470
|
+
]);
|
|
471
|
+
var WRITE_VERBS = /* @__PURE__ */ new Set([
|
|
472
|
+
"add",
|
|
473
|
+
"append",
|
|
474
|
+
"apply",
|
|
475
|
+
"approve",
|
|
476
|
+
"archive",
|
|
477
|
+
"assign",
|
|
478
|
+
"buy",
|
|
479
|
+
"cancel",
|
|
480
|
+
"charge",
|
|
481
|
+
"clear",
|
|
482
|
+
"close",
|
|
483
|
+
"commit",
|
|
484
|
+
"copy",
|
|
485
|
+
"create",
|
|
486
|
+
"delete",
|
|
487
|
+
"deploy",
|
|
488
|
+
"destroy",
|
|
489
|
+
"disable",
|
|
490
|
+
"drop",
|
|
491
|
+
"edit",
|
|
492
|
+
"enable",
|
|
493
|
+
"exec",
|
|
494
|
+
"execute",
|
|
495
|
+
"fork",
|
|
496
|
+
"grant",
|
|
497
|
+
"import",
|
|
498
|
+
"insert",
|
|
499
|
+
"install",
|
|
500
|
+
"invite",
|
|
501
|
+
"kill",
|
|
502
|
+
"lock",
|
|
503
|
+
"merge",
|
|
504
|
+
"modify",
|
|
505
|
+
"move",
|
|
506
|
+
"notify",
|
|
507
|
+
"patch",
|
|
508
|
+
"pay",
|
|
509
|
+
"post",
|
|
510
|
+
"publish",
|
|
511
|
+
"purge",
|
|
512
|
+
"push",
|
|
513
|
+
"put",
|
|
514
|
+
"refund",
|
|
515
|
+
"remove",
|
|
516
|
+
"rename",
|
|
517
|
+
"replace",
|
|
518
|
+
"reply",
|
|
519
|
+
"reset",
|
|
520
|
+
"restart",
|
|
521
|
+
"restore",
|
|
522
|
+
"revoke",
|
|
523
|
+
"rm",
|
|
524
|
+
"run",
|
|
525
|
+
"save",
|
|
526
|
+
"schedule",
|
|
527
|
+
"sell",
|
|
528
|
+
"send",
|
|
529
|
+
"set",
|
|
530
|
+
"share",
|
|
531
|
+
"sign",
|
|
532
|
+
"start",
|
|
533
|
+
"stop",
|
|
534
|
+
"submit",
|
|
535
|
+
"sync",
|
|
536
|
+
"transfer",
|
|
537
|
+
"trigger",
|
|
538
|
+
"truncate",
|
|
539
|
+
"uninstall",
|
|
540
|
+
"unlock",
|
|
541
|
+
"update",
|
|
542
|
+
"upload",
|
|
543
|
+
"upsert",
|
|
544
|
+
"write"
|
|
273
545
|
]);
|
|
274
|
-
function
|
|
275
|
-
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).
|
|
546
|
+
function words(name) {
|
|
547
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
276
548
|
}
|
|
277
549
|
function suggestTool(tool) {
|
|
278
550
|
const governable = GUARDIAN_TOOL_NAME.test(tool.name) && tool.name.length <= 64;
|
|
@@ -285,6 +557,16 @@ function suggestTool(tool) {
|
|
|
285
557
|
governable
|
|
286
558
|
};
|
|
287
559
|
}
|
|
560
|
+
const tokens = words(tool.name);
|
|
561
|
+
const writeVerb = tokens.find((token) => WRITE_VERBS.has(token));
|
|
562
|
+
if (writeVerb) {
|
|
563
|
+
return {
|
|
564
|
+
name: tool.name,
|
|
565
|
+
suggestedMode: "ask",
|
|
566
|
+
why: `"${writeVerb}" may change something`,
|
|
567
|
+
governable
|
|
568
|
+
};
|
|
569
|
+
}
|
|
288
570
|
if (annotations.readOnlyHint === true) {
|
|
289
571
|
return {
|
|
290
572
|
name: tool.name,
|
|
@@ -293,7 +575,7 @@ function suggestTool(tool) {
|
|
|
293
575
|
governable
|
|
294
576
|
};
|
|
295
577
|
}
|
|
296
|
-
const verb =
|
|
578
|
+
const verb = tokens[0] ?? "";
|
|
297
579
|
if (READ_VERBS.has(verb)) {
|
|
298
580
|
return {
|
|
299
581
|
name: tool.name,
|
|
@@ -309,7 +591,7 @@ function suggestTool(tool) {
|
|
|
309
591
|
governable
|
|
310
592
|
};
|
|
311
593
|
}
|
|
312
|
-
function renderSuggestions(serverCommand, tools) {
|
|
594
|
+
function renderSuggestions(serverCommand, tools, remote = false) {
|
|
313
595
|
const rows = tools.map(suggestTool);
|
|
314
596
|
const width = Math.max(4, ...rows.map((r) => r.name.length));
|
|
315
597
|
const lines = [
|
|
@@ -322,7 +604,7 @@ function renderSuggestions(serverCommand, tools) {
|
|
|
322
604
|
"",
|
|
323
605
|
"Register these in the Guardian console (Connections \u2192 Register a tool server \u2192",
|
|
324
606
|
"record-only), then set each mode for your agent. Run the server through",
|
|
325
|
-
"`guardian-mcp proxy -- <same command>` so every call is decided first."
|
|
607
|
+
remote ? "`guardian-mcp proxy --url <same url> --header \u2026` so every call is decided first." : "`guardian-mcp proxy -- <same command>` so every call is decided first."
|
|
326
608
|
];
|
|
327
609
|
const ungovernable = rows.filter((r) => !r.governable);
|
|
328
610
|
if (ungovernable.length > 0) {
|
|
@@ -349,6 +631,20 @@ import {
|
|
|
349
631
|
} from "@odingard/guardian-sdk";
|
|
350
632
|
var CLAIM_TOOL = "guardian_claim";
|
|
351
633
|
var RECEIPT_TOOL = "guardian_receipt";
|
|
634
|
+
var GUARDIAN_RESULT_META = "com.odingard/guardian";
|
|
635
|
+
var UNAVAILABLE = "SERVICE_UNAVAILABLE";
|
|
636
|
+
function unreachableResult(toolName, requestId) {
|
|
637
|
+
return jsonResult2(
|
|
638
|
+
{
|
|
639
|
+
decision: "DENY",
|
|
640
|
+
toolName,
|
|
641
|
+
reasonCode: UNAVAILABLE,
|
|
642
|
+
...requestId ? { requestId } : {},
|
|
643
|
+
message: `Guardian could not be reached, so ${toolName} was not checked and nothing was forwarded. Try again later.`
|
|
644
|
+
},
|
|
645
|
+
true
|
|
646
|
+
);
|
|
647
|
+
}
|
|
352
648
|
var PROXY_GOVERNANCE_TOOLS = [
|
|
353
649
|
{
|
|
354
650
|
name: CLAIM_TOOL,
|
|
@@ -375,10 +671,93 @@ function jsonResult2(payload, isError = false) {
|
|
|
375
671
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
376
672
|
};
|
|
377
673
|
}
|
|
674
|
+
function withRequestId(result, requestId) {
|
|
675
|
+
if (!requestId) return result;
|
|
676
|
+
const meta = typeof result._meta === "object" && result._meta !== null ? result._meta : {};
|
|
677
|
+
return {
|
|
678
|
+
...result,
|
|
679
|
+
_meta: { ...meta, [GUARDIAN_RESULT_META]: { requestId } }
|
|
680
|
+
};
|
|
681
|
+
}
|
|
378
682
|
function isDecisionResult(value) {
|
|
379
683
|
return typeof value === "object" && value !== null && "guardian" in value && typeof value.guardian === "object";
|
|
380
684
|
}
|
|
381
|
-
|
|
685
|
+
var UpstreamCallFailed = class extends Error {
|
|
686
|
+
constructor(toolName) {
|
|
687
|
+
super("upstream call failed");
|
|
688
|
+
this.toolName = toolName;
|
|
689
|
+
this.name = "UpstreamCallFailed";
|
|
690
|
+
}
|
|
691
|
+
toolName;
|
|
692
|
+
};
|
|
693
|
+
var UPSTREAM_RESULT = /* @__PURE__ */ Symbol("guardian-mcp.upstreamResult");
|
|
694
|
+
function upstreamRan(value) {
|
|
695
|
+
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, UPSTREAM_RESULT);
|
|
696
|
+
}
|
|
697
|
+
var UpstreamCallCancelled = class extends Error {
|
|
698
|
+
constructor(toolName) {
|
|
699
|
+
super("the call was cancelled before it was forwarded");
|
|
700
|
+
this.toolName = toolName;
|
|
701
|
+
this.name = "UpstreamCallCancelled";
|
|
702
|
+
}
|
|
703
|
+
toolName;
|
|
704
|
+
};
|
|
705
|
+
var UpstreamListFailed = class extends Error {
|
|
706
|
+
constructor() {
|
|
707
|
+
super(
|
|
708
|
+
"guardian-mcp: the upstream MCP server's tool list is unavailable; no tools are served."
|
|
709
|
+
);
|
|
710
|
+
this.name = "UpstreamListFailed";
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
function callUpstream(upstream, name, args, signal, forwarding) {
|
|
714
|
+
return async () => {
|
|
715
|
+
if (signal?.aborted) {
|
|
716
|
+
throw new UpstreamCallCancelled(name);
|
|
717
|
+
}
|
|
718
|
+
forwarding.sent = true;
|
|
719
|
+
try {
|
|
720
|
+
return {
|
|
721
|
+
[UPSTREAM_RESULT]: await upstream.callTool(
|
|
722
|
+
name,
|
|
723
|
+
args,
|
|
724
|
+
signal ? { signal } : void 0
|
|
725
|
+
)
|
|
726
|
+
};
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (err instanceof UpstreamCallCancelled) {
|
|
729
|
+
forwarding.sent = false;
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
throw new UpstreamCallFailed(name);
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function cancelledResult(toolName, requestId) {
|
|
737
|
+
return jsonResult2(
|
|
738
|
+
{
|
|
739
|
+
toolName,
|
|
740
|
+
...requestId ? { requestId } : {},
|
|
741
|
+
cancelled: true,
|
|
742
|
+
message: requestId ? `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded; the approved call is still held, so call ${CLAIM_TOOL} with this requestId to forward it.` : `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded.`
|
|
743
|
+
},
|
|
744
|
+
true
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
function failure(err, toolName, forwarding) {
|
|
748
|
+
if (err instanceof UpstreamCallCancelled) {
|
|
749
|
+
return cancelledResult(toolName);
|
|
750
|
+
}
|
|
751
|
+
if (err instanceof UpstreamCallFailed) {
|
|
752
|
+
return jsonResult2(
|
|
753
|
+
{
|
|
754
|
+
decision: "ALLOW",
|
|
755
|
+
toolName,
|
|
756
|
+
error: "Guardian allowed the call but the upstream MCP server did not return a result. It may or may not have run; check before retrying. The capability was not redeemed."
|
|
757
|
+
},
|
|
758
|
+
true
|
|
759
|
+
);
|
|
760
|
+
}
|
|
382
761
|
if (err instanceof GuardianExecutionFailed) {
|
|
383
762
|
return jsonResult2(
|
|
384
763
|
{
|
|
@@ -387,12 +766,16 @@ function failure(err, toolName) {
|
|
|
387
766
|
requestId: err.requestId,
|
|
388
767
|
executionFailed: err.reasonCode,
|
|
389
768
|
localEffectRan: err.localEffectRan,
|
|
390
|
-
|
|
769
|
+
...err.outcomeUnknown ? { outcomeUnknown: true } : {},
|
|
770
|
+
message: err.outcomeUnknown ? err.localEffectRan ? `The upstream call ran, but Guardian could not confirm that it was recorded. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : `Guardian could not confirm whether the call completed. It may have run. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : err.localEffectRan ? "The upstream call ran but Guardian refused to redeem the capability. Do not call it again. Treat this as an incident, not a success." : "Guardian could not redeem the capability; the call was not completed."
|
|
391
771
|
},
|
|
392
772
|
true
|
|
393
773
|
);
|
|
394
774
|
}
|
|
395
775
|
if (err instanceof GuardianDenied) {
|
|
776
|
+
if (err.reasonCode === UNAVAILABLE) {
|
|
777
|
+
return unreachableResult(toolName, err.requestId);
|
|
778
|
+
}
|
|
396
779
|
return jsonResult2(
|
|
397
780
|
{
|
|
398
781
|
decision: "DENY",
|
|
@@ -417,17 +800,28 @@ function failure(err, toolName) {
|
|
|
417
800
|
return jsonResult2(
|
|
418
801
|
{
|
|
419
802
|
toolName,
|
|
420
|
-
error: "Guardian proxy error; the call was not forwarded."
|
|
803
|
+
error: forwarding.sent ? "Guardian proxy error after the call was forwarded. It may have run; check before retrying." : "Guardian proxy error; the call was not forwarded."
|
|
421
804
|
},
|
|
422
805
|
true
|
|
423
806
|
);
|
|
424
807
|
}
|
|
808
|
+
var CLAIMED_REQUESTS_LIMIT = 1e3;
|
|
425
809
|
function createGovernedProxy(options) {
|
|
426
810
|
const { upstream, log = () => {
|
|
427
811
|
} } = options;
|
|
428
812
|
const toolInvokeRequestIds = /* @__PURE__ */ new Set();
|
|
429
813
|
const pendingCalls = /* @__PURE__ */ new Map();
|
|
430
814
|
const pendingInstalls = /* @__PURE__ */ new Map();
|
|
815
|
+
const claimedCapabilities = /* @__PURE__ */ new Map();
|
|
816
|
+
const claimedRequests = /* @__PURE__ */ new Map();
|
|
817
|
+
const rememberClaim = (requestId, entry) => {
|
|
818
|
+
claimedRequests.delete(requestId);
|
|
819
|
+
claimedRequests.set(requestId, entry);
|
|
820
|
+
while (claimedRequests.size > CLAIMED_REQUESTS_LIMIT) {
|
|
821
|
+
const oldest = claimedRequests.keys().next().value;
|
|
822
|
+
claimedRequests.delete(oldest);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
431
825
|
const recordingClient = {
|
|
432
826
|
...options.client,
|
|
433
827
|
async request(invocation) {
|
|
@@ -449,6 +843,26 @@ function createGovernedProxy(options) {
|
|
|
449
843
|
...options.adapterId ? { adapterId: options.adapterId } : {},
|
|
450
844
|
...options.agentName ? { agentName: options.agentName } : {}
|
|
451
845
|
});
|
|
846
|
+
ctx.installed ??= /* @__PURE__ */ new Set();
|
|
847
|
+
function heldSetupStep(toolName, requestId) {
|
|
848
|
+
const held = ctx.heldSetup?.get(`${options.connectionId}:${toolName}`);
|
|
849
|
+
return held?.requestId === requestId ? held.step : void 0;
|
|
850
|
+
}
|
|
851
|
+
function tracked() {
|
|
852
|
+
let allowed;
|
|
853
|
+
return {
|
|
854
|
+
ctx: {
|
|
855
|
+
...ctx,
|
|
856
|
+
options: {
|
|
857
|
+
...ctx.options,
|
|
858
|
+
onReceipt: (event) => {
|
|
859
|
+
allowed = event.requestId;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
allowedRequestId: () => allowed
|
|
864
|
+
};
|
|
865
|
+
}
|
|
452
866
|
let upstreamTools = null;
|
|
453
867
|
async function loadTools() {
|
|
454
868
|
if (upstreamTools) return upstreamTools;
|
|
@@ -465,8 +879,19 @@ function createGovernedProxy(options) {
|
|
|
465
879
|
upstreamTools = tools;
|
|
466
880
|
return tools;
|
|
467
881
|
}
|
|
468
|
-
function toCallResult(toolName, args, output) {
|
|
469
|
-
if (
|
|
882
|
+
function toCallResult(toolName, args, output, allowedRequestId) {
|
|
883
|
+
if (upstreamRan(output)) {
|
|
884
|
+
return withRequestId(output[UPSTREAM_RESULT], allowedRequestId);
|
|
885
|
+
}
|
|
886
|
+
if (!isDecisionResult(output)) {
|
|
887
|
+
return jsonResult2(
|
|
888
|
+
{
|
|
889
|
+
toolName,
|
|
890
|
+
error: "Guardian proxy error; the call was not forwarded."
|
|
891
|
+
},
|
|
892
|
+
true
|
|
893
|
+
);
|
|
894
|
+
}
|
|
470
895
|
const g = output.guardian;
|
|
471
896
|
if (g.decision === "ASK") {
|
|
472
897
|
if (toolInvokeRequestIds.has(g.requestId)) {
|
|
@@ -479,37 +904,92 @@ function createGovernedProxy(options) {
|
|
|
479
904
|
});
|
|
480
905
|
}
|
|
481
906
|
pendingInstalls.set(g.requestId, toolName);
|
|
907
|
+
const step = heldSetupStep(toolName, g.requestId);
|
|
482
908
|
return jsonResult2({
|
|
483
909
|
decision: "ASK",
|
|
484
910
|
toolName,
|
|
485
911
|
requestId: g.requestId,
|
|
486
|
-
message: `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
|
|
912
|
+
message: step === "enable" ? `Switching on ${toolName} for this agent needs a human to approve it in Guardian. After approval call ${CLAIM_TOOL} with this requestId (or retry the original call).` : `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
|
|
487
913
|
});
|
|
488
914
|
}
|
|
489
915
|
if (g.decision === "DENY") {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
916
|
+
if (g.reasonCode === UNAVAILABLE) {
|
|
917
|
+
return unreachableResult(toolName, g.requestId);
|
|
918
|
+
}
|
|
919
|
+
return jsonResult2(
|
|
920
|
+
{
|
|
921
|
+
decision: "DENY",
|
|
922
|
+
toolName,
|
|
923
|
+
reasonCode: g.reasonCode,
|
|
924
|
+
...g.requestId ? { requestId: g.requestId } : {},
|
|
925
|
+
message: g.message
|
|
926
|
+
},
|
|
927
|
+
true
|
|
928
|
+
);
|
|
497
929
|
}
|
|
498
930
|
return jsonResult2({ decision: "ALLOW", toolName, requestId: g.requestId });
|
|
499
931
|
}
|
|
500
|
-
|
|
932
|
+
function alreadyClaimed(requestId) {
|
|
933
|
+
const seen = claimedRequests.get(requestId);
|
|
934
|
+
if (!seen) return void 0;
|
|
935
|
+
if (seen.state === "forwarding" || !seen.result) {
|
|
936
|
+
return jsonResult2(
|
|
937
|
+
{
|
|
938
|
+
toolName: seen.toolName,
|
|
939
|
+
requestId,
|
|
940
|
+
alreadyClaimed: true,
|
|
941
|
+
message: `This approved call is already being forwarded by an earlier ${CLAIM_TOOL}. Do not propose ${seen.toolName} again; wait, then call ${RECEIPT_TOOL} with this requestId to see what happened.`
|
|
942
|
+
},
|
|
943
|
+
true
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
const notice = {
|
|
947
|
+
type: "text",
|
|
948
|
+
text: `This requestId was already claimed and ${seen.toolName} was already forwarded. Do not propose it again. The earlier result follows.`
|
|
949
|
+
};
|
|
950
|
+
const earlier = seen.result;
|
|
951
|
+
return {
|
|
952
|
+
...earlier,
|
|
953
|
+
isError: true,
|
|
954
|
+
content: [
|
|
955
|
+
notice,
|
|
956
|
+
...Array.isArray(earlier.content) ? earlier.content : []
|
|
957
|
+
]
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
async function claim(requestId, signal) {
|
|
961
|
+
const repeat = alreadyClaimed(requestId);
|
|
962
|
+
if (repeat) return repeat;
|
|
501
963
|
const held = pendingCalls.get(requestId);
|
|
502
964
|
if (held) {
|
|
965
|
+
if (signal?.aborted) {
|
|
966
|
+
return cancelledResult(held.name, requestId);
|
|
967
|
+
}
|
|
968
|
+
pendingCalls.delete(requestId);
|
|
969
|
+
const putBack = () => {
|
|
970
|
+
claimedRequests.delete(requestId);
|
|
971
|
+
pendingCalls.set(requestId, held);
|
|
972
|
+
};
|
|
973
|
+
rememberClaim(requestId, { toolName: held.name, state: "forwarding" });
|
|
503
974
|
let claimed;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
975
|
+
const reused = claimedCapabilities.get(requestId);
|
|
976
|
+
if (reused) {
|
|
977
|
+
claimedCapabilities.delete(requestId);
|
|
978
|
+
claimed = reused;
|
|
979
|
+
} else {
|
|
980
|
+
try {
|
|
981
|
+
claimed = await options.client.claim(requestId);
|
|
982
|
+
} catch {
|
|
983
|
+
putBack();
|
|
984
|
+
return unreachableResult(held.name, requestId);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
|
|
988
|
+
putBack();
|
|
989
|
+
return unreachableResult(held.name, requestId);
|
|
511
990
|
}
|
|
512
991
|
if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
|
|
992
|
+
putBack();
|
|
513
993
|
return jsonResult2({
|
|
514
994
|
decision: "ASK",
|
|
515
995
|
toolName: held.name,
|
|
@@ -517,40 +997,95 @@ function createGovernedProxy(options) {
|
|
|
517
997
|
message: "Still awaiting human approval. Nothing was sent."
|
|
518
998
|
});
|
|
519
999
|
}
|
|
520
|
-
|
|
1000
|
+
let result;
|
|
1001
|
+
const forwarding = { sent: false };
|
|
521
1002
|
try {
|
|
1003
|
+
const call = tracked();
|
|
522
1004
|
const output = await settle(
|
|
523
|
-
ctx,
|
|
1005
|
+
call.ctx,
|
|
524
1006
|
held.name,
|
|
525
1007
|
held.args,
|
|
526
1008
|
claimed,
|
|
527
|
-
(
|
|
1009
|
+
callUpstream(upstream, held.name, held.args, signal, forwarding)
|
|
1010
|
+
);
|
|
1011
|
+
result = toCallResult(
|
|
1012
|
+
held.name,
|
|
1013
|
+
held.args,
|
|
1014
|
+
output,
|
|
1015
|
+
call.allowedRequestId()
|
|
528
1016
|
);
|
|
529
|
-
return toCallResult(held.name, held.args, output);
|
|
530
1017
|
} catch (err) {
|
|
531
|
-
|
|
1018
|
+
if (err instanceof UpstreamCallCancelled) {
|
|
1019
|
+
putBack();
|
|
1020
|
+
if (claimed.ok && claimed.decision === "ALLOW") {
|
|
1021
|
+
claimedCapabilities.set(requestId, claimed);
|
|
1022
|
+
}
|
|
1023
|
+
return cancelledResult(held.name, requestId);
|
|
1024
|
+
}
|
|
1025
|
+
result = failure(err, held.name, forwarding);
|
|
1026
|
+
}
|
|
1027
|
+
if (!claimed.ok || claimed.decision !== "ALLOW" || !claimed.capabilityToken) {
|
|
1028
|
+
claimedRequests.delete(requestId);
|
|
1029
|
+
} else {
|
|
1030
|
+
rememberClaim(requestId, {
|
|
1031
|
+
toolName: held.name,
|
|
1032
|
+
state: "done",
|
|
1033
|
+
result
|
|
1034
|
+
});
|
|
532
1035
|
}
|
|
1036
|
+
return result;
|
|
533
1037
|
}
|
|
534
1038
|
const installFor = pendingInstalls.get(requestId);
|
|
535
1039
|
if (installFor) {
|
|
536
|
-
|
|
1040
|
+
if (signal?.aborted) {
|
|
1041
|
+
return jsonResult2(
|
|
1042
|
+
{
|
|
1043
|
+
toolName: installFor,
|
|
1044
|
+
requestId,
|
|
1045
|
+
cancelled: true,
|
|
1046
|
+
message: `The request was cancelled. Nothing was claimed; call ${CLAIM_TOOL} with this requestId again.`
|
|
1047
|
+
},
|
|
1048
|
+
true
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const key = `${options.connectionId}:${installFor}`;
|
|
1052
|
+
const step = heldSetupStep(installFor, requestId) ?? "install";
|
|
1053
|
+
const forgetHeld = () => {
|
|
1054
|
+
if (ctx.heldSetup?.get(key)?.requestId === requestId) {
|
|
1055
|
+
ctx.heldSetup.delete(key);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
let claimed;
|
|
1059
|
+
try {
|
|
1060
|
+
claimed = await options.client.claim(requestId);
|
|
1061
|
+
} catch {
|
|
1062
|
+
return unreachableResult(installFor, requestId);
|
|
1063
|
+
}
|
|
1064
|
+
if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
|
|
1065
|
+
return unreachableResult(installFor, requestId);
|
|
1066
|
+
}
|
|
537
1067
|
if (claimed.ok && claimed.decision === "ALLOW" && claimed.capabilityToken) {
|
|
1068
|
+
forgetHeld();
|
|
1069
|
+
pendingInstalls.delete(requestId);
|
|
538
1070
|
const executed = await options.client.execute({
|
|
539
1071
|
requestId: claimed.requestId,
|
|
540
1072
|
capabilityToken: claimed.capabilityToken
|
|
541
1073
|
});
|
|
542
|
-
|
|
543
|
-
|
|
1074
|
+
if (executed.ok && step === "enable") {
|
|
1075
|
+
ctx.ensured.add(key);
|
|
1076
|
+
} else if (executed.ok) {
|
|
1077
|
+
ctx.installed?.add(key);
|
|
1078
|
+
}
|
|
544
1079
|
return jsonResult2(
|
|
545
1080
|
executed.ok ? {
|
|
546
1081
|
decision: "ALLOW",
|
|
547
1082
|
toolName: installFor,
|
|
548
|
-
message: `${installFor} is registered with Guardian. Retry the original call.`
|
|
1083
|
+
message: step === "enable" ? `${installFor} is switched on for this agent. Retry the original call.` : `${installFor} is registered with Guardian. Retry the original call; switching it on for this agent may need one more approval.`
|
|
549
1084
|
} : { toolName: installFor, executionFailed: executed.reasonCode },
|
|
550
1085
|
!executed.ok
|
|
551
1086
|
);
|
|
552
1087
|
}
|
|
553
|
-
if (claimed.ok && claimed.decision === "ASK") {
|
|
1088
|
+
if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
|
|
554
1089
|
return jsonResult2({
|
|
555
1090
|
decision: "ASK",
|
|
556
1091
|
toolName: installFor,
|
|
@@ -558,24 +1093,36 @@ function createGovernedProxy(options) {
|
|
|
558
1093
|
message: "Still awaiting human approval."
|
|
559
1094
|
});
|
|
560
1095
|
}
|
|
1096
|
+
forgetHeld();
|
|
561
1097
|
pendingInstalls.delete(requestId);
|
|
562
|
-
return jsonResult2(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1098
|
+
return jsonResult2(
|
|
1099
|
+
{
|
|
1100
|
+
decision: "DENY",
|
|
1101
|
+
toolName: installFor,
|
|
1102
|
+
reasonCode: claimed.ok ? claimed.reasonCode ?? "MISSING_CAPABILITY" : claimed.reasonCode
|
|
1103
|
+
},
|
|
1104
|
+
true
|
|
1105
|
+
);
|
|
567
1106
|
}
|
|
568
1107
|
return jsonResult2(
|
|
569
1108
|
{
|
|
570
1109
|
requestId,
|
|
571
|
-
error:
|
|
1110
|
+
error: `This proxy holds no call with this requestId, so this claim forwarded nothing. If it was claimed before (in this or an earlier proxy session), the call may already have been forwarded: call ${RECEIPT_TOOL} with this requestId before proposing it again.`
|
|
572
1111
|
},
|
|
573
1112
|
true
|
|
574
1113
|
);
|
|
575
1114
|
}
|
|
576
1115
|
return {
|
|
577
1116
|
async listTools() {
|
|
578
|
-
|
|
1117
|
+
let tools;
|
|
1118
|
+
try {
|
|
1119
|
+
tools = await loadTools();
|
|
1120
|
+
} catch {
|
|
1121
|
+
log(
|
|
1122
|
+
"guardian-mcp proxy: the upstream MCP server's tool list is unavailable"
|
|
1123
|
+
);
|
|
1124
|
+
throw new UpstreamListFailed();
|
|
1125
|
+
}
|
|
579
1126
|
return [
|
|
580
1127
|
...[...tools.values()].map((tool) => ({
|
|
581
1128
|
...tool,
|
|
@@ -584,13 +1131,14 @@ function createGovernedProxy(options) {
|
|
|
584
1131
|
...PROXY_GOVERNANCE_TOOLS
|
|
585
1132
|
];
|
|
586
1133
|
},
|
|
587
|
-
async callTool(name, args) {
|
|
1134
|
+
async callTool(name, args, callOptions) {
|
|
1135
|
+
const signal = callOptions?.signal;
|
|
588
1136
|
if (name === CLAIM_TOOL) {
|
|
589
1137
|
const requestId = args.requestId;
|
|
590
1138
|
if (typeof requestId !== "string" || requestId.length === 0) {
|
|
591
1139
|
return jsonResult2({ error: "requestId is required" }, true);
|
|
592
1140
|
}
|
|
593
|
-
return claim(requestId);
|
|
1141
|
+
return claim(requestId, signal);
|
|
594
1142
|
}
|
|
595
1143
|
if (name === RECEIPT_TOOL) {
|
|
596
1144
|
const requestId = args.requestId;
|
|
@@ -603,26 +1151,123 @@ function createGovernedProxy(options) {
|
|
|
603
1151
|
!result.ok
|
|
604
1152
|
);
|
|
605
1153
|
}
|
|
606
|
-
|
|
1154
|
+
let tools;
|
|
1155
|
+
try {
|
|
1156
|
+
tools = await loadTools();
|
|
1157
|
+
} catch {
|
|
1158
|
+
return jsonResult2(
|
|
1159
|
+
{
|
|
1160
|
+
toolName: name,
|
|
1161
|
+
error: "The upstream MCP server's tool list is unavailable; nothing was forwarded."
|
|
1162
|
+
},
|
|
1163
|
+
true
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
607
1166
|
if (!tools.has(name)) {
|
|
608
1167
|
return jsonResult2({ error: `unknown tool: ${name}` }, true);
|
|
609
1168
|
}
|
|
1169
|
+
if (signal?.aborted) {
|
|
1170
|
+
return cancelledResult(name);
|
|
1171
|
+
}
|
|
1172
|
+
const forwarding = { sent: false };
|
|
610
1173
|
try {
|
|
1174
|
+
const call = tracked();
|
|
611
1175
|
const output = await runThroughGuardian(
|
|
612
|
-
ctx,
|
|
1176
|
+
call.ctx,
|
|
613
1177
|
name,
|
|
614
1178
|
args,
|
|
615
|
-
(
|
|
1179
|
+
callUpstream(upstream, name, args, signal, forwarding)
|
|
616
1180
|
);
|
|
617
|
-
return toCallResult(name, args, output);
|
|
1181
|
+
return toCallResult(name, args, output, call.allowedRequestId());
|
|
618
1182
|
} catch (err) {
|
|
619
|
-
return failure(err, name);
|
|
1183
|
+
return failure(err, name, forwarding);
|
|
620
1184
|
}
|
|
621
1185
|
}
|
|
622
1186
|
};
|
|
623
1187
|
}
|
|
624
1188
|
|
|
1189
|
+
// src/upstream.ts
|
|
1190
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1191
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
1192
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
1193
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1194
|
+
var UpstreamConnectError = class extends Error {
|
|
1195
|
+
constructor(message) {
|
|
1196
|
+
super(message);
|
|
1197
|
+
this.name = "UpstreamConnectError";
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
function upstreamTransport(target, env) {
|
|
1201
|
+
if (!("remote" in target)) {
|
|
1202
|
+
return new StdioClientTransport({
|
|
1203
|
+
command: target.command,
|
|
1204
|
+
args: target.args,
|
|
1205
|
+
env: upstreamEnv(env),
|
|
1206
|
+
stderr: "inherit"
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
const { url, transport, headers } = target.remote;
|
|
1210
|
+
const options = {
|
|
1211
|
+
requestInit: { headers },
|
|
1212
|
+
redirectPolicy: "same-origin"
|
|
1213
|
+
};
|
|
1214
|
+
return transport === "sse" ? new SSEClientTransport(new URL(url), options) : new StreamableHTTPClientTransport(new URL(url), options);
|
|
1215
|
+
}
|
|
1216
|
+
async function connectUpstream(target, env, clientInfo, transport = upstreamTransport(target, env)) {
|
|
1217
|
+
const client2 = new Client(clientInfo, { capabilities: {} });
|
|
1218
|
+
try {
|
|
1219
|
+
await client2.connect(transport);
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
await client2.close().catch(() => {
|
|
1222
|
+
});
|
|
1223
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1224
|
+
throw new UpstreamConnectError(
|
|
1225
|
+
`guardian-mcp: could not connect to the upstream MCP server at ${describeTarget(target)}: ${redact(detail, secretsOf(target)).slice(0, 300)}. No tools are served.`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
return client2;
|
|
1229
|
+
}
|
|
1230
|
+
async function listAllTools(client2) {
|
|
1231
|
+
const tools = [];
|
|
1232
|
+
let cursor;
|
|
1233
|
+
do {
|
|
1234
|
+
const page = await client2.listTools(cursor ? { cursor } : void 0);
|
|
1235
|
+
tools.push(...page.tools);
|
|
1236
|
+
cursor = page.nextCursor;
|
|
1237
|
+
} while (cursor);
|
|
1238
|
+
return tools;
|
|
1239
|
+
}
|
|
1240
|
+
function toolListFailure(target, err) {
|
|
1241
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1242
|
+
return `guardian-mcp: the upstream MCP server at ${describeTarget(target)} did not return its tool list: ${redact(detail, secretsOf(target)).slice(0, 300)}.`;
|
|
1243
|
+
}
|
|
1244
|
+
function clientUpstream(client2) {
|
|
1245
|
+
return {
|
|
1246
|
+
listTools: () => listAllTools(client2),
|
|
1247
|
+
async callTool(name, args, options) {
|
|
1248
|
+
if (options?.signal?.aborted) {
|
|
1249
|
+
throw new UpstreamCallCancelled(name);
|
|
1250
|
+
}
|
|
1251
|
+
return await client2.callTool({
|
|
1252
|
+
name,
|
|
1253
|
+
arguments: args
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
625
1259
|
// src/index.ts
|
|
1260
|
+
async function connectOrExit(...args) {
|
|
1261
|
+
try {
|
|
1262
|
+
return await connectUpstream(...args);
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
process.stderr.write(
|
|
1265
|
+
`${err instanceof UpstreamConnectError ? err.message : "guardian-mcp: could not connect to the upstream MCP server. No tools are served."}
|
|
1266
|
+
`
|
|
1267
|
+
);
|
|
1268
|
+
process.exit(1);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
626
1271
|
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
627
1272
|
var parsed = parseCli(process.argv.slice(2), process.env);
|
|
628
1273
|
if (!parsed.ok) {
|
|
@@ -633,30 +1278,25 @@ if (!parsed.ok) {
|
|
|
633
1278
|
var config = parsed.config;
|
|
634
1279
|
if (config.mode.kind === "tools") {
|
|
635
1280
|
const mode = config.mode;
|
|
636
|
-
const lister =
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
do {
|
|
651
|
-
const page = await lister.listTools(cursor ? { cursor } : void 0);
|
|
652
|
-
tools.push(...page.tools);
|
|
653
|
-
cursor = page.nextCursor;
|
|
654
|
-
} while (cursor);
|
|
1281
|
+
const lister = await connectOrExit(mode, process.env, {
|
|
1282
|
+
name: "guardian-mcp-tools",
|
|
1283
|
+
version: VERSION
|
|
1284
|
+
});
|
|
1285
|
+
let tools;
|
|
1286
|
+
try {
|
|
1287
|
+
tools = await listAllTools(lister);
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
process.stderr.write(`${toolListFailure(mode, err)}
|
|
1290
|
+
`);
|
|
1291
|
+
await lister.close().catch(() => {
|
|
1292
|
+
});
|
|
1293
|
+
process.exit(1);
|
|
1294
|
+
}
|
|
655
1295
|
await lister.close();
|
|
656
|
-
const serverCommand =
|
|
1296
|
+
const serverCommand = describeTarget(mode);
|
|
657
1297
|
process.stdout.write(
|
|
658
1298
|
mode.json ? `${JSON.stringify({ server: serverCommand, tools: tools.map(suggestTool) }, null, 2)}
|
|
659
|
-
` : `${renderSuggestions(serverCommand, tools)}
|
|
1299
|
+
` : `${renderSuggestions(serverCommand, tools, "remote" in mode)}
|
|
660
1300
|
`
|
|
661
1301
|
);
|
|
662
1302
|
process.exit(0);
|
|
@@ -671,18 +1311,10 @@ var server = new Server(
|
|
|
671
1311
|
{ capabilities: { tools: {} } }
|
|
672
1312
|
);
|
|
673
1313
|
if (config.mode.kind === "proxy") {
|
|
674
|
-
const upstreamClient =
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
);
|
|
678
|
-
await upstreamClient.connect(
|
|
679
|
-
new StdioClientTransport({
|
|
680
|
-
command: config.mode.command,
|
|
681
|
-
args: config.mode.args,
|
|
682
|
-
env: upstreamEnv(process.env),
|
|
683
|
-
stderr: "inherit"
|
|
684
|
-
})
|
|
685
|
-
);
|
|
1314
|
+
const upstreamClient = await connectOrExit(config.mode, process.env, {
|
|
1315
|
+
name: "guardian-mcp-proxy",
|
|
1316
|
+
version: VERSION
|
|
1317
|
+
});
|
|
686
1318
|
const proxy = createGovernedProxy({
|
|
687
1319
|
client,
|
|
688
1320
|
connectionId: config.connectionId,
|
|
@@ -690,35 +1322,19 @@ if (config.mode.kind === "proxy") {
|
|
|
690
1322
|
...config.adapterId ? { adapterId: config.adapterId } : {},
|
|
691
1323
|
log: (message) => process.stderr.write(`${message}
|
|
692
1324
|
`),
|
|
693
|
-
upstream:
|
|
694
|
-
async listTools() {
|
|
695
|
-
const tools = [];
|
|
696
|
-
let cursor;
|
|
697
|
-
do {
|
|
698
|
-
const page = await upstreamClient.listTools(
|
|
699
|
-
cursor ? { cursor } : void 0
|
|
700
|
-
);
|
|
701
|
-
tools.push(...page.tools);
|
|
702
|
-
cursor = page.nextCursor;
|
|
703
|
-
} while (cursor);
|
|
704
|
-
return tools;
|
|
705
|
-
},
|
|
706
|
-
async callTool(name, args) {
|
|
707
|
-
return await upstreamClient.callTool({
|
|
708
|
-
name,
|
|
709
|
-
arguments: args
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
}
|
|
1325
|
+
upstream: clientUpstream(upstreamClient)
|
|
713
1326
|
});
|
|
714
1327
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
715
1328
|
tools: await proxy.listTools()
|
|
716
1329
|
}));
|
|
717
1330
|
server.setRequestHandler(
|
|
718
1331
|
CallToolRequestSchema,
|
|
719
|
-
async (request) => await proxy.callTool(
|
|
1332
|
+
async (request, extra) => await proxy.callTool(
|
|
720
1333
|
request.params.name,
|
|
721
|
-
request.params.arguments ?? {}
|
|
1334
|
+
request.params.arguments ?? {},
|
|
1335
|
+
// Cancelled (notifications/cancelled) or timed-out requests are
|
|
1336
|
+
// not forwarded if the signal fires before the call is sent.
|
|
1337
|
+
{ signal: extra.signal }
|
|
722
1338
|
)
|
|
723
1339
|
);
|
|
724
1340
|
} else {
|