@odingard/guardian-mcp 0.3.2 → 0.3.3
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 +93 -1
- package/dist/index.js +346 -88
- package/package.json +5 -4
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -87,6 +87,98 @@ upstream server's tools unchanged, and every call is decided first:
|
|
|
87
87
|
`guardian-mcp --help` prints usage. A missing setting prints a one-line
|
|
88
88
|
message naming it instead of a stack trace.
|
|
89
89
|
|
|
90
|
+
## Govern a remote MCP server: `guardian-mcp proxy --url` (unreleased)
|
|
91
|
+
|
|
92
|
+
**Status: not yet proven live.** This is tested against local Streamable
|
|
93
|
+
HTTP and SSE MCP servers and a fake Guardian only. Nothing has run against a
|
|
94
|
+
real Guardian or a real hosted server such as Composio. Composio's
|
|
95
|
+
`x-api-key` header is unconfirmed.
|
|
96
|
+
|
|
97
|
+
The proxy can also front an MCP server that runs online, such as Composio.
|
|
98
|
+
The agent still starts `guardian-mcp` over stdio like any
|
|
99
|
+
local server; guardian-mcp connects to the remote server over **Streamable
|
|
100
|
+
HTTP** (the default) or **SSE** (`--transport sse`).
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"composio-governed": {
|
|
106
|
+
"command": "guardian-mcp",
|
|
107
|
+
"args": [
|
|
108
|
+
"proxy",
|
|
109
|
+
"--url",
|
|
110
|
+
"<the URL Composio's generate step returned>",
|
|
111
|
+
"--header",
|
|
112
|
+
"x-api-key: ${COMPOSIO_API_KEY}"
|
|
113
|
+
],
|
|
114
|
+
"env": {
|
|
115
|
+
"GUARDIAN_BASE_URL": "https://guardian-api.odingard.com",
|
|
116
|
+
"GUARDIAN_AGENT_CREDENTIAL": "gac1.…",
|
|
117
|
+
"GUARDIAN_CONNECTION_ID": "pcn_…",
|
|
118
|
+
"COMPOSIO_API_KEY": "…"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A server that takes a Bearer key: `--url https://mcp.example.com/mcp --header
|
|
126
|
+
'Authorization: Bearer ${REMOTE_MCP_API_KEY}'`.
|
|
127
|
+
|
|
128
|
+
- Every `tools/call` is decided by Guardian exactly as for a stdio server:
|
|
129
|
+
ALLOW forwards it, ASK holds it for `guardian_claim`, DENY never sends it.
|
|
130
|
+
`tools/list` passes through, with `guardian_claim` and `guardian_receipt`
|
|
131
|
+
added.
|
|
132
|
+
- **Secrets stay in the proxy.** Write header values as `${ENV_VAR}`;
|
|
133
|
+
guardian-mcp expands them itself, so no shell is needed and the key never
|
|
134
|
+
appears in the agent's view. In a shell, use single quotes so the shell
|
|
135
|
+
does not expand it onto the command line. The URL and header values are
|
|
136
|
+
never printed; error messages show the server's origin only.
|
|
137
|
+
- `${…}` can only name a variable that is set, and never a `GUARDIAN_*`
|
|
138
|
+
one, so the agent credential cannot be sent to the remote server.
|
|
139
|
+
- The URL must be `https://`; plain `http://` is accepted only for
|
|
140
|
+
`localhost`. User names or passwords in the URL are refused; send
|
|
141
|
+
credentials as headers. Redirects to another origin are not followed.
|
|
142
|
+
- **Fails closed.** If the remote server cannot be reached or rejects the key
|
|
143
|
+
at startup, guardian-mcp exits with a one-line message and serves no tools.
|
|
144
|
+
If the remote fails after Guardian allowed a call, the result is an error
|
|
145
|
+
that says the call may or may not have run, and the capability is not
|
|
146
|
+
redeemed. It is never reported as a success.
|
|
147
|
+
- `guardian-mcp tools --url …` lists a remote server's tools with suggested
|
|
148
|
+
modes, the same as for a local one.
|
|
149
|
+
- OAuth sign-in flows are not supported; use an API key or bearer token
|
|
150
|
+
header.
|
|
151
|
+
- **The upstream cannot speak for Guardian.** A remote server's result is
|
|
152
|
+
passed to the agent as the server's own output, whatever fields it
|
|
153
|
+
carries. A result that contains a `guardian: {decision, requestId}` field
|
|
154
|
+
is never read as a Guardian decision, so it cannot hold, swap or hide a
|
|
155
|
+
call.
|
|
156
|
+
- If `tools/list` fails upstream, the agent gets a generic "tool list is
|
|
157
|
+
unavailable" error. The upstream's error text (which can quote its URL
|
|
158
|
+
path, response body or headers) is not shown to the agent or logged;
|
|
159
|
+
`guardian-mcp tools --url` prints it with the URL, its path identifiers,
|
|
160
|
+
query values and header values scrubbed.
|
|
161
|
+
|
|
162
|
+
What is **not** governed: a remote server the agent connects to directly
|
|
163
|
+
(for example an MCP client's own `"url"` entry, or a model provider's hosted
|
|
164
|
+
MCP connector), and anything the agent does without MCP. Also not covered:
|
|
165
|
+
|
|
166
|
+
- **Lost results after ALLOW.** If the remote times out (the MCP SDK's
|
|
167
|
+
default request timeout is 60 seconds) or drops the session after
|
|
168
|
+
Guardian allowed a call, the call may have run, but the capability is not
|
|
169
|
+
redeemed and no receipt is written. Receipts can then understate what
|
|
170
|
+
happened; check the remote system itself.
|
|
171
|
+
- **Keys in the same process tree.** guardian-mcp keeps the upstream key out
|
|
172
|
+
of the agent's MCP view. It cannot stop other code that runs as the same
|
|
173
|
+
user, in the same container, from reading the key from the environment it
|
|
174
|
+
was set in and calling the remote server directly. Recipes:
|
|
175
|
+
[ZeroClaw](../../examples/zeroclaw/README.md) and
|
|
176
|
+
[your own container (CrewAI, LangGraph, AutoGen)](../../examples/byo/README.md).
|
|
177
|
+
Runnable example: `examples/remote-mcp.mjs` (SIMULATED by default, LIVE with
|
|
178
|
+
Guardian settings). It runs inside this repository, where the workspace
|
|
179
|
+
links the unreleased guardian-mcp; the published `@odingard/guardian-mcp`
|
|
180
|
+
0.3.2 does not have `--url` yet.
|
|
181
|
+
|
|
90
182
|
## See what you are about to govern: `guardian-mcp tools`
|
|
91
183
|
|
|
92
184
|
Before putting a server behind the proxy, list its tools along with a suggested Guardian mode for each. This needs no Guardian credential:
|
|
@@ -112,4 +204,4 @@ They are suggestions only: you set the real modes in the console. Tool names tha
|
|
|
112
204
|
|
|
113
205
|
## License
|
|
114
206
|
|
|
115
|
-
Apache-2.0.
|
|
207
|
+
Apache-2.0.
|
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,156 @@ 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 segment of parsed2.pathname.split("/")) {
|
|
239
|
+
if (segment.length >= 16 || segment.length >= 8 && /\d/.test(segment)) {
|
|
240
|
+
out.add(segment);
|
|
241
|
+
out.add(decodeURIComponent(segment));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const value of Object.values(headers)) {
|
|
245
|
+
out.add(value);
|
|
246
|
+
const parts = value.split(/\s+/);
|
|
247
|
+
for (const part of parts) if (part.length >= 8) out.add(part);
|
|
248
|
+
}
|
|
249
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
250
|
+
}
|
|
251
|
+
function redact(text, secrets) {
|
|
252
|
+
let out = text;
|
|
253
|
+
for (const secret of secrets) {
|
|
254
|
+
if (secret) out = out.split(secret).join("[redacted]");
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
function describeTarget(target) {
|
|
259
|
+
if ("remote" in target) {
|
|
260
|
+
return `${new URL(target.remote.url).origin} (${target.remote.transport})`;
|
|
261
|
+
}
|
|
262
|
+
return [target.command, ...target.args].join(" ");
|
|
263
|
+
}
|
|
121
264
|
|
|
122
265
|
// src/dispatch.ts
|
|
123
266
|
var TOOLS = [
|
|
@@ -309,7 +452,7 @@ function suggestTool(tool) {
|
|
|
309
452
|
governable
|
|
310
453
|
};
|
|
311
454
|
}
|
|
312
|
-
function renderSuggestions(serverCommand, tools) {
|
|
455
|
+
function renderSuggestions(serverCommand, tools, remote = false) {
|
|
313
456
|
const rows = tools.map(suggestTool);
|
|
314
457
|
const width = Math.max(4, ...rows.map((r) => r.name.length));
|
|
315
458
|
const lines = [
|
|
@@ -322,7 +465,7 @@ function renderSuggestions(serverCommand, tools) {
|
|
|
322
465
|
"",
|
|
323
466
|
"Register these in the Guardian console (Connections \u2192 Register a tool server \u2192",
|
|
324
467
|
"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."
|
|
468
|
+
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
469
|
];
|
|
327
470
|
const ungovernable = rows.filter((r) => !r.governable);
|
|
328
471
|
if (ungovernable.length > 0) {
|
|
@@ -378,7 +521,46 @@ function jsonResult2(payload, isError = false) {
|
|
|
378
521
|
function isDecisionResult(value) {
|
|
379
522
|
return typeof value === "object" && value !== null && "guardian" in value && typeof value.guardian === "object";
|
|
380
523
|
}
|
|
524
|
+
var UpstreamCallFailed = class extends Error {
|
|
525
|
+
constructor(toolName) {
|
|
526
|
+
super("upstream call failed");
|
|
527
|
+
this.toolName = toolName;
|
|
528
|
+
this.name = "UpstreamCallFailed";
|
|
529
|
+
}
|
|
530
|
+
toolName;
|
|
531
|
+
};
|
|
532
|
+
var UPSTREAM_RESULT = /* @__PURE__ */ Symbol("guardian-mcp.upstreamResult");
|
|
533
|
+
function upstreamRan(value) {
|
|
534
|
+
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, UPSTREAM_RESULT);
|
|
535
|
+
}
|
|
536
|
+
var UpstreamListFailed = class extends Error {
|
|
537
|
+
constructor() {
|
|
538
|
+
super(
|
|
539
|
+
"guardian-mcp: the upstream MCP server's tool list is unavailable; no tools are served."
|
|
540
|
+
);
|
|
541
|
+
this.name = "UpstreamListFailed";
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
function callUpstream(upstream, name, args) {
|
|
545
|
+
return async () => {
|
|
546
|
+
try {
|
|
547
|
+
return { [UPSTREAM_RESULT]: await upstream.callTool(name, args) };
|
|
548
|
+
} catch {
|
|
549
|
+
throw new UpstreamCallFailed(name);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
}
|
|
381
553
|
function failure(err, toolName) {
|
|
554
|
+
if (err instanceof UpstreamCallFailed) {
|
|
555
|
+
return jsonResult2(
|
|
556
|
+
{
|
|
557
|
+
decision: "ALLOW",
|
|
558
|
+
toolName,
|
|
559
|
+
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."
|
|
560
|
+
},
|
|
561
|
+
true
|
|
562
|
+
);
|
|
563
|
+
}
|
|
382
564
|
if (err instanceof GuardianExecutionFailed) {
|
|
383
565
|
return jsonResult2(
|
|
384
566
|
{
|
|
@@ -466,7 +648,16 @@ function createGovernedProxy(options) {
|
|
|
466
648
|
return tools;
|
|
467
649
|
}
|
|
468
650
|
function toCallResult(toolName, args, output) {
|
|
469
|
-
if (
|
|
651
|
+
if (upstreamRan(output)) return output[UPSTREAM_RESULT];
|
|
652
|
+
if (!isDecisionResult(output)) {
|
|
653
|
+
return jsonResult2(
|
|
654
|
+
{
|
|
655
|
+
toolName,
|
|
656
|
+
error: "Guardian proxy error; the call was not forwarded."
|
|
657
|
+
},
|
|
658
|
+
true
|
|
659
|
+
);
|
|
660
|
+
}
|
|
470
661
|
const g = output.guardian;
|
|
471
662
|
if (g.decision === "ASK") {
|
|
472
663
|
if (toolInvokeRequestIds.has(g.requestId)) {
|
|
@@ -500,16 +691,19 @@ function createGovernedProxy(options) {
|
|
|
500
691
|
async function claim(requestId) {
|
|
501
692
|
const held = pendingCalls.get(requestId);
|
|
502
693
|
if (held) {
|
|
694
|
+
pendingCalls.delete(requestId);
|
|
503
695
|
let claimed;
|
|
504
696
|
try {
|
|
505
697
|
claimed = await options.client.claim(requestId);
|
|
506
698
|
} catch {
|
|
699
|
+
pendingCalls.set(requestId, held);
|
|
507
700
|
return jsonResult2(
|
|
508
701
|
{ requestId, error: "Guardian unreachable; nothing was forwarded." },
|
|
509
702
|
true
|
|
510
703
|
);
|
|
511
704
|
}
|
|
512
705
|
if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
|
|
706
|
+
pendingCalls.set(requestId, held);
|
|
513
707
|
return jsonResult2({
|
|
514
708
|
decision: "ASK",
|
|
515
709
|
toolName: held.name,
|
|
@@ -517,14 +711,13 @@ function createGovernedProxy(options) {
|
|
|
517
711
|
message: "Still awaiting human approval. Nothing was sent."
|
|
518
712
|
});
|
|
519
713
|
}
|
|
520
|
-
pendingCalls.delete(requestId);
|
|
521
714
|
try {
|
|
522
715
|
const output = await settle(
|
|
523
716
|
ctx,
|
|
524
717
|
held.name,
|
|
525
718
|
held.args,
|
|
526
719
|
claimed,
|
|
527
|
-
(
|
|
720
|
+
callUpstream(upstream, held.name, held.args)
|
|
528
721
|
);
|
|
529
722
|
return toCallResult(held.name, held.args, output);
|
|
530
723
|
} catch (err) {
|
|
@@ -575,7 +768,15 @@ function createGovernedProxy(options) {
|
|
|
575
768
|
}
|
|
576
769
|
return {
|
|
577
770
|
async listTools() {
|
|
578
|
-
|
|
771
|
+
let tools;
|
|
772
|
+
try {
|
|
773
|
+
tools = await loadTools();
|
|
774
|
+
} catch {
|
|
775
|
+
log(
|
|
776
|
+
"guardian-mcp proxy: the upstream MCP server's tool list is unavailable"
|
|
777
|
+
);
|
|
778
|
+
throw new UpstreamListFailed();
|
|
779
|
+
}
|
|
579
780
|
return [
|
|
580
781
|
...[...tools.values()].map((tool) => ({
|
|
581
782
|
...tool,
|
|
@@ -603,7 +804,18 @@ function createGovernedProxy(options) {
|
|
|
603
804
|
!result.ok
|
|
604
805
|
);
|
|
605
806
|
}
|
|
606
|
-
|
|
807
|
+
let tools;
|
|
808
|
+
try {
|
|
809
|
+
tools = await loadTools();
|
|
810
|
+
} catch {
|
|
811
|
+
return jsonResult2(
|
|
812
|
+
{
|
|
813
|
+
toolName: name,
|
|
814
|
+
error: "The upstream MCP server's tool list is unavailable; nothing was forwarded."
|
|
815
|
+
},
|
|
816
|
+
true
|
|
817
|
+
);
|
|
818
|
+
}
|
|
607
819
|
if (!tools.has(name)) {
|
|
608
820
|
return jsonResult2({ error: `unknown tool: ${name}` }, true);
|
|
609
821
|
}
|
|
@@ -612,7 +824,7 @@ function createGovernedProxy(options) {
|
|
|
612
824
|
ctx,
|
|
613
825
|
name,
|
|
614
826
|
args,
|
|
615
|
-
(
|
|
827
|
+
callUpstream(upstream, name, args)
|
|
616
828
|
);
|
|
617
829
|
return toCallResult(name, args, output);
|
|
618
830
|
} catch (err) {
|
|
@@ -622,7 +834,85 @@ function createGovernedProxy(options) {
|
|
|
622
834
|
};
|
|
623
835
|
}
|
|
624
836
|
|
|
837
|
+
// src/upstream.ts
|
|
838
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
839
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
840
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
841
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
842
|
+
var UpstreamConnectError = class extends Error {
|
|
843
|
+
constructor(message) {
|
|
844
|
+
super(message);
|
|
845
|
+
this.name = "UpstreamConnectError";
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
function upstreamTransport(target, env) {
|
|
849
|
+
if (!("remote" in target)) {
|
|
850
|
+
return new StdioClientTransport({
|
|
851
|
+
command: target.command,
|
|
852
|
+
args: target.args,
|
|
853
|
+
env: upstreamEnv(env),
|
|
854
|
+
stderr: "inherit"
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
const { url, transport, headers } = target.remote;
|
|
858
|
+
const options = {
|
|
859
|
+
requestInit: { headers },
|
|
860
|
+
redirectPolicy: "same-origin"
|
|
861
|
+
};
|
|
862
|
+
return transport === "sse" ? new SSEClientTransport(new URL(url), options) : new StreamableHTTPClientTransport(new URL(url), options);
|
|
863
|
+
}
|
|
864
|
+
async function connectUpstream(target, env, clientInfo, transport = upstreamTransport(target, env)) {
|
|
865
|
+
const client2 = new Client(clientInfo, { capabilities: {} });
|
|
866
|
+
try {
|
|
867
|
+
await client2.connect(transport);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
await client2.close().catch(() => {
|
|
870
|
+
});
|
|
871
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
872
|
+
throw new UpstreamConnectError(
|
|
873
|
+
`guardian-mcp: could not connect to the upstream MCP server at ${describeTarget(target)}: ${redact(detail, secretsOf(target)).slice(0, 300)}. No tools are served.`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
return client2;
|
|
877
|
+
}
|
|
878
|
+
async function listAllTools(client2) {
|
|
879
|
+
const tools = [];
|
|
880
|
+
let cursor;
|
|
881
|
+
do {
|
|
882
|
+
const page = await client2.listTools(cursor ? { cursor } : void 0);
|
|
883
|
+
tools.push(...page.tools);
|
|
884
|
+
cursor = page.nextCursor;
|
|
885
|
+
} while (cursor);
|
|
886
|
+
return tools;
|
|
887
|
+
}
|
|
888
|
+
function toolListFailure(target, err) {
|
|
889
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
890
|
+
return `guardian-mcp: the upstream MCP server at ${describeTarget(target)} did not return its tool list: ${redact(detail, secretsOf(target)).slice(0, 300)}.`;
|
|
891
|
+
}
|
|
892
|
+
function clientUpstream(client2) {
|
|
893
|
+
return {
|
|
894
|
+
listTools: () => listAllTools(client2),
|
|
895
|
+
async callTool(name, args) {
|
|
896
|
+
return await client2.callTool({
|
|
897
|
+
name,
|
|
898
|
+
arguments: args
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
625
904
|
// src/index.ts
|
|
905
|
+
async function connectOrExit(...args) {
|
|
906
|
+
try {
|
|
907
|
+
return await connectUpstream(...args);
|
|
908
|
+
} catch (err) {
|
|
909
|
+
process.stderr.write(
|
|
910
|
+
`${err instanceof UpstreamConnectError ? err.message : "guardian-mcp: could not connect to the upstream MCP server. No tools are served."}
|
|
911
|
+
`
|
|
912
|
+
);
|
|
913
|
+
process.exit(1);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
626
916
|
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
627
917
|
var parsed = parseCli(process.argv.slice(2), process.env);
|
|
628
918
|
if (!parsed.ok) {
|
|
@@ -633,30 +923,25 @@ if (!parsed.ok) {
|
|
|
633
923
|
var config = parsed.config;
|
|
634
924
|
if (config.mode.kind === "tools") {
|
|
635
925
|
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);
|
|
926
|
+
const lister = await connectOrExit(mode, process.env, {
|
|
927
|
+
name: "guardian-mcp-tools",
|
|
928
|
+
version: VERSION
|
|
929
|
+
});
|
|
930
|
+
let tools;
|
|
931
|
+
try {
|
|
932
|
+
tools = await listAllTools(lister);
|
|
933
|
+
} catch (err) {
|
|
934
|
+
process.stderr.write(`${toolListFailure(mode, err)}
|
|
935
|
+
`);
|
|
936
|
+
await lister.close().catch(() => {
|
|
937
|
+
});
|
|
938
|
+
process.exit(1);
|
|
939
|
+
}
|
|
655
940
|
await lister.close();
|
|
656
|
-
const serverCommand =
|
|
941
|
+
const serverCommand = describeTarget(mode);
|
|
657
942
|
process.stdout.write(
|
|
658
943
|
mode.json ? `${JSON.stringify({ server: serverCommand, tools: tools.map(suggestTool) }, null, 2)}
|
|
659
|
-
` : `${renderSuggestions(serverCommand, tools)}
|
|
944
|
+
` : `${renderSuggestions(serverCommand, tools, "remote" in mode)}
|
|
660
945
|
`
|
|
661
946
|
);
|
|
662
947
|
process.exit(0);
|
|
@@ -671,18 +956,10 @@ var server = new Server(
|
|
|
671
956
|
{ capabilities: { tools: {} } }
|
|
672
957
|
);
|
|
673
958
|
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
|
-
);
|
|
959
|
+
const upstreamClient = await connectOrExit(config.mode, process.env, {
|
|
960
|
+
name: "guardian-mcp-proxy",
|
|
961
|
+
version: VERSION
|
|
962
|
+
});
|
|
686
963
|
const proxy = createGovernedProxy({
|
|
687
964
|
client,
|
|
688
965
|
connectionId: config.connectionId,
|
|
@@ -690,26 +967,7 @@ if (config.mode.kind === "proxy") {
|
|
|
690
967
|
...config.adapterId ? { adapterId: config.adapterId } : {},
|
|
691
968
|
log: (message) => process.stderr.write(`${message}
|
|
692
969
|
`),
|
|
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
|
-
}
|
|
970
|
+
upstream: clientUpstream(upstreamClient)
|
|
713
971
|
});
|
|
714
972
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
715
973
|
tools: await proxy.listTools()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odingard/guardian-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"mcpName": "io.github.Odingard/guardian-mcp",
|
|
5
5
|
"description": "MCP server that routes an agent's tool calls through Guardian — the human authority layer for autonomous AI. Every tool call gets a deterministic ALLOW/ASK/DENY decision before anything executes; ASK holds for human approval, DENY never runs, every outcome carries a verifiable receipt.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -20,17 +20,18 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --external:@odingard/guardian-sdk --external:@modelcontextprotocol/sdk --outfile=dist/index.js",
|
|
22
22
|
"test": "vitest run",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
23
24
|
"prepublishOnly": "npm run build && npm test"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
27
|
-
"@odingard/guardian-sdk": "^0.3.
|
|
28
|
+
"@odingard/guardian-sdk": "^0.3.8"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@types/node": "^22",
|
|
31
32
|
"esbuild": "^0.28.2",
|
|
32
33
|
"typescript": "^5.9.3",
|
|
33
|
-
"vitest": "^
|
|
34
|
+
"vitest": "^5.1.0"
|
|
34
35
|
},
|
|
35
36
|
"publishConfig": {
|
|
36
37
|
"access": "public"
|
|
@@ -40,5 +41,5 @@
|
|
|
40
41
|
"url": "https://github.com/Odingard/guardian-sdk.git",
|
|
41
42
|
"directory": "packages/mcp"
|
|
42
43
|
},
|
|
43
|
-
"homepage": "https://
|
|
44
|
+
"homepage": "https://odingard.com/guardian"
|
|
44
45
|
}
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
|
|
3
3
|
"name": "io.github.Odingard/guardian-mcp",
|
|
4
4
|
"description": "Routes agent tool calls through Guardian for ALLOW/ASK/DENY before execution.",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.3",
|
|
6
6
|
"tools": [
|
|
7
7
|
{
|
|
8
8
|
"name": "guardian_invoke_tool",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
{
|
|
27
27
|
"registryType": "npm",
|
|
28
28
|
"identifier": "@odingard/guardian-mcp",
|
|
29
|
-
"version": "0.3.
|
|
29
|
+
"version": "0.3.3",
|
|
30
30
|
"transport": {
|
|
31
31
|
"type": "stdio"
|
|
32
32
|
},
|