@odingard/guardian-mcp 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,18 @@ npm i -g @odingard/guardian-mcp
39
39
 
40
40
  Nothing executes without a Guardian capability — the model's output is a proposal, never an action.
41
41
 
42
+ Only a call whose receipt status is `sent-verified` is returned as a plain
43
+ result. Every other outcome is an MCP tool error (`isError: true`) that says
44
+ what happened:
45
+
46
+ - DENY, or Guardian could not be reached: nothing was done. A denial says not
47
+ to retry the same call; an outage says to try again later.
48
+ - Receipt status `not-sent`: `outcome: "not-done"`, the call was not done.
49
+ - Receipt status `outcome-pending` or `outcome-unconfirmed`, or an execute
50
+ answer that was refused or lost: `outcome: "unknown"`. The call may have
51
+ been done, so check (`guardian_receipt`, or the target system) before
52
+ retrying.
53
+
42
54
  ## Govern an existing MCP server: `guardian-mcp proxy` (0.3)
43
55
 
44
56
  Put Guardian in front of any stdio MCP server you already use, such as
@@ -62,11 +74,12 @@ upstream server's tools unchanged, and every call is decided first:
62
74
  }
63
75
  ```
64
76
 
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
- | **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. |
77
+ | Decision | What happens |
78
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
79
+ | **ALLOW** | The call is forwarded to the upstream server, then the capability is redeemed and the receipt recorded. The upstream result is returned unchanged, with Guardian's `requestId` under `_meta["com.odingard/guardian"]`, so the client can fetch the receipt with `guardian_receipt`. |
80
+ | **ASK** | Nothing is forwarded. The exact call is held under a `requestId`; after a human approves, `guardian_claim` forwards exactly that call, once. |
81
+ | **DENY** | Nothing is forwarded, ever. The result is an MCP tool error (`isError: true`) with the reason code and, when Guardian made a record of it, the `requestId`. |
82
+ | **Guardian unreachable** | Nothing is forwarded. The result is a tool error (`isError: true`) whose JSON keeps `decision: "DENY"` with `reasonCode: "SERVICE_UNAVAILABLE"`, and its message says Guardian could not be reached and the call can be tried again later. A held call stays held, so `guardian_claim` can be retried. |
70
83
 
71
84
  - `guardian_claim` and `guardian_receipt` are added to the tool list.
72
85
  - The upstream server never receives `GUARDIAN_*` settings, so it never sees
@@ -79,20 +92,59 @@ upstream server's tools unchanged, and every call is decided first:
79
92
  for the agent.
80
93
  - Since 0.3.1 the proxy does not try to register tools itself.
81
94
  - `GUARDIAN_AUTO_REGISTER=true` turns that back on, but it is denied unless
82
- the agent's grant pins `mcp.install`.
95
+ the agent's grant pins `mcp.install`, and it only works for a tool that
96
+ has no entry yet. An "already registered" answer is refused
97
+ (`RECORD_TRANSPORT_UNVERIFIED`) and nothing is forwarded, including after
98
+ a proxy restart for an entry the proxy created itself. Once the entries
99
+ exist, run the proxy without it.
83
100
  - When Guardian refuses, the result says what to fix.
84
101
  - Held calls live in the proxy process. Restarting the proxy drops them, and
85
102
  nothing is forwarded.
103
+ - **Cancelled requests are not forwarded.** If the client cancels a
104
+ `tools/call` (or it times out) before the call is sent upstream, for
105
+ example while Guardian is still deciding, the call is not forwarded and the
106
+ capability is not redeemed. A `guardian_claim` cancelled that way keeps the
107
+ call held with its approval, so the next `guardian_claim` forwards it
108
+ without asking the person again. Once a call has been sent upstream it runs
109
+ to its real outcome, which is recorded.
110
+ - **A second `guardian_claim` of the same `requestId`** says the call was
111
+ already forwarded and must not be proposed again. If the first claim has
112
+ finished, its result is returned again after that notice; if it is still
113
+ running, the answer says to check `guardian_receipt`. The proxy remembers
114
+ the last 1000 claimed requests. A `requestId` it does not hold is answered
115
+ with "this claim forwarded nothing", and a reminder to check
116
+ `guardian_receipt` if it was claimed before.
117
+ - An unexpected proxy error after a call was forwarded says the call may
118
+ have run, never that it was not forwarded.
86
119
 
87
120
  `guardian-mcp --help` prints usage. A missing setting prints a one-line
88
121
  message naming it instead of a stack trace.
89
122
 
90
- ## Govern a remote MCP server: `guardian-mcp proxy --url` (unreleased)
123
+ ### Pin the contents of file arguments: `--pin-file` (0.4)
124
+
125
+ Approval binds the exact arguments. A file path is an argument; the file's contents are not, unless you declare the argument with `--pin-file`.
126
+
127
+ ```bash
128
+ guardian-mcp proxy --pin-file upload_file:path --pin-file send_email:attachment -- npx -y my-mcp-server
129
+ ```
130
+
131
+ For each declared `<tool>:<argument>` the proxy (the SDK on your machine) reads the file the path names, puts its SHA-256 in the approved arguments, and checks it again just before the call is forwarded. Guardian's servers never see the file. If the file changed, nothing is forwarded, the capability is not redeemed, and the result says `CONTENT_CHANGED_SINCE_APPROVAL`; propose the call again for a new approval. A call held for approval keeps the arguments that were proposed, pins included, and `guardian_claim` checks the file against them. After a proxy restart a claim finds nothing; call the tool again, which pins the file again. The upstream server receives the approved arguments without the reserved key `guardianContentPins`. In code: `createGovernedProxy({ ..., pinFiles: { upload_file: ["path"] } })`.
132
+
133
+ - The mode is always `recheck`: the upstream server opens the path itself, so a short window remains between the check and its open.
134
+ - Stdio upstreams only. With `--url` (HTTP or SSE) the path names a file on another machine, so `--pin-file` is refused at start (`CONTENT_PIN_REMOTE_UPSTREAM`). Paths on a remote MCP server are not pinned.
135
+ - File paths must be absolute. The proxy does not resolve relative paths or `~`.
136
+ - Content pinning is supported on Linux and macOS. On Windows a declared file argument is refused (`CONTENT_PIN_UNSUPPORTED_PLATFORM`).
137
+ - **Digest privacy:** the SHA-256 of each pinned file is sent to Guardian, shown to the approver and kept in receipts. For a small or low-entropy file (a token, a PIN, a short config value), anyone who sees the digest can guess the contents offline by hashing candidates. Do not pin files like that, or treat the digest as sensitive as the file.
138
+ - This stops a changed file from running under an approval given for different contents. It does not protect against code that bypasses the proxy, and files a tool opens without naming them in a declared argument are not pinned.
139
+
140
+ ## Govern a remote MCP server: `guardian-mcp proxy --url` (0.3.3 and later)
91
141
 
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.
142
+ **Status: tested against a real Guardian; production proof pending.** It ran
143
+ end to end over Streamable HTTP against the real Guardian server code (allow,
144
+ ask then approve, deny, outage), not yet on hosted production. SSE is tested
145
+ against local servers and a stand-in Guardian only, and no real hosted server
146
+ such as Composio has been tried. Composio's `x-api-key` header is
147
+ unconfirmed.
96
148
 
97
149
  The proxy can also front an MCP server that runs online, such as Composio.
98
150
  The agent still starts `guardian-mcp` over stdio like any
@@ -157,7 +209,8 @@ A server that takes a Bearer key: `--url https://mcp.example.com/mcp --header
157
209
  unavailable" error. The upstream's error text (which can quote its URL
158
210
  path, response body or headers) is not shown to the agent or logged;
159
211
  `guardian-mcp tools --url` prints it with the URL, its path identifiers,
160
- query values and header values scrubbed.
212
+ query values and header values scrubbed. Each query value is scrubbed as
213
+ typed (a `+` stays a `+`), percent-decoded, and with `+` read as a space.
161
214
 
162
215
  What is **not** governed: a remote server the agent connects to directly
163
216
  (for example an MCP client's own `"url"` entry, or a model provider's hosted
@@ -175,9 +228,8 @@ MCP connector), and anything the agent does without MCP. Also not covered:
175
228
  [ZeroClaw](../../examples/zeroclaw/README.md) and
176
229
  [your own container (CrewAI, LangGraph, AutoGen)](../../examples/byo/README.md).
177
230
  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.
231
+ Guardian settings). `--url` is in the published `@odingard/guardian-mcp`
232
+ from 0.3.3.
181
233
 
182
234
  ## See what you are about to govern: `guardian-mcp tools`
183
235
 
@@ -197,8 +249,10 @@ createIssue Ask me first "create" may change something ⚠ name not accept
197
249
 
198
250
  How the suggestions are made:
199
251
 
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.
252
+ - A tool the server marks destructive (`destructiveHint`) suggests Ask.
253
+ - So does a tool whose name contains a verb that changes something or sends data out, anywhere in the name (`create`, `update`, `delete`, `replace`, `send`, `run`, `write` and similar), even when the server marks it read-only. `get_or_create_customer`, `find_and_replace` and `list_and_delete_old_files` suggest Ask.
254
+ - Otherwise a tool the server marks read-only (`readOnlyHint`) suggests Allow, and so does a name that starts with a read verb (`get`, `list`, `search`, `read`, `describe`, `show`, `view`, `find`, `lookup`, `count`, `inspect`).
255
+ - Everything else suggests Ask, including `fetch` (it can send data to any URL) and `status`.
202
256
 
203
257
  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
258
 
package/dist/index.js CHANGED
@@ -16,6 +16,11 @@ var USAGE = `guardian-mcp \u2014 route an agent's tool calls through Guardian (A
16
16
  Usage:
17
17
  guardian-mcp Guardian tools (guardian_invoke_tool, guardian_claim, guardian_receipt)
18
18
  guardian-mcp proxy -- <command> [args...] Govern every tool of an existing stdio MCP server
19
+ guardian-mcp proxy --pin-file <tool>:<arg> [--pin-file ...] -- <command> [args...]
20
+ Also pin the file a path argument names: its SHA-256
21
+ goes into the approved arguments and is checked again
22
+ just before the call is forwarded (stdio upstreams
23
+ only; the path must be absolute)
19
24
  guardian-mcp proxy --url <https-url> [--transport streamable-http|sse] [--header "Name: value"]...
20
25
  Govern every tool of a remote MCP server (Streamable
21
26
  HTTP by default, or SSE). The agent still talks stdio.
@@ -64,9 +69,24 @@ function parseCli(argv, env) {
64
69
  };
65
70
  }
66
71
  if (argv[0] === "proxy") {
67
- const target = parseTarget("proxy", argv.slice(1), env);
72
+ const pins = extractPinFiles(argv.slice(1));
73
+ if (!pins.ok) return pins;
74
+ const target = parseTarget("proxy", pins.rest, env);
68
75
  if (!target.ok) return target;
69
- mode = { kind: "proxy", ...target.target };
76
+ const declared = Object.keys(pins.pinFiles).length > 0;
77
+ if (declared && "remote" in target.target) {
78
+ return {
79
+ ok: false,
80
+ message: `guardian-mcp proxy: --pin-file is supported only for a stdio upstream (CONTENT_PIN_REMOTE_UPSTREAM). A remote MCP server opens paths on another machine, so the proxy cannot check the file it will read.
81
+
82
+ ${USAGE}`
83
+ };
84
+ }
85
+ mode = {
86
+ kind: "proxy",
87
+ ...target.target,
88
+ ...declared ? { pinFiles: pins.pinFiles } : {}
89
+ };
70
90
  } else if (argv.length > 0) {
71
91
  return {
72
92
  ok: false,
@@ -135,6 +155,42 @@ function expandEnv(cmd, value, env, where) {
135
155
  value: value.replace(ENV_REF, (_, name) => env[name])
136
156
  };
137
157
  }
158
+ function extractPinFiles(argv) {
159
+ const pinFiles = {};
160
+ const rest = [];
161
+ const fail = (message) => ({
162
+ ok: false,
163
+ message: `guardian-mcp proxy: ${message}
164
+
165
+ ${USAGE}`
166
+ });
167
+ let i = 0;
168
+ for (; i < argv.length; i++) {
169
+ const arg = argv[i];
170
+ if (arg === "--") break;
171
+ let value;
172
+ if (arg === "--pin-file") {
173
+ value = argv[++i];
174
+ } else if (arg.startsWith("--pin-file=")) {
175
+ value = arg.slice("--pin-file=".length);
176
+ } else {
177
+ rest.push(arg);
178
+ continue;
179
+ }
180
+ const colon = value?.lastIndexOf(":") ?? -1;
181
+ const tool = colon > 0 ? value.slice(0, colon) : "";
182
+ const name = colon > 0 ? value.slice(colon + 1) : "";
183
+ if (!tool || !name || name.includes(".")) {
184
+ return fail(
185
+ '--pin-file needs "<tool>:<argument>", naming a top-level argument.'
186
+ );
187
+ }
188
+ const list = pinFiles[tool] ??= [];
189
+ if (!list.includes(name)) list.push(name);
190
+ }
191
+ rest.push(...argv.slice(i));
192
+ return { ok: true, pinFiles, rest };
193
+ }
138
194
  function parseTarget(cmd, argv, env) {
139
195
  const fail = (message) => ({
140
196
  ok: false,
@@ -235,6 +291,18 @@ function secretsOf(target) {
235
291
  const parsed2 = new URL(url);
236
292
  if (parsed2.search) out.add(parsed2.search.slice(1));
237
293
  for (const v of parsed2.searchParams.values()) if (v.length >= 4) out.add(v);
294
+ for (const pair of parsed2.search.slice(1).split("&")) {
295
+ const raw = pair.slice(pair.indexOf("=") + 1);
296
+ if (pair.indexOf("=") < 0 || raw.length < 4) continue;
297
+ const forms = [raw, raw.replace(/\+/g, " ")];
298
+ for (const form of [...forms]) {
299
+ try {
300
+ forms.push(decodeURIComponent(form));
301
+ } catch {
302
+ }
303
+ }
304
+ for (const form of forms) if (form.length >= 4) out.add(form);
305
+ }
238
306
  for (const segment of parsed2.pathname.split("/")) {
239
307
  if (segment.length >= 16 || segment.length >= 8 && /\d/.test(segment)) {
240
308
  out.add(segment);
@@ -308,11 +376,26 @@ var TOOLS = [
308
376
  }
309
377
  ];
310
378
  function jsonResult(payload) {
311
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
379
+ return {
380
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
381
+ };
312
382
  }
313
383
  function errorResult(message) {
314
384
  return { isError: true, content: [{ type: "text", text: message }] };
315
385
  }
386
+ function jsonError(payload) {
387
+ return { ...jsonResult(payload), isError: true };
388
+ }
389
+ var CHECK_FIRST = "Call guardian_receipt with this requestId, or check the target system, before retrying.";
390
+ function denied(reasonCode, requestId) {
391
+ const code = reasonCode ?? "DENIED";
392
+ return jsonError({
393
+ decision: "DENY",
394
+ reasonCode: code,
395
+ ...requestId ? { requestId } : {},
396
+ message: code === "SERVICE_UNAVAILABLE" ? "Guardian could not be reached, so nothing was done. Try again later." : "Guardian denied this call. Nothing was done. Do not retry the same call."
397
+ });
398
+ }
316
399
  async function requestAndExecute(client2, result) {
317
400
  if (!result.ok) {
318
401
  const heldRequestId = result.requestId;
@@ -324,33 +407,62 @@ async function requestAndExecute(client2, result) {
324
407
  next: "Held for human approval. Call guardian_claim with this requestId after approval."
325
408
  });
326
409
  }
327
- return jsonResult({ decision: "DENY", reasonCode: result.reasonCode });
410
+ return denied(
411
+ result.reasonCode,
412
+ typeof heldRequestId === "string" && heldRequestId.length > 0 ? heldRequestId : void 0
413
+ );
328
414
  }
329
415
  if (result.decision === "ALLOW" && result.capabilityToken) {
330
416
  const executed = await client2.execute({
331
417
  requestId: result.requestId,
332
418
  capabilityToken: result.capabilityToken
333
419
  });
334
- return jsonResult(
335
- executed.ok ? {
420
+ if (!executed.ok) {
421
+ return jsonError({
422
+ decision: "ALLOW",
423
+ requestId: result.requestId,
424
+ outcome: "unknown",
425
+ executionFailed: executed.reasonCode,
426
+ message: `Guardian could not confirm the outcome (${executed.reasonCode}). The call may have been done. ${CHECK_FIRST}`
427
+ });
428
+ }
429
+ const status = executed.receipt?.status;
430
+ if (status === "sent-verified") {
431
+ return jsonResult({
336
432
  decision: "ALLOW",
337
433
  requestId: result.requestId,
338
434
  receipt: executed.receipt
339
- } : {
435
+ });
436
+ }
437
+ if (status === "not-sent") {
438
+ return jsonError({
340
439
  decision: "ALLOW",
341
440
  requestId: result.requestId,
342
- executionFailed: executed.reasonCode
343
- }
344
- );
441
+ outcome: "not-done",
442
+ receipt: executed.receipt,
443
+ message: "Guardian did not send the call (receipt status not-sent). It was not done."
444
+ });
445
+ }
446
+ return jsonError({
447
+ decision: "ALLOW",
448
+ requestId: result.requestId,
449
+ outcome: "unknown",
450
+ receipt: executed.receipt,
451
+ message: `The outcome is unknown (receipt status ${typeof status === "string" ? status : "missing"}). The call may have been done. ${CHECK_FIRST}`
452
+ });
345
453
  }
346
- return jsonResult({
347
- decision: result.decision,
348
- requestId: result.requestId,
349
- ...result.reasonCode ? { reasonCode: result.reasonCode } : {},
350
- ...result.decision === "ASK" ? {
454
+ if (result.decision === "ASK") {
455
+ return jsonResult({
456
+ decision: "ASK",
457
+ requestId: result.requestId,
458
+ ...result.reasonCode ? { reasonCode: result.reasonCode } : {},
351
459
  message: "Held for human approval. Call guardian_claim with this requestId after approval."
352
- } : {}
353
- });
460
+ });
461
+ }
462
+ return denied(
463
+ result.reasonCode ?? (result.decision === "DENY" ? void 0 : "MISSING_CAPABILITY"),
464
+ result.requestId
465
+ );
354
466
  }
355
467
  async function handleToolCall(options, name, arguments_) {
356
468
  const { client: client2, defaultConnectionId, adapterId } = options;
@@ -410,12 +522,85 @@ var READ_VERBS = /* @__PURE__ */ new Set([
410
522
  "find",
411
523
  "lookup",
412
524
  "count",
413
- "inspect",
414
- "status",
415
- "fetch"
525
+ "inspect"
526
+ ]);
527
+ var WRITE_VERBS = /* @__PURE__ */ new Set([
528
+ "add",
529
+ "append",
530
+ "apply",
531
+ "approve",
532
+ "archive",
533
+ "assign",
534
+ "buy",
535
+ "cancel",
536
+ "charge",
537
+ "clear",
538
+ "close",
539
+ "commit",
540
+ "copy",
541
+ "create",
542
+ "delete",
543
+ "deploy",
544
+ "destroy",
545
+ "disable",
546
+ "drop",
547
+ "edit",
548
+ "enable",
549
+ "exec",
550
+ "execute",
551
+ "fork",
552
+ "grant",
553
+ "import",
554
+ "insert",
555
+ "install",
556
+ "invite",
557
+ "kill",
558
+ "lock",
559
+ "merge",
560
+ "modify",
561
+ "move",
562
+ "notify",
563
+ "patch",
564
+ "pay",
565
+ "post",
566
+ "publish",
567
+ "purge",
568
+ "push",
569
+ "put",
570
+ "refund",
571
+ "remove",
572
+ "rename",
573
+ "replace",
574
+ "reply",
575
+ "reset",
576
+ "restart",
577
+ "restore",
578
+ "revoke",
579
+ "rm",
580
+ "run",
581
+ "save",
582
+ "schedule",
583
+ "sell",
584
+ "send",
585
+ "set",
586
+ "share",
587
+ "sign",
588
+ "start",
589
+ "stop",
590
+ "submit",
591
+ "sync",
592
+ "transfer",
593
+ "trigger",
594
+ "truncate",
595
+ "uninstall",
596
+ "unlock",
597
+ "update",
598
+ "upload",
599
+ "upsert",
600
+ "write"
416
601
  ]);
417
- function firstWord(name) {
418
- return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).find(Boolean) ?? "";
602
+ function words(name) {
603
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
419
604
  }
420
605
  function suggestTool(tool) {
421
606
  const governable = GUARDIAN_TOOL_NAME.test(tool.name) && tool.name.length <= 64;
@@ -428,6 +613,16 @@ function suggestTool(tool) {
428
613
  governable
429
614
  };
430
615
  }
616
+ const tokens = words(tool.name);
617
+ const writeVerb = tokens.find((token) => WRITE_VERBS.has(token));
618
+ if (writeVerb) {
619
+ return {
620
+ name: tool.name,
621
+ suggestedMode: "ask",
622
+ why: `"${writeVerb}" may change something`,
623
+ governable
624
+ };
625
+ }
431
626
  if (annotations.readOnlyHint === true) {
432
627
  return {
433
628
  name: tool.name,
@@ -436,7 +631,7 @@ function suggestTool(tool) {
436
631
  governable
437
632
  };
438
633
  }
439
- const verb = firstWord(tool.name);
634
+ const verb = tokens[0] ?? "";
440
635
  if (READ_VERBS.has(verb)) {
441
636
  return {
442
637
  name: tool.name,
@@ -482,6 +677,8 @@ function renderSuggestions(serverCommand, tools, remote = false) {
482
677
 
483
678
  // src/proxy.ts
484
679
  import {
680
+ CONTENT_PIN_KEY,
681
+ CONTENT_PIN_REMOTE_UPSTREAM,
485
682
  createGuardianContext,
486
683
  runThroughGuardian,
487
684
  settle,
@@ -490,8 +687,40 @@ import {
490
687
  GuardianExecutionFailed,
491
688
  GuardianRecordTransportRequired
492
689
  } from "@odingard/guardian-sdk";
690
+ var ContentPinConfigError = class extends Error {
691
+ constructor(reasonCode, message) {
692
+ super(message);
693
+ this.reasonCode = reasonCode;
694
+ this.name = "ContentPinConfigError";
695
+ }
696
+ reasonCode;
697
+ };
698
+ function pinFilesToFileArgs(pinFiles) {
699
+ const fileArgs = {};
700
+ for (const [toolName, args] of Object.entries(pinFiles ?? {})) {
701
+ if (args.length === 0) continue;
702
+ fileArgs[toolName] = Object.fromEntries(
703
+ args.map((arg) => [arg, "recheck"])
704
+ );
705
+ }
706
+ return fileArgs;
707
+ }
493
708
  var CLAIM_TOOL = "guardian_claim";
494
709
  var RECEIPT_TOOL = "guardian_receipt";
710
+ var GUARDIAN_RESULT_META = "com.odingard/guardian";
711
+ var UNAVAILABLE = "SERVICE_UNAVAILABLE";
712
+ function unreachableResult(toolName, requestId) {
713
+ return jsonResult2(
714
+ {
715
+ decision: "DENY",
716
+ toolName,
717
+ reasonCode: UNAVAILABLE,
718
+ ...requestId ? { requestId } : {},
719
+ message: `Guardian could not be reached, so ${toolName} was not checked and nothing was forwarded. Try again later.`
720
+ },
721
+ true
722
+ );
723
+ }
495
724
  var PROXY_GOVERNANCE_TOOLS = [
496
725
  {
497
726
  name: CLAIM_TOOL,
@@ -518,6 +747,14 @@ function jsonResult2(payload, isError = false) {
518
747
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
519
748
  };
520
749
  }
750
+ function withRequestId(result, requestId) {
751
+ if (!requestId) return result;
752
+ const meta = typeof result._meta === "object" && result._meta !== null ? result._meta : {};
753
+ return {
754
+ ...result,
755
+ _meta: { ...meta, [GUARDIAN_RESULT_META]: { requestId } }
756
+ };
757
+ }
521
758
  function isDecisionResult(value) {
522
759
  return typeof value === "object" && value !== null && "guardian" in value && typeof value.guardian === "object";
523
760
  }
@@ -533,6 +770,14 @@ var UPSTREAM_RESULT = /* @__PURE__ */ Symbol("guardian-mcp.upstreamResult");
533
770
  function upstreamRan(value) {
534
771
  return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, UPSTREAM_RESULT);
535
772
  }
773
+ var UpstreamCallCancelled = class extends Error {
774
+ constructor(toolName) {
775
+ super("the call was cancelled before it was forwarded");
776
+ this.toolName = toolName;
777
+ this.name = "UpstreamCallCancelled";
778
+ }
779
+ toolName;
780
+ };
536
781
  var UpstreamListFailed = class extends Error {
537
782
  constructor() {
538
783
  super(
@@ -541,16 +786,50 @@ var UpstreamListFailed = class extends Error {
541
786
  this.name = "UpstreamListFailed";
542
787
  }
543
788
  };
544
- function callUpstream(upstream, name, args) {
545
- return async () => {
789
+ function forwardable(approved) {
790
+ const record = approved !== null && typeof approved === "object" && !Array.isArray(approved) ? { ...approved } : {};
791
+ delete record[CONTENT_PIN_KEY];
792
+ return record;
793
+ }
794
+ function callUpstream(upstream, name, signal, forwarding) {
795
+ return async (approved) => {
796
+ const args = forwardable(approved);
797
+ if (signal?.aborted) {
798
+ throw new UpstreamCallCancelled(name);
799
+ }
800
+ forwarding.sent = true;
546
801
  try {
547
- return { [UPSTREAM_RESULT]: await upstream.callTool(name, args) };
548
- } catch {
802
+ return {
803
+ [UPSTREAM_RESULT]: await upstream.callTool(
804
+ name,
805
+ args,
806
+ signal ? { signal } : void 0
807
+ )
808
+ };
809
+ } catch (err) {
810
+ if (err instanceof UpstreamCallCancelled) {
811
+ forwarding.sent = false;
812
+ throw err;
813
+ }
549
814
  throw new UpstreamCallFailed(name);
550
815
  }
551
816
  };
552
817
  }
553
- function failure(err, toolName) {
818
+ function cancelledResult(toolName, requestId) {
819
+ return jsonResult2(
820
+ {
821
+ toolName,
822
+ ...requestId ? { requestId } : {},
823
+ cancelled: true,
824
+ message: requestId ? `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded; the approved call is still held, so call ${CLAIM_TOOL} with this requestId to forward it.` : `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded.`
825
+ },
826
+ true
827
+ );
828
+ }
829
+ function failure(err, toolName, forwarding) {
830
+ if (err instanceof UpstreamCallCancelled) {
831
+ return cancelledResult(toolName);
832
+ }
554
833
  if (err instanceof UpstreamCallFailed) {
555
834
  return jsonResult2(
556
835
  {
@@ -561,6 +840,19 @@ function failure(err, toolName) {
561
840
  true
562
841
  );
563
842
  }
843
+ if (err instanceof GuardianExecutionFailed && !err.localEffectRan && (err.reasonCode === "CONTENT_CHANGED_SINCE_APPROVAL" || err.reasonCode.startsWith("CONTENT_PIN_"))) {
844
+ return jsonResult2(
845
+ {
846
+ decision: "ALLOW",
847
+ toolName,
848
+ requestId: err.requestId,
849
+ executionFailed: err.reasonCode,
850
+ localEffectRan: false,
851
+ message: err.reasonCode === "CONTENT_CHANGED_SINCE_APPROVAL" ? "A file named in the approved call changed since it was approved. Nothing was forwarded and the capability was not redeemed. Propose the call again; it needs a new approval." : "A file named in the approved call could not be checked against its approval. Nothing was forwarded and the capability was not redeemed."
852
+ },
853
+ true
854
+ );
855
+ }
564
856
  if (err instanceof GuardianExecutionFailed) {
565
857
  return jsonResult2(
566
858
  {
@@ -569,12 +861,16 @@ function failure(err, toolName) {
569
861
  requestId: err.requestId,
570
862
  executionFailed: err.reasonCode,
571
863
  localEffectRan: err.localEffectRan,
572
- message: err.localEffectRan ? "The upstream call ran but Guardian refused to redeem the capability. Treat this as an incident, not a success." : "Guardian could not redeem the capability; the call was not completed."
864
+ ...err.outcomeUnknown ? { outcomeUnknown: true } : {},
865
+ message: err.outcomeUnknown ? err.localEffectRan ? `The upstream call ran, but Guardian could not confirm that it was recorded. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : `Guardian could not confirm whether the call completed. It may have run. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : err.localEffectRan ? "The upstream call ran but Guardian refused to redeem the capability. Do not call it again. Treat this as an incident, not a success." : "Guardian could not redeem the capability; the call was not completed."
573
866
  },
574
867
  true
575
868
  );
576
869
  }
577
870
  if (err instanceof GuardianDenied) {
871
+ if (err.reasonCode === UNAVAILABLE) {
872
+ return unreachableResult(toolName, err.requestId);
873
+ }
578
874
  return jsonResult2(
579
875
  {
580
876
  decision: "DENY",
@@ -599,17 +895,35 @@ function failure(err, toolName) {
599
895
  return jsonResult2(
600
896
  {
601
897
  toolName,
602
- error: "Guardian proxy error; the call was not forwarded."
898
+ error: forwarding.sent ? "Guardian proxy error after the call was forwarded. It may have run; check before retrying." : "Guardian proxy error; the call was not forwarded."
603
899
  },
604
900
  true
605
901
  );
606
902
  }
903
+ var CLAIMED_REQUESTS_LIMIT = 1e3;
607
904
  function createGovernedProxy(options) {
608
905
  const { upstream, log = () => {
609
906
  } } = options;
907
+ const fileArgs = pinFilesToFileArgs(options.pinFiles);
908
+ if (Object.keys(fileArgs).length > 0 && options.upstreamTransport === "remote") {
909
+ throw new ContentPinConfigError(
910
+ CONTENT_PIN_REMOTE_UPSTREAM,
911
+ "guardian-mcp proxy: --pin-file is supported only for a stdio upstream. A remote (HTTP/SSE) MCP server opens paths on another machine, so the proxy cannot check the file it will read."
912
+ );
913
+ }
610
914
  const toolInvokeRequestIds = /* @__PURE__ */ new Set();
611
915
  const pendingCalls = /* @__PURE__ */ new Map();
612
916
  const pendingInstalls = /* @__PURE__ */ new Map();
917
+ const claimedCapabilities = /* @__PURE__ */ new Map();
918
+ const claimedRequests = /* @__PURE__ */ new Map();
919
+ const rememberClaim = (requestId, entry) => {
920
+ claimedRequests.delete(requestId);
921
+ claimedRequests.set(requestId, entry);
922
+ while (claimedRequests.size > CLAIMED_REQUESTS_LIMIT) {
923
+ const oldest = claimedRequests.keys().next().value;
924
+ claimedRequests.delete(oldest);
925
+ }
926
+ };
613
927
  const recordingClient = {
614
928
  ...options.client,
615
929
  async request(invocation) {
@@ -629,8 +943,30 @@ function createGovernedProxy(options) {
629
943
  onDeny: "result",
630
944
  installRecordTransport: options.autoRegister ?? false,
631
945
  ...options.adapterId ? { adapterId: options.adapterId } : {},
632
- ...options.agentName ? { agentName: options.agentName } : {}
946
+ ...options.agentName ? { agentName: options.agentName } : {},
947
+ ...Object.keys(fileArgs).length > 0 ? { fileArgs } : {}
633
948
  });
949
+ ctx.installed ??= /* @__PURE__ */ new Set();
950
+ ctx.proposedInputs = /* @__PURE__ */ new Map();
951
+ function heldSetupStep(toolName, requestId) {
952
+ const held = ctx.heldSetup?.get(`${options.connectionId}:${toolName}`);
953
+ return held?.requestId === requestId ? held.step : void 0;
954
+ }
955
+ function tracked() {
956
+ let allowed;
957
+ return {
958
+ ctx: {
959
+ ...ctx,
960
+ options: {
961
+ ...ctx.options,
962
+ onReceipt: (event) => {
963
+ allowed = event.requestId;
964
+ }
965
+ }
966
+ },
967
+ allowedRequestId: () => allowed
968
+ };
969
+ }
634
970
  let upstreamTools = null;
635
971
  async function loadTools() {
636
972
  if (upstreamTools) return upstreamTools;
@@ -647,8 +983,10 @@ function createGovernedProxy(options) {
647
983
  upstreamTools = tools;
648
984
  return tools;
649
985
  }
650
- function toCallResult(toolName, args, output) {
651
- if (upstreamRan(output)) return output[UPSTREAM_RESULT];
986
+ function toCallResult(toolName, args, output, allowedRequestId) {
987
+ if (upstreamRan(output)) {
988
+ return withRequestId(output[UPSTREAM_RESULT], allowedRequestId);
989
+ }
652
990
  if (!isDecisionResult(output)) {
653
991
  return jsonResult2(
654
992
  {
@@ -661,7 +999,9 @@ function createGovernedProxy(options) {
661
999
  const g = output.guardian;
662
1000
  if (g.decision === "ASK") {
663
1001
  if (toolInvokeRequestIds.has(g.requestId)) {
664
- pendingCalls.set(g.requestId, { name: toolName, args });
1002
+ const proposed = ctx.proposedInputs?.get(g.requestId) ?? args;
1003
+ ctx.proposedInputs?.delete(g.requestId);
1004
+ pendingCalls.set(g.requestId, { name: toolName, args, proposed });
665
1005
  return jsonResult2({
666
1006
  decision: "ASK",
667
1007
  toolName,
@@ -670,40 +1010,92 @@ function createGovernedProxy(options) {
670
1010
  });
671
1011
  }
672
1012
  pendingInstalls.set(g.requestId, toolName);
1013
+ const step = heldSetupStep(toolName, g.requestId);
673
1014
  return jsonResult2({
674
1015
  decision: "ASK",
675
1016
  toolName,
676
1017
  requestId: g.requestId,
677
- message: `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
1018
+ message: step === "enable" ? `Switching on ${toolName} for this agent needs a human to approve it in Guardian. After approval call ${CLAIM_TOOL} with this requestId (or retry the original call).` : `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
678
1019
  });
679
1020
  }
680
1021
  if (g.decision === "DENY") {
681
- return jsonResult2({
682
- decision: "DENY",
683
- toolName,
684
- reasonCode: g.reasonCode,
685
- ...g.requestId ? { requestId: g.requestId } : {},
686
- message: g.message
687
- });
1022
+ if (g.reasonCode === UNAVAILABLE) {
1023
+ return unreachableResult(toolName, g.requestId);
1024
+ }
1025
+ return jsonResult2(
1026
+ {
1027
+ decision: "DENY",
1028
+ toolName,
1029
+ reasonCode: g.reasonCode,
1030
+ ...g.requestId ? { requestId: g.requestId } : {},
1031
+ message: g.message
1032
+ },
1033
+ true
1034
+ );
688
1035
  }
689
1036
  return jsonResult2({ decision: "ALLOW", toolName, requestId: g.requestId });
690
1037
  }
691
- async function claim(requestId) {
1038
+ function alreadyClaimed(requestId) {
1039
+ const seen = claimedRequests.get(requestId);
1040
+ if (!seen) return void 0;
1041
+ if (seen.state === "forwarding" || !seen.result) {
1042
+ return jsonResult2(
1043
+ {
1044
+ toolName: seen.toolName,
1045
+ requestId,
1046
+ alreadyClaimed: true,
1047
+ message: `This approved call is already being forwarded by an earlier ${CLAIM_TOOL}. Do not propose ${seen.toolName} again; wait, then call ${RECEIPT_TOOL} with this requestId to see what happened.`
1048
+ },
1049
+ true
1050
+ );
1051
+ }
1052
+ const notice = {
1053
+ type: "text",
1054
+ text: `This requestId was already claimed and ${seen.toolName} was already forwarded. Do not propose it again. The earlier result follows.`
1055
+ };
1056
+ const earlier = seen.result;
1057
+ return {
1058
+ ...earlier,
1059
+ isError: true,
1060
+ content: [
1061
+ notice,
1062
+ ...Array.isArray(earlier.content) ? earlier.content : []
1063
+ ]
1064
+ };
1065
+ }
1066
+ async function claim(requestId, signal) {
1067
+ const repeat = alreadyClaimed(requestId);
1068
+ if (repeat) return repeat;
692
1069
  const held = pendingCalls.get(requestId);
693
1070
  if (held) {
1071
+ if (signal?.aborted) {
1072
+ return cancelledResult(held.name, requestId);
1073
+ }
694
1074
  pendingCalls.delete(requestId);
695
- let claimed;
696
- try {
697
- claimed = await options.client.claim(requestId);
698
- } catch {
1075
+ const putBack = () => {
1076
+ claimedRequests.delete(requestId);
699
1077
  pendingCalls.set(requestId, held);
700
- return jsonResult2(
701
- { requestId, error: "Guardian unreachable; nothing was forwarded." },
702
- true
703
- );
1078
+ };
1079
+ rememberClaim(requestId, { toolName: held.name, state: "forwarding" });
1080
+ let claimed;
1081
+ const reused = claimedCapabilities.get(requestId);
1082
+ if (reused) {
1083
+ claimedCapabilities.delete(requestId);
1084
+ claimed = reused;
1085
+ } else {
1086
+ try {
1087
+ claimed = await options.client.claim(requestId);
1088
+ } catch {
1089
+ putBack();
1090
+ return unreachableResult(held.name, requestId);
1091
+ }
1092
+ }
1093
+ if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
1094
+ putBack();
1095
+ return unreachableResult(held.name, requestId);
704
1096
  }
705
1097
  if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
706
- pendingCalls.set(requestId, held);
1098
+ putBack();
707
1099
  return jsonResult2({
708
1100
  decision: "ASK",
709
1101
  toolName: held.name,
@@ -711,39 +1103,95 @@ function createGovernedProxy(options) {
711
1103
  message: "Still awaiting human approval. Nothing was sent."
712
1104
  });
713
1105
  }
1106
+ let result;
1107
+ const forwarding = { sent: false };
714
1108
  try {
1109
+ const call = tracked();
715
1110
  const output = await settle(
716
- ctx,
1111
+ call.ctx,
717
1112
  held.name,
718
- held.args,
1113
+ held.proposed,
719
1114
  claimed,
720
- callUpstream(upstream, held.name, held.args)
1115
+ callUpstream(upstream, held.name, signal, forwarding)
1116
+ );
1117
+ result = toCallResult(
1118
+ held.name,
1119
+ held.args,
1120
+ output,
1121
+ call.allowedRequestId()
721
1122
  );
722
- return toCallResult(held.name, held.args, output);
723
1123
  } catch (err) {
724
- return failure(err, held.name);
1124
+ if (err instanceof UpstreamCallCancelled) {
1125
+ putBack();
1126
+ if (claimed.ok && claimed.decision === "ALLOW") {
1127
+ claimedCapabilities.set(requestId, claimed);
1128
+ }
1129
+ return cancelledResult(held.name, requestId);
1130
+ }
1131
+ result = failure(err, held.name, forwarding);
725
1132
  }
1133
+ if (!claimed.ok || claimed.decision !== "ALLOW" || !claimed.capabilityToken) {
1134
+ claimedRequests.delete(requestId);
1135
+ } else {
1136
+ rememberClaim(requestId, {
1137
+ toolName: held.name,
1138
+ state: "done",
1139
+ result
1140
+ });
1141
+ }
1142
+ return result;
726
1143
  }
727
1144
  const installFor = pendingInstalls.get(requestId);
728
1145
  if (installFor) {
729
- const claimed = await options.client.claim(requestId);
1146
+ if (signal?.aborted) {
1147
+ return jsonResult2(
1148
+ {
1149
+ toolName: installFor,
1150
+ requestId,
1151
+ cancelled: true,
1152
+ message: `The request was cancelled. Nothing was claimed; call ${CLAIM_TOOL} with this requestId again.`
1153
+ },
1154
+ true
1155
+ );
1156
+ }
1157
+ const key = `${options.connectionId}:${installFor}`;
1158
+ const step = heldSetupStep(installFor, requestId) ?? "install";
1159
+ const forgetHeld = () => {
1160
+ if (ctx.heldSetup?.get(key)?.requestId === requestId) {
1161
+ ctx.heldSetup.delete(key);
1162
+ }
1163
+ };
1164
+ let claimed;
1165
+ try {
1166
+ claimed = await options.client.claim(requestId);
1167
+ } catch {
1168
+ return unreachableResult(installFor, requestId);
1169
+ }
1170
+ if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
1171
+ return unreachableResult(installFor, requestId);
1172
+ }
730
1173
  if (claimed.ok && claimed.decision === "ALLOW" && claimed.capabilityToken) {
1174
+ forgetHeld();
1175
+ pendingInstalls.delete(requestId);
731
1176
  const executed = await options.client.execute({
732
1177
  requestId: claimed.requestId,
733
1178
  capabilityToken: claimed.capabilityToken
734
1179
  });
735
- pendingInstalls.delete(requestId);
736
- ctx.ensured.add(`${options.connectionId}:${installFor}`);
1180
+ if (executed.ok && step === "enable") {
1181
+ ctx.ensured.add(key);
1182
+ } else if (executed.ok) {
1183
+ ctx.installed?.add(key);
1184
+ }
737
1185
  return jsonResult2(
738
1186
  executed.ok ? {
739
1187
  decision: "ALLOW",
740
1188
  toolName: installFor,
741
- message: `${installFor} is registered with Guardian. Retry the original call.`
1189
+ message: step === "enable" ? `${installFor} is switched on for this agent. Retry the original call.` : `${installFor} is registered with Guardian. Retry the original call; switching it on for this agent may need one more approval.`
742
1190
  } : { toolName: installFor, executionFailed: executed.reasonCode },
743
1191
  !executed.ok
744
1192
  );
745
1193
  }
746
- if (claimed.ok && claimed.decision === "ASK") {
1194
+ if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
747
1195
  return jsonResult2({
748
1196
  decision: "ASK",
749
1197
  toolName: installFor,
@@ -751,17 +1199,21 @@ function createGovernedProxy(options) {
751
1199
  message: "Still awaiting human approval."
752
1200
  });
753
1201
  }
1202
+ forgetHeld();
754
1203
  pendingInstalls.delete(requestId);
755
- return jsonResult2({
756
- decision: "DENY",
757
- toolName: installFor,
758
- reasonCode: claimed.ok ? claimed.reasonCode ?? "MISSING_CAPABILITY" : claimed.reasonCode
759
- });
1204
+ return jsonResult2(
1205
+ {
1206
+ decision: "DENY",
1207
+ toolName: installFor,
1208
+ reasonCode: claimed.ok ? claimed.reasonCode ?? "MISSING_CAPABILITY" : claimed.reasonCode
1209
+ },
1210
+ true
1211
+ );
760
1212
  }
761
1213
  return jsonResult2(
762
1214
  {
763
1215
  requestId,
764
- error: "No held call with this requestId in this proxy session. Nothing was forwarded."
1216
+ error: `This proxy holds no call with this requestId, so this claim forwarded nothing. If it was claimed before (in this or an earlier proxy session), the call may already have been forwarded: call ${RECEIPT_TOOL} with this requestId before proposing it again.`
765
1217
  },
766
1218
  true
767
1219
  );
@@ -785,13 +1237,14 @@ function createGovernedProxy(options) {
785
1237
  ...PROXY_GOVERNANCE_TOOLS
786
1238
  ];
787
1239
  },
788
- async callTool(name, args) {
1240
+ async callTool(name, args, callOptions) {
1241
+ const signal = callOptions?.signal;
789
1242
  if (name === CLAIM_TOOL) {
790
1243
  const requestId = args.requestId;
791
1244
  if (typeof requestId !== "string" || requestId.length === 0) {
792
1245
  return jsonResult2({ error: "requestId is required" }, true);
793
1246
  }
794
- return claim(requestId);
1247
+ return claim(requestId, signal);
795
1248
  }
796
1249
  if (name === RECEIPT_TOOL) {
797
1250
  const requestId = args.requestId;
@@ -819,16 +1272,21 @@ function createGovernedProxy(options) {
819
1272
  if (!tools.has(name)) {
820
1273
  return jsonResult2({ error: `unknown tool: ${name}` }, true);
821
1274
  }
1275
+ if (signal?.aborted) {
1276
+ return cancelledResult(name);
1277
+ }
1278
+ const forwarding = { sent: false };
822
1279
  try {
1280
+ const call = tracked();
823
1281
  const output = await runThroughGuardian(
824
- ctx,
1282
+ call.ctx,
825
1283
  name,
826
1284
  args,
827
- callUpstream(upstream, name, args)
1285
+ callUpstream(upstream, name, signal, forwarding)
828
1286
  );
829
- return toCallResult(name, args, output);
1287
+ return toCallResult(name, args, output, call.allowedRequestId());
830
1288
  } catch (err) {
831
- return failure(err, name);
1289
+ return failure(err, name, forwarding);
832
1290
  }
833
1291
  }
834
1292
  };
@@ -892,7 +1350,10 @@ function toolListFailure(target, err) {
892
1350
  function clientUpstream(client2) {
893
1351
  return {
894
1352
  listTools: () => listAllTools(client2),
895
- async callTool(name, args) {
1353
+ async callTool(name, args, options) {
1354
+ if (options?.signal?.aborted) {
1355
+ throw new UpstreamCallCancelled(name);
1356
+ }
896
1357
  return await client2.callTool({
897
1358
  name,
898
1359
  arguments: args
@@ -960,23 +1421,39 @@ if (config.mode.kind === "proxy") {
960
1421
  name: "guardian-mcp-proxy",
961
1422
  version: VERSION
962
1423
  });
963
- const proxy = createGovernedProxy({
964
- client,
965
- connectionId: config.connectionId,
966
- autoRegister: config.autoRegister,
967
- ...config.adapterId ? { adapterId: config.adapterId } : {},
968
- log: (message) => process.stderr.write(`${message}
1424
+ let proxy;
1425
+ try {
1426
+ proxy = createGovernedProxy({
1427
+ client,
1428
+ connectionId: config.connectionId,
1429
+ autoRegister: config.autoRegister,
1430
+ upstreamTransport: "remote" in config.mode ? "remote" : "stdio",
1431
+ ...config.mode.pinFiles ? { pinFiles: config.mode.pinFiles } : {},
1432
+ ...config.adapterId ? { adapterId: config.adapterId } : {},
1433
+ log: (message) => process.stderr.write(`${message}
969
1434
  `),
970
- upstream: clientUpstream(upstreamClient)
971
- });
1435
+ upstream: clientUpstream(upstreamClient)
1436
+ });
1437
+ } catch (err) {
1438
+ process.stderr.write(
1439
+ `${err instanceof Error ? err.message : "guardian-mcp proxy: invalid configuration."}
1440
+ `
1441
+ );
1442
+ await upstreamClient.close().catch(() => {
1443
+ });
1444
+ process.exit(2);
1445
+ }
972
1446
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
973
1447
  tools: await proxy.listTools()
974
1448
  }));
975
1449
  server.setRequestHandler(
976
1450
  CallToolRequestSchema,
977
- async (request) => await proxy.callTool(
1451
+ async (request, extra) => await proxy.callTool(
978
1452
  request.params.name,
979
- request.params.arguments ?? {}
1453
+ request.params.arguments ?? {},
1454
+ // Cancelled (notifications/cancelled) or timed-out requests are
1455
+ // not forwarded if the signal fires before the call is sent.
1456
+ { signal: extra.signal }
980
1457
  )
981
1458
  );
982
1459
  } else {
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@odingard/guardian-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.Odingard/guardian-mcp",
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.",
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, and every allowed call that completes carries a verifiable receipt.",
6
6
  "license": "Apache-2.0",
7
+ "author": "Odingard Security LLC",
7
8
  "type": "module",
8
9
  "main": "dist/index.js",
9
10
  "bin": {
@@ -25,7 +26,7 @@
25
26
  },
26
27
  "dependencies": {
27
28
  "@modelcontextprotocol/sdk": "^1.32.2",
28
- "@odingard/guardian-sdk": "^0.3.8"
29
+ "@odingard/guardian-sdk": "^0.4.0"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@types/node": "^22",
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.3",
5
+ "version": "0.4.0",
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.3",
29
+ "version": "0.4.0",
30
30
  "transport": {
31
31
  "type": "stdio"
32
32
  },