@odingard/guardian-mcp 0.3.0 → 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 CHANGED
@@ -31,11 +31,11 @@ npm i -g @odingard/guardian-mcp
31
31
 
32
32
  ## Tools
33
33
 
34
- | Tool | What it does |
35
- | --- | --- |
34
+ | Tool | What it does |
35
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
36
36
  | `guardian_invoke_tool` | Propose a tool call. ALLOW executes and returns the receipt; ASK returns a `requestId` held for human approval; DENY refuses with a reason code. |
37
- | `guardian_claim` | Claim an approved ASK by `requestId`, execute, return the receipt. |
38
- | `guardian_receipt` | Fetch the machine-readable evidence receipts for a request. |
37
+ | `guardian_claim` | Claim an approved ASK by `requestId`, execute, return the receipt. |
38
+ | `guardian_receipt` | Fetch the machine-readable evidence receipts for a request. |
39
39
 
40
40
  Nothing executes without a Guardian capability — the model's output is a proposal, never an action.
41
41
 
@@ -62,11 +62,11 @@ upstream server's tools unchanged, and every call is decided first:
62
62
  }
63
63
  ```
64
64
 
65
- | Decision | What happens |
66
- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
67
- | **ALLOW** | The call is forwarded to the upstream server, then the capability is redeemed and the receipt recorded. |
65
+ | Decision | What happens |
66
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
67
+ | **ALLOW** | The call is forwarded to the upstream server, then the capability is redeemed and the receipt recorded. |
68
68
  | **ASK** | Nothing is forwarded. The exact call is held under a `requestId`; after a human approves, `guardian_claim` forwards exactly that call, once. |
69
- | **DENY** | Nothing is forwarded, ever. The same applies if Guardian is unreachable. |
69
+ | **DENY** | Nothing is forwarded, ever. The same applies if Guardian is unreachable. |
70
70
 
71
71
  - `guardian_claim` and `guardian_receipt` are added to the tool list.
72
72
  - The upstream server never receives `GUARDIAN_*` settings, so it never sees
@@ -74,18 +74,134 @@ upstream server's tools unchanged, and every call is decided first:
74
74
  - **Decision-gated:** an allowed call runs upstream before the capability is
75
75
  redeemed. A refusal at redemption is reported as an incident
76
76
  (`localEffectRan: true`), never as success.
77
- - **First-run setup:** connect a tool server in the Guardian console whose
78
- tools JSON lists each upstream tool as record-only
79
- (`{"name":"send_message","transport":"record"}`), and allow those tools
80
- for the agent, then set `GUARDIAN_AUTO_REGISTER=false`. Otherwise the proxy
81
- proposes an `mcp.install` registration per tool, which is denied unless the
82
- agent's grant pins `mcp.install`.
77
+ - **First-run setup:** in the Guardian console, connect a tool server and add
78
+ each upstream tool as "My own code (record-only)". Then allow those tools
79
+ for the agent.
80
+ - Since 0.3.1 the proxy does not try to register tools itself.
81
+ - `GUARDIAN_AUTO_REGISTER=true` turns that back on, but it is denied unless
82
+ the agent's grant pins `mcp.install`.
83
+ - When Guardian refuses, the result says what to fix.
83
84
  - Held calls live in the proxy process. Restarting the proxy drops them, and
84
85
  nothing is forwarded.
85
86
 
86
87
  `guardian-mcp --help` prints usage. A missing setting prints a one-line
87
88
  message naming it instead of a stack trace.
88
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
+
182
+ ## See what you are about to govern: `guardian-mcp tools`
183
+
184
+ Before putting a server behind the proxy, list its tools along with a suggested Guardian mode for each. This needs no Guardian credential:
185
+
186
+ ```bash
187
+ npx -y @odingard/guardian-mcp tools -- npx -y @modelcontextprotocol/server-filesystem ./data
188
+ ```
189
+
190
+ ```text
191
+ Tool Suggested Why
192
+ read_file Allow server marks it read-only
193
+ write_file Ask me first "write" may change something
194
+ delete_branch Ask me first server marks it destructive
195
+ createIssue Ask me first "create" may change something ⚠ name not accepted by Guardian
196
+ ```
197
+
198
+ How the suggestions are made:
199
+
200
+ - The server's own MCP annotations (`readOnlyHint`, `destructiveHint`) are used first.
201
+ - Otherwise the tool's verb decides. `get`, `list` and `search` suggest Allow; everything else suggests Ask.
202
+
203
+ They are suggestions only: you set the real modes in the console. Tool names that break Guardian's naming rule are flagged, because every call to them would be denied. Add `--json` for machine-readable output.
204
+
89
205
  ## License
90
206
 
91
- Apache-2.0. Canonical development happens in the [Odingard Guardian monorepo](https://github.com/Odingard/guardian).
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,35 +16,57 @@ 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.
22
+ guardian-mcp tools [--json] -- <command> [args...]
23
+ guardian-mcp tools [--json] --url <https-url> [--transport ...] [--header ...]
24
+ List a server's tools with a suggested Guardian mode
25
+ for each (no Guardian credential needed)
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).
21
33
 
22
34
  Environment:
23
35
  GUARDIAN_BASE_URL required, e.g. https://guardian-api.odingard.com
24
36
  GUARDIAN_AGENT_CREDENTIAL required, the agent credential (gac1.\u2026) from the Guardian console
25
37
  GUARDIAN_CONNECTION_ID tool connection id (required for proxy mode)
26
38
  GUARDIAN_ADAPTER_ID optional adapter override
27
- GUARDIAN_AUTO_REGISTER proxy: "false" when the upstream tools are already registered
28
- as record-only on the connection (skips mcp.install)
39
+ GUARDIAN_AUTO_REGISTER proxy: "true" to propose registering each upstream tool on first
40
+ use (mcp.install). Default off: register the tools once in the
41
+ console as "My own code (record-only)" and allow them for the agent
29
42
 
30
- Example (proxy):
43
+ Examples (proxy):
31
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}'
32
46
  `;
33
47
  function parseCli(argv, env) {
34
48
  let mode = { kind: "dispatch" };
35
49
  if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
36
50
  return { ok: false, message: USAGE };
37
51
  }
52
+ if (argv[0] === "tools") {
53
+ const json = argv[1] === "--json";
54
+ const target = parseTarget("tools", argv.slice(json ? 2 : 1), env);
55
+ if (!target.ok) return target;
56
+ return {
57
+ ok: true,
58
+ config: {
59
+ baseUrl: "",
60
+ credential: "",
61
+ autoRegister: false,
62
+ mode: { kind: "tools", json, ...target.target }
63
+ }
64
+ };
65
+ }
38
66
  if (argv[0] === "proxy") {
39
- const rest = argv[1] === "--" ? argv.slice(2) : argv.slice(1);
40
- const [command, ...args] = rest;
41
- if (!command) {
42
- return {
43
- ok: false,
44
- message: `guardian-mcp proxy: missing the upstream MCP server command.
45
-
46
- ${USAGE}`
47
- };
48
- }
49
- 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 };
50
70
  } else if (argv.length > 0) {
51
71
  return {
52
72
  ok: false,
@@ -76,7 +96,9 @@ ${USAGE}`
76
96
  credential: env.GUARDIAN_AGENT_CREDENTIAL,
77
97
  ...env.GUARDIAN_CONNECTION_ID ? { connectionId: env.GUARDIAN_CONNECTION_ID } : {},
78
98
  ...env.GUARDIAN_ADAPTER_ID ? { adapterId: env.GUARDIAN_ADAPTER_ID } : {},
79
- autoRegister: !/^(false|0|no|off)$/i.test(env.GUARDIAN_AUTO_REGISTER ?? ""),
99
+ // Off by default — production denies first-use registration
100
+ // unless the grant pins mcp.install. Opt in with GUARDIAN_AUTO_REGISTER=true.
101
+ autoRegister: /^(true|1|yes|on)$/i.test(env.GUARDIAN_AUTO_REGISTER ?? ""),
80
102
  mode
81
103
  }
82
104
  };
@@ -89,6 +111,156 @@ function upstreamEnv(env) {
89
111
  }
90
112
  return out;
91
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
+ }
92
264
 
93
265
  // src/dispatch.ts
94
266
  var TOOLS = [
@@ -225,6 +397,89 @@ async function handleToolCall(options, name, arguments_) {
225
397
  return errorResult(`unknown tool: ${name}`);
226
398
  }
227
399
 
400
+ // src/presets.ts
401
+ var GUARDIAN_TOOL_NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
402
+ var READ_VERBS = /* @__PURE__ */ new Set([
403
+ "get",
404
+ "list",
405
+ "search",
406
+ "read",
407
+ "describe",
408
+ "show",
409
+ "view",
410
+ "find",
411
+ "lookup",
412
+ "count",
413
+ "inspect",
414
+ "status",
415
+ "fetch"
416
+ ]);
417
+ function firstWord(name) {
418
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).find(Boolean) ?? "";
419
+ }
420
+ function suggestTool(tool) {
421
+ const governable = GUARDIAN_TOOL_NAME.test(tool.name) && tool.name.length <= 64;
422
+ const annotations = tool.annotations ?? {};
423
+ if (annotations.destructiveHint === true) {
424
+ return {
425
+ name: tool.name,
426
+ suggestedMode: "ask",
427
+ why: "server marks it destructive",
428
+ governable
429
+ };
430
+ }
431
+ if (annotations.readOnlyHint === true) {
432
+ return {
433
+ name: tool.name,
434
+ suggestedMode: "allow",
435
+ why: "server marks it read-only",
436
+ governable
437
+ };
438
+ }
439
+ const verb = firstWord(tool.name);
440
+ if (READ_VERBS.has(verb)) {
441
+ return {
442
+ name: tool.name,
443
+ suggestedMode: "allow",
444
+ why: `name starts with "${verb}" (not annotated; check it really only reads)`,
445
+ governable
446
+ };
447
+ }
448
+ return {
449
+ name: tool.name,
450
+ suggestedMode: "ask",
451
+ why: verb ? `"${verb}" may change something` : "unclear what it does",
452
+ governable
453
+ };
454
+ }
455
+ function renderSuggestions(serverCommand, tools, remote = false) {
456
+ const rows = tools.map(suggestTool);
457
+ const width = Math.max(4, ...rows.map((r) => r.name.length));
458
+ const lines = [
459
+ `Tools of: ${serverCommand}`,
460
+ "",
461
+ `${"Tool".padEnd(width)} Suggested Why`,
462
+ ...rows.map(
463
+ (r) => `${r.name.padEnd(width)} ${(r.suggestedMode === "ask" ? "Ask me first" : "Allow").padEnd(12)} ${r.why}${r.governable ? "" : " \u26A0 name not accepted by Guardian"}`
464
+ ),
465
+ "",
466
+ "Register these in the Guardian console (Connections \u2192 Register a tool server \u2192",
467
+ "record-only), then set each mode for your agent. Run the server through",
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."
469
+ ];
470
+ const ungovernable = rows.filter((r) => !r.governable);
471
+ if (ungovernable.length > 0) {
472
+ lines.push(
473
+ "",
474
+ `\u26A0 ${ungovernable.length} tool name(s) don't match Guardian's naming rule`,
475
+ " (lowercase letters/digits, separated by . _ or -; at most 64 chars).",
476
+ " Calls to them will be denied. Leave them out, or use a server version",
477
+ " with compatible names."
478
+ );
479
+ }
480
+ return lines.join("\n");
481
+ }
482
+
228
483
  // src/proxy.ts
229
484
  import {
230
485
  createGuardianContext,
@@ -266,7 +521,46 @@ function jsonResult2(payload, isError = false) {
266
521
  function isDecisionResult(value) {
267
522
  return typeof value === "object" && value !== null && "guardian" in value && typeof value.guardian === "object";
268
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
+ }
269
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
+ }
270
564
  if (err instanceof GuardianExecutionFailed) {
271
565
  return jsonResult2(
272
566
  {
@@ -333,7 +627,7 @@ function createGovernedProxy(options) {
333
627
  mode: "inProcess",
334
628
  onAsk: "result",
335
629
  onDeny: "result",
336
- installRecordTransport: options.autoRegister ?? true,
630
+ installRecordTransport: options.autoRegister ?? false,
337
631
  ...options.adapterId ? { adapterId: options.adapterId } : {},
338
632
  ...options.agentName ? { agentName: options.agentName } : {}
339
633
  });
@@ -354,7 +648,16 @@ function createGovernedProxy(options) {
354
648
  return tools;
355
649
  }
356
650
  function toCallResult(toolName, args, output) {
357
- if (!isDecisionResult(output)) return output;
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
+ }
358
661
  const g = output.guardian;
359
662
  if (g.decision === "ASK") {
360
663
  if (toolInvokeRequestIds.has(g.requestId)) {
@@ -388,16 +691,19 @@ function createGovernedProxy(options) {
388
691
  async function claim(requestId) {
389
692
  const held = pendingCalls.get(requestId);
390
693
  if (held) {
694
+ pendingCalls.delete(requestId);
391
695
  let claimed;
392
696
  try {
393
697
  claimed = await options.client.claim(requestId);
394
698
  } catch {
699
+ pendingCalls.set(requestId, held);
395
700
  return jsonResult2(
396
701
  { requestId, error: "Guardian unreachable; nothing was forwarded." },
397
702
  true
398
703
  );
399
704
  }
400
705
  if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
706
+ pendingCalls.set(requestId, held);
401
707
  return jsonResult2({
402
708
  decision: "ASK",
403
709
  toolName: held.name,
@@ -405,14 +711,13 @@ function createGovernedProxy(options) {
405
711
  message: "Still awaiting human approval. Nothing was sent."
406
712
  });
407
713
  }
408
- pendingCalls.delete(requestId);
409
714
  try {
410
715
  const output = await settle(
411
716
  ctx,
412
717
  held.name,
413
718
  held.args,
414
719
  claimed,
415
- () => upstream.callTool(held.name, held.args)
720
+ callUpstream(upstream, held.name, held.args)
416
721
  );
417
722
  return toCallResult(held.name, held.args, output);
418
723
  } catch (err) {
@@ -463,7 +768,15 @@ function createGovernedProxy(options) {
463
768
  }
464
769
  return {
465
770
  async listTools() {
466
- const tools = await loadTools();
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
+ }
467
780
  return [
468
781
  ...[...tools.values()].map((tool) => ({
469
782
  ...tool,
@@ -491,7 +804,18 @@ function createGovernedProxy(options) {
491
804
  !result.ok
492
805
  );
493
806
  }
494
- const tools = await loadTools();
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
+ }
495
819
  if (!tools.has(name)) {
496
820
  return jsonResult2({ error: `unknown tool: ${name}` }, true);
497
821
  }
@@ -500,7 +824,7 @@ function createGovernedProxy(options) {
500
824
  ctx,
501
825
  name,
502
826
  args,
503
- () => upstream.callTool(name, args)
827
+ callUpstream(upstream, name, args)
504
828
  );
505
829
  return toCallResult(name, args, output);
506
830
  } catch (err) {
@@ -510,7 +834,85 @@ function createGovernedProxy(options) {
510
834
  };
511
835
  }
512
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
+
513
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
+ }
514
916
  var VERSION = createRequire(import.meta.url)("../package.json").version;
515
917
  var parsed = parseCli(process.argv.slice(2), process.env);
516
918
  if (!parsed.ok) {
@@ -519,6 +921,31 @@ if (!parsed.ok) {
519
921
  process.exit(parsed.message.startsWith("guardian-mcp \u2014") ? 0 : 2);
520
922
  }
521
923
  var config = parsed.config;
924
+ if (config.mode.kind === "tools") {
925
+ const mode = config.mode;
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
+ }
940
+ await lister.close();
941
+ const serverCommand = describeTarget(mode);
942
+ process.stdout.write(
943
+ mode.json ? `${JSON.stringify({ server: serverCommand, tools: tools.map(suggestTool) }, null, 2)}
944
+ ` : `${renderSuggestions(serverCommand, tools, "remote" in mode)}
945
+ `
946
+ );
947
+ process.exit(0);
948
+ }
522
949
  var client = createGuardianClient({
523
950
  baseUrl: config.baseUrl,
524
951
  credential: config.credential,
@@ -529,18 +956,10 @@ var server = new Server(
529
956
  { capabilities: { tools: {} } }
530
957
  );
531
958
  if (config.mode.kind === "proxy") {
532
- const upstreamClient = new Client(
533
- { name: "guardian-mcp-proxy", version: VERSION },
534
- { capabilities: {} }
535
- );
536
- await upstreamClient.connect(
537
- new StdioClientTransport({
538
- command: config.mode.command,
539
- args: config.mode.args,
540
- env: upstreamEnv(process.env),
541
- stderr: "inherit"
542
- })
543
- );
959
+ const upstreamClient = await connectOrExit(config.mode, process.env, {
960
+ name: "guardian-mcp-proxy",
961
+ version: VERSION
962
+ });
544
963
  const proxy = createGovernedProxy({
545
964
  client,
546
965
  connectionId: config.connectionId,
@@ -548,26 +967,7 @@ if (config.mode.kind === "proxy") {
548
967
  ...config.adapterId ? { adapterId: config.adapterId } : {},
549
968
  log: (message) => process.stderr.write(`${message}
550
969
  `),
551
- upstream: {
552
- async listTools() {
553
- const tools = [];
554
- let cursor;
555
- do {
556
- const page = await upstreamClient.listTools(
557
- cursor ? { cursor } : void 0
558
- );
559
- tools.push(...page.tools);
560
- cursor = page.nextCursor;
561
- } while (cursor);
562
- return tools;
563
- },
564
- async callTool(name, args) {
565
- return await upstreamClient.callTool({
566
- name,
567
- arguments: args
568
- });
569
- }
570
- }
970
+ upstream: clientUpstream(upstreamClient)
571
971
  });
572
972
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
573
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.0",
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.0"
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": "^3.2.4"
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://guardian.odingard.com"
44
+ "homepage": "https://odingard.com/guardian"
44
45
  }
package/server.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
3
3
  "name": "io.github.Odingard/guardian-mcp",
4
- "description": "MCP server that routes an agent's tool calls through Guardian — Human Authority Infrastructure for Autonomous AI. Every tool call gets a deterministic ALLOW/ASK/DENY decision before anything executes; ASK holds the call until a human approves it, DENY never runs, and every outcome carries a verifiable receipt.",
5
- "version": "0.3.0",
4
+ "description": "Routes agent tool calls through Guardian for ALLOW/ASK/DENY before execution.",
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.0",
29
+ "version": "0.3.3",
30
30
  "transport": {
31
31
  "type": "stdio"
32
32
  },
@@ -38,7 +38,7 @@
38
38
  },
39
39
  {
40
40
  "name": "GUARDIAN_AGENT_CREDENTIAL",
41
- "description": "Agent credential (gac1.…)",
41
+ "description": "Agent credential (gak_…)",
42
42
  "isRequired": true,
43
43
  "isSecret": true
44
44
  },