@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 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. 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,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
- Example (proxy):
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 after = argv.slice(json ? 2 : 1);
45
- const rest = after[0] === "--" ? after.slice(1) : after;
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", command, args, json }
62
+ mode: { kind: "tools", json, ...target.target }
62
63
  }
63
64
  };
64
65
  }
65
66
  if (argv[0] === "proxy") {
66
- const rest = argv[1] === "--" ? argv.slice(2) : argv.slice(1);
67
- const [command, ...args] = rest;
68
- if (!command) {
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
- // WO-050: off by default — production denies first-use registration
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 (!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
+ }
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
- () => upstream.callTool(held.name, held.args)
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
- 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
+ }
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
- 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
+ }
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
- () => upstream.callTool(name, args)
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 = new Client(
637
- { name: "guardian-mcp-tools", version: VERSION },
638
- { capabilities: {} }
639
- );
640
- await lister.connect(
641
- new StdioClientTransport({
642
- command: mode.command,
643
- args: mode.args,
644
- env: upstreamEnv(process.env),
645
- stderr: "inherit"
646
- })
647
- );
648
- const tools = [];
649
- let cursor;
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 = [mode.command, ...mode.args].join(" ");
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 = new Client(
675
- { name: "guardian-mcp-proxy", version: VERSION },
676
- { capabilities: {} }
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.2",
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
@@ -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.2",
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.2",
29
+ "version": "0.3.3",
30
30
  "transport": {
31
31
  "type": "stdio"
32
32
  },