@odingard/guardian-mcp 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,42 @@ 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
+ ## Govern a remote MCP server: `guardian-mcp proxy --url` (0.3.3 and later)
91
124
 
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.
125
+ **Status: tested against a real Guardian; production proof pending.** It ran
126
+ end to end over Streamable HTTP against the real Guardian server code (allow,
127
+ ask then approve, deny, outage), not yet on hosted production. SSE is tested
128
+ against local servers and a stand-in Guardian only, and no real hosted server
129
+ such as Composio has been tried. Composio's `x-api-key` header is
130
+ unconfirmed.
96
131
 
97
132
  The proxy can also front an MCP server that runs online, such as Composio.
98
133
  The agent still starts `guardian-mcp` over stdio like any
@@ -157,7 +192,8 @@ A server that takes a Bearer key: `--url https://mcp.example.com/mcp --header
157
192
  unavailable" error. The upstream's error text (which can quote its URL
158
193
  path, response body or headers) is not shown to the agent or logged;
159
194
  `guardian-mcp tools --url` prints it with the URL, its path identifiers,
160
- query values and header values scrubbed.
195
+ query values and header values scrubbed. Each query value is scrubbed as
196
+ typed (a `+` stays a `+`), percent-decoded, and with `+` read as a space.
161
197
 
162
198
  What is **not** governed: a remote server the agent connects to directly
163
199
  (for example an MCP client's own `"url"` entry, or a model provider's hosted
@@ -175,9 +211,8 @@ MCP connector), and anything the agent does without MCP. Also not covered:
175
211
  [ZeroClaw](../../examples/zeroclaw/README.md) and
176
212
  [your own container (CrewAI, LangGraph, AutoGen)](../../examples/byo/README.md).
177
213
  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.
214
+ Guardian settings). `--url` is in the published `@odingard/guardian-mcp`
215
+ from 0.3.3.
181
216
 
182
217
  ## See what you are about to govern: `guardian-mcp tools`
183
218
 
@@ -197,8 +232,10 @@ createIssue Ask me first "create" may change something ⚠ name not accept
197
232
 
198
233
  How the suggestions are made:
199
234
 
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.
235
+ - A tool the server marks destructive (`destructiveHint`) suggests Ask.
236
+ - 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.
237
+ - 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`).
238
+ - Everything else suggests Ask, including `fetch` (it can send data to any URL) and `status`.
202
239
 
203
240
  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
241
 
package/dist/index.js CHANGED
@@ -235,6 +235,18 @@ function secretsOf(target) {
235
235
  const parsed2 = new URL(url);
236
236
  if (parsed2.search) out.add(parsed2.search.slice(1));
237
237
  for (const v of parsed2.searchParams.values()) if (v.length >= 4) out.add(v);
238
+ for (const pair of parsed2.search.slice(1).split("&")) {
239
+ const raw = pair.slice(pair.indexOf("=") + 1);
240
+ if (pair.indexOf("=") < 0 || raw.length < 4) continue;
241
+ const forms = [raw, raw.replace(/\+/g, " ")];
242
+ for (const form of [...forms]) {
243
+ try {
244
+ forms.push(decodeURIComponent(form));
245
+ } catch {
246
+ }
247
+ }
248
+ for (const form of forms) if (form.length >= 4) out.add(form);
249
+ }
238
250
  for (const segment of parsed2.pathname.split("/")) {
239
251
  if (segment.length >= 16 || segment.length >= 8 && /\d/.test(segment)) {
240
252
  out.add(segment);
@@ -308,11 +320,26 @@ var TOOLS = [
308
320
  }
309
321
  ];
310
322
  function jsonResult(payload) {
311
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
323
+ return {
324
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
325
+ };
312
326
  }
313
327
  function errorResult(message) {
314
328
  return { isError: true, content: [{ type: "text", text: message }] };
315
329
  }
330
+ function jsonError(payload) {
331
+ return { ...jsonResult(payload), isError: true };
332
+ }
333
+ var CHECK_FIRST = "Call guardian_receipt with this requestId, or check the target system, before retrying.";
334
+ function denied(reasonCode, requestId) {
335
+ const code = reasonCode ?? "DENIED";
336
+ return jsonError({
337
+ decision: "DENY",
338
+ reasonCode: code,
339
+ ...requestId ? { requestId } : {},
340
+ message: code === "SERVICE_UNAVAILABLE" ? "Guardian could not be reached, so nothing was done. Try again later." : "Guardian denied this call. Nothing was done. Do not retry the same call."
341
+ });
342
+ }
316
343
  async function requestAndExecute(client2, result) {
317
344
  if (!result.ok) {
318
345
  const heldRequestId = result.requestId;
@@ -324,33 +351,62 @@ async function requestAndExecute(client2, result) {
324
351
  next: "Held for human approval. Call guardian_claim with this requestId after approval."
325
352
  });
326
353
  }
327
- return jsonResult({ decision: "DENY", reasonCode: result.reasonCode });
354
+ return denied(
355
+ result.reasonCode,
356
+ typeof heldRequestId === "string" && heldRequestId.length > 0 ? heldRequestId : void 0
357
+ );
328
358
  }
329
359
  if (result.decision === "ALLOW" && result.capabilityToken) {
330
360
  const executed = await client2.execute({
331
361
  requestId: result.requestId,
332
362
  capabilityToken: result.capabilityToken
333
363
  });
334
- return jsonResult(
335
- executed.ok ? {
364
+ if (!executed.ok) {
365
+ return jsonError({
366
+ decision: "ALLOW",
367
+ requestId: result.requestId,
368
+ outcome: "unknown",
369
+ executionFailed: executed.reasonCode,
370
+ message: `Guardian could not confirm the outcome (${executed.reasonCode}). The call may have been done. ${CHECK_FIRST}`
371
+ });
372
+ }
373
+ const status = executed.receipt?.status;
374
+ if (status === "sent-verified") {
375
+ return jsonResult({
336
376
  decision: "ALLOW",
337
377
  requestId: result.requestId,
338
378
  receipt: executed.receipt
339
- } : {
379
+ });
380
+ }
381
+ if (status === "not-sent") {
382
+ return jsonError({
340
383
  decision: "ALLOW",
341
384
  requestId: result.requestId,
342
- executionFailed: executed.reasonCode
343
- }
344
- );
385
+ outcome: "not-done",
386
+ receipt: executed.receipt,
387
+ message: "Guardian did not send the call (receipt status not-sent). It was not done."
388
+ });
389
+ }
390
+ return jsonError({
391
+ decision: "ALLOW",
392
+ requestId: result.requestId,
393
+ outcome: "unknown",
394
+ receipt: executed.receipt,
395
+ message: `The outcome is unknown (receipt status ${typeof status === "string" ? status : "missing"}). The call may have been done. ${CHECK_FIRST}`
396
+ });
345
397
  }
346
- return jsonResult({
347
- decision: result.decision,
348
- requestId: result.requestId,
349
- ...result.reasonCode ? { reasonCode: result.reasonCode } : {},
350
- ...result.decision === "ASK" ? {
398
+ if (result.decision === "ASK") {
399
+ return jsonResult({
400
+ decision: "ASK",
401
+ requestId: result.requestId,
402
+ ...result.reasonCode ? { reasonCode: result.reasonCode } : {},
351
403
  message: "Held for human approval. Call guardian_claim with this requestId after approval."
352
- } : {}
353
- });
404
+ });
405
+ }
406
+ return denied(
407
+ result.reasonCode ?? (result.decision === "DENY" ? void 0 : "MISSING_CAPABILITY"),
408
+ result.requestId
409
+ );
354
410
  }
355
411
  async function handleToolCall(options, name, arguments_) {
356
412
  const { client: client2, defaultConnectionId, adapterId } = options;
@@ -410,12 +466,85 @@ var READ_VERBS = /* @__PURE__ */ new Set([
410
466
  "find",
411
467
  "lookup",
412
468
  "count",
413
- "inspect",
414
- "status",
415
- "fetch"
469
+ "inspect"
416
470
  ]);
417
- function firstWord(name) {
418
- return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).find(Boolean) ?? "";
471
+ var WRITE_VERBS = /* @__PURE__ */ new Set([
472
+ "add",
473
+ "append",
474
+ "apply",
475
+ "approve",
476
+ "archive",
477
+ "assign",
478
+ "buy",
479
+ "cancel",
480
+ "charge",
481
+ "clear",
482
+ "close",
483
+ "commit",
484
+ "copy",
485
+ "create",
486
+ "delete",
487
+ "deploy",
488
+ "destroy",
489
+ "disable",
490
+ "drop",
491
+ "edit",
492
+ "enable",
493
+ "exec",
494
+ "execute",
495
+ "fork",
496
+ "grant",
497
+ "import",
498
+ "insert",
499
+ "install",
500
+ "invite",
501
+ "kill",
502
+ "lock",
503
+ "merge",
504
+ "modify",
505
+ "move",
506
+ "notify",
507
+ "patch",
508
+ "pay",
509
+ "post",
510
+ "publish",
511
+ "purge",
512
+ "push",
513
+ "put",
514
+ "refund",
515
+ "remove",
516
+ "rename",
517
+ "replace",
518
+ "reply",
519
+ "reset",
520
+ "restart",
521
+ "restore",
522
+ "revoke",
523
+ "rm",
524
+ "run",
525
+ "save",
526
+ "schedule",
527
+ "sell",
528
+ "send",
529
+ "set",
530
+ "share",
531
+ "sign",
532
+ "start",
533
+ "stop",
534
+ "submit",
535
+ "sync",
536
+ "transfer",
537
+ "trigger",
538
+ "truncate",
539
+ "uninstall",
540
+ "unlock",
541
+ "update",
542
+ "upload",
543
+ "upsert",
544
+ "write"
545
+ ]);
546
+ function words(name) {
547
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
419
548
  }
420
549
  function suggestTool(tool) {
421
550
  const governable = GUARDIAN_TOOL_NAME.test(tool.name) && tool.name.length <= 64;
@@ -428,6 +557,16 @@ function suggestTool(tool) {
428
557
  governable
429
558
  };
430
559
  }
560
+ const tokens = words(tool.name);
561
+ const writeVerb = tokens.find((token) => WRITE_VERBS.has(token));
562
+ if (writeVerb) {
563
+ return {
564
+ name: tool.name,
565
+ suggestedMode: "ask",
566
+ why: `"${writeVerb}" may change something`,
567
+ governable
568
+ };
569
+ }
431
570
  if (annotations.readOnlyHint === true) {
432
571
  return {
433
572
  name: tool.name,
@@ -436,7 +575,7 @@ function suggestTool(tool) {
436
575
  governable
437
576
  };
438
577
  }
439
- const verb = firstWord(tool.name);
578
+ const verb = tokens[0] ?? "";
440
579
  if (READ_VERBS.has(verb)) {
441
580
  return {
442
581
  name: tool.name,
@@ -492,6 +631,20 @@ import {
492
631
  } from "@odingard/guardian-sdk";
493
632
  var CLAIM_TOOL = "guardian_claim";
494
633
  var RECEIPT_TOOL = "guardian_receipt";
634
+ var GUARDIAN_RESULT_META = "com.odingard/guardian";
635
+ var UNAVAILABLE = "SERVICE_UNAVAILABLE";
636
+ function unreachableResult(toolName, requestId) {
637
+ return jsonResult2(
638
+ {
639
+ decision: "DENY",
640
+ toolName,
641
+ reasonCode: UNAVAILABLE,
642
+ ...requestId ? { requestId } : {},
643
+ message: `Guardian could not be reached, so ${toolName} was not checked and nothing was forwarded. Try again later.`
644
+ },
645
+ true
646
+ );
647
+ }
495
648
  var PROXY_GOVERNANCE_TOOLS = [
496
649
  {
497
650
  name: CLAIM_TOOL,
@@ -518,6 +671,14 @@ function jsonResult2(payload, isError = false) {
518
671
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
519
672
  };
520
673
  }
674
+ function withRequestId(result, requestId) {
675
+ if (!requestId) return result;
676
+ const meta = typeof result._meta === "object" && result._meta !== null ? result._meta : {};
677
+ return {
678
+ ...result,
679
+ _meta: { ...meta, [GUARDIAN_RESULT_META]: { requestId } }
680
+ };
681
+ }
521
682
  function isDecisionResult(value) {
522
683
  return typeof value === "object" && value !== null && "guardian" in value && typeof value.guardian === "object";
523
684
  }
@@ -533,6 +694,14 @@ var UPSTREAM_RESULT = /* @__PURE__ */ Symbol("guardian-mcp.upstreamResult");
533
694
  function upstreamRan(value) {
534
695
  return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, UPSTREAM_RESULT);
535
696
  }
697
+ var UpstreamCallCancelled = class extends Error {
698
+ constructor(toolName) {
699
+ super("the call was cancelled before it was forwarded");
700
+ this.toolName = toolName;
701
+ this.name = "UpstreamCallCancelled";
702
+ }
703
+ toolName;
704
+ };
536
705
  var UpstreamListFailed = class extends Error {
537
706
  constructor() {
538
707
  super(
@@ -541,16 +710,44 @@ var UpstreamListFailed = class extends Error {
541
710
  this.name = "UpstreamListFailed";
542
711
  }
543
712
  };
544
- function callUpstream(upstream, name, args) {
713
+ function callUpstream(upstream, name, args, signal, forwarding) {
545
714
  return async () => {
715
+ if (signal?.aborted) {
716
+ throw new UpstreamCallCancelled(name);
717
+ }
718
+ forwarding.sent = true;
546
719
  try {
547
- return { [UPSTREAM_RESULT]: await upstream.callTool(name, args) };
548
- } catch {
720
+ return {
721
+ [UPSTREAM_RESULT]: await upstream.callTool(
722
+ name,
723
+ args,
724
+ signal ? { signal } : void 0
725
+ )
726
+ };
727
+ } catch (err) {
728
+ if (err instanceof UpstreamCallCancelled) {
729
+ forwarding.sent = false;
730
+ throw err;
731
+ }
549
732
  throw new UpstreamCallFailed(name);
550
733
  }
551
734
  };
552
735
  }
553
- function failure(err, toolName) {
736
+ function cancelledResult(toolName, requestId) {
737
+ return jsonResult2(
738
+ {
739
+ toolName,
740
+ ...requestId ? { requestId } : {},
741
+ cancelled: true,
742
+ message: requestId ? `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded; the approved call is still held, so call ${CLAIM_TOOL} with this requestId to forward it.` : `The request was cancelled before ${toolName} was forwarded. Nothing was forwarded.`
743
+ },
744
+ true
745
+ );
746
+ }
747
+ function failure(err, toolName, forwarding) {
748
+ if (err instanceof UpstreamCallCancelled) {
749
+ return cancelledResult(toolName);
750
+ }
554
751
  if (err instanceof UpstreamCallFailed) {
555
752
  return jsonResult2(
556
753
  {
@@ -569,12 +766,16 @@ function failure(err, toolName) {
569
766
  requestId: err.requestId,
570
767
  executionFailed: err.reasonCode,
571
768
  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."
769
+ ...err.outcomeUnknown ? { outcomeUnknown: true } : {},
770
+ message: err.outcomeUnknown ? err.localEffectRan ? `The upstream call ran, but Guardian could not confirm that it was recorded. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : `Guardian could not confirm whether the call completed. It may have run. Do not call it again; check this requestId with ${RECEIPT_TOOL}.` : err.localEffectRan ? "The upstream call ran but Guardian refused to redeem the capability. Do not call it again. Treat this as an incident, not a success." : "Guardian could not redeem the capability; the call was not completed."
573
771
  },
574
772
  true
575
773
  );
576
774
  }
577
775
  if (err instanceof GuardianDenied) {
776
+ if (err.reasonCode === UNAVAILABLE) {
777
+ return unreachableResult(toolName, err.requestId);
778
+ }
578
779
  return jsonResult2(
579
780
  {
580
781
  decision: "DENY",
@@ -599,17 +800,28 @@ function failure(err, toolName) {
599
800
  return jsonResult2(
600
801
  {
601
802
  toolName,
602
- error: "Guardian proxy error; the call was not forwarded."
803
+ error: forwarding.sent ? "Guardian proxy error after the call was forwarded. It may have run; check before retrying." : "Guardian proxy error; the call was not forwarded."
603
804
  },
604
805
  true
605
806
  );
606
807
  }
808
+ var CLAIMED_REQUESTS_LIMIT = 1e3;
607
809
  function createGovernedProxy(options) {
608
810
  const { upstream, log = () => {
609
811
  } } = options;
610
812
  const toolInvokeRequestIds = /* @__PURE__ */ new Set();
611
813
  const pendingCalls = /* @__PURE__ */ new Map();
612
814
  const pendingInstalls = /* @__PURE__ */ new Map();
815
+ const claimedCapabilities = /* @__PURE__ */ new Map();
816
+ const claimedRequests = /* @__PURE__ */ new Map();
817
+ const rememberClaim = (requestId, entry) => {
818
+ claimedRequests.delete(requestId);
819
+ claimedRequests.set(requestId, entry);
820
+ while (claimedRequests.size > CLAIMED_REQUESTS_LIMIT) {
821
+ const oldest = claimedRequests.keys().next().value;
822
+ claimedRequests.delete(oldest);
823
+ }
824
+ };
613
825
  const recordingClient = {
614
826
  ...options.client,
615
827
  async request(invocation) {
@@ -631,6 +843,26 @@ function createGovernedProxy(options) {
631
843
  ...options.adapterId ? { adapterId: options.adapterId } : {},
632
844
  ...options.agentName ? { agentName: options.agentName } : {}
633
845
  });
846
+ ctx.installed ??= /* @__PURE__ */ new Set();
847
+ function heldSetupStep(toolName, requestId) {
848
+ const held = ctx.heldSetup?.get(`${options.connectionId}:${toolName}`);
849
+ return held?.requestId === requestId ? held.step : void 0;
850
+ }
851
+ function tracked() {
852
+ let allowed;
853
+ return {
854
+ ctx: {
855
+ ...ctx,
856
+ options: {
857
+ ...ctx.options,
858
+ onReceipt: (event) => {
859
+ allowed = event.requestId;
860
+ }
861
+ }
862
+ },
863
+ allowedRequestId: () => allowed
864
+ };
865
+ }
634
866
  let upstreamTools = null;
635
867
  async function loadTools() {
636
868
  if (upstreamTools) return upstreamTools;
@@ -647,8 +879,10 @@ function createGovernedProxy(options) {
647
879
  upstreamTools = tools;
648
880
  return tools;
649
881
  }
650
- function toCallResult(toolName, args, output) {
651
- if (upstreamRan(output)) return output[UPSTREAM_RESULT];
882
+ function toCallResult(toolName, args, output, allowedRequestId) {
883
+ if (upstreamRan(output)) {
884
+ return withRequestId(output[UPSTREAM_RESULT], allowedRequestId);
885
+ }
652
886
  if (!isDecisionResult(output)) {
653
887
  return jsonResult2(
654
888
  {
@@ -670,40 +904,92 @@ function createGovernedProxy(options) {
670
904
  });
671
905
  }
672
906
  pendingInstalls.set(g.requestId, toolName);
907
+ const step = heldSetupStep(toolName, g.requestId);
673
908
  return jsonResult2({
674
909
  decision: "ASK",
675
910
  toolName,
676
911
  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.`
912
+ message: step === "enable" ? `Switching on ${toolName} for this agent needs a human to approve it in Guardian. After approval call ${CLAIM_TOOL} with this requestId (or retry the original call).` : `First use of ${toolName} needs a human to approve registering it with Guardian. After approval call ${CLAIM_TOOL} with this requestId, then retry the original call.`
678
913
  });
679
914
  }
680
915
  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
- });
916
+ if (g.reasonCode === UNAVAILABLE) {
917
+ return unreachableResult(toolName, g.requestId);
918
+ }
919
+ return jsonResult2(
920
+ {
921
+ decision: "DENY",
922
+ toolName,
923
+ reasonCode: g.reasonCode,
924
+ ...g.requestId ? { requestId: g.requestId } : {},
925
+ message: g.message
926
+ },
927
+ true
928
+ );
688
929
  }
689
930
  return jsonResult2({ decision: "ALLOW", toolName, requestId: g.requestId });
690
931
  }
691
- async function claim(requestId) {
932
+ function alreadyClaimed(requestId) {
933
+ const seen = claimedRequests.get(requestId);
934
+ if (!seen) return void 0;
935
+ if (seen.state === "forwarding" || !seen.result) {
936
+ return jsonResult2(
937
+ {
938
+ toolName: seen.toolName,
939
+ requestId,
940
+ alreadyClaimed: true,
941
+ message: `This approved call is already being forwarded by an earlier ${CLAIM_TOOL}. Do not propose ${seen.toolName} again; wait, then call ${RECEIPT_TOOL} with this requestId to see what happened.`
942
+ },
943
+ true
944
+ );
945
+ }
946
+ const notice = {
947
+ type: "text",
948
+ text: `This requestId was already claimed and ${seen.toolName} was already forwarded. Do not propose it again. The earlier result follows.`
949
+ };
950
+ const earlier = seen.result;
951
+ return {
952
+ ...earlier,
953
+ isError: true,
954
+ content: [
955
+ notice,
956
+ ...Array.isArray(earlier.content) ? earlier.content : []
957
+ ]
958
+ };
959
+ }
960
+ async function claim(requestId, signal) {
961
+ const repeat = alreadyClaimed(requestId);
962
+ if (repeat) return repeat;
692
963
  const held = pendingCalls.get(requestId);
693
964
  if (held) {
965
+ if (signal?.aborted) {
966
+ return cancelledResult(held.name, requestId);
967
+ }
694
968
  pendingCalls.delete(requestId);
695
- let claimed;
696
- try {
697
- claimed = await options.client.claim(requestId);
698
- } catch {
969
+ const putBack = () => {
970
+ claimedRequests.delete(requestId);
699
971
  pendingCalls.set(requestId, held);
700
- return jsonResult2(
701
- { requestId, error: "Guardian unreachable; nothing was forwarded." },
702
- true
703
- );
972
+ };
973
+ rememberClaim(requestId, { toolName: held.name, state: "forwarding" });
974
+ let claimed;
975
+ const reused = claimedCapabilities.get(requestId);
976
+ if (reused) {
977
+ claimedCapabilities.delete(requestId);
978
+ claimed = reused;
979
+ } else {
980
+ try {
981
+ claimed = await options.client.claim(requestId);
982
+ } catch {
983
+ putBack();
984
+ return unreachableResult(held.name, requestId);
985
+ }
986
+ }
987
+ if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
988
+ putBack();
989
+ return unreachableResult(held.name, requestId);
704
990
  }
705
991
  if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
706
- pendingCalls.set(requestId, held);
992
+ putBack();
707
993
  return jsonResult2({
708
994
  decision: "ASK",
709
995
  toolName: held.name,
@@ -711,39 +997,95 @@ function createGovernedProxy(options) {
711
997
  message: "Still awaiting human approval. Nothing was sent."
712
998
  });
713
999
  }
1000
+ let result;
1001
+ const forwarding = { sent: false };
714
1002
  try {
1003
+ const call = tracked();
715
1004
  const output = await settle(
716
- ctx,
1005
+ call.ctx,
717
1006
  held.name,
718
1007
  held.args,
719
1008
  claimed,
720
- callUpstream(upstream, held.name, held.args)
1009
+ callUpstream(upstream, held.name, held.args, signal, forwarding)
1010
+ );
1011
+ result = toCallResult(
1012
+ held.name,
1013
+ held.args,
1014
+ output,
1015
+ call.allowedRequestId()
721
1016
  );
722
- return toCallResult(held.name, held.args, output);
723
1017
  } catch (err) {
724
- return failure(err, held.name);
1018
+ if (err instanceof UpstreamCallCancelled) {
1019
+ putBack();
1020
+ if (claimed.ok && claimed.decision === "ALLOW") {
1021
+ claimedCapabilities.set(requestId, claimed);
1022
+ }
1023
+ return cancelledResult(held.name, requestId);
1024
+ }
1025
+ result = failure(err, held.name, forwarding);
1026
+ }
1027
+ if (!claimed.ok || claimed.decision !== "ALLOW" || !claimed.capabilityToken) {
1028
+ claimedRequests.delete(requestId);
1029
+ } else {
1030
+ rememberClaim(requestId, {
1031
+ toolName: held.name,
1032
+ state: "done",
1033
+ result
1034
+ });
725
1035
  }
1036
+ return result;
726
1037
  }
727
1038
  const installFor = pendingInstalls.get(requestId);
728
1039
  if (installFor) {
729
- const claimed = await options.client.claim(requestId);
1040
+ if (signal?.aborted) {
1041
+ return jsonResult2(
1042
+ {
1043
+ toolName: installFor,
1044
+ requestId,
1045
+ cancelled: true,
1046
+ message: `The request was cancelled. Nothing was claimed; call ${CLAIM_TOOL} with this requestId again.`
1047
+ },
1048
+ true
1049
+ );
1050
+ }
1051
+ const key = `${options.connectionId}:${installFor}`;
1052
+ const step = heldSetupStep(installFor, requestId) ?? "install";
1053
+ const forgetHeld = () => {
1054
+ if (ctx.heldSetup?.get(key)?.requestId === requestId) {
1055
+ ctx.heldSetup.delete(key);
1056
+ }
1057
+ };
1058
+ let claimed;
1059
+ try {
1060
+ claimed = await options.client.claim(requestId);
1061
+ } catch {
1062
+ return unreachableResult(installFor, requestId);
1063
+ }
1064
+ if (!claimed.ok && claimed.reasonCode === UNAVAILABLE) {
1065
+ return unreachableResult(installFor, requestId);
1066
+ }
730
1067
  if (claimed.ok && claimed.decision === "ALLOW" && claimed.capabilityToken) {
1068
+ forgetHeld();
1069
+ pendingInstalls.delete(requestId);
731
1070
  const executed = await options.client.execute({
732
1071
  requestId: claimed.requestId,
733
1072
  capabilityToken: claimed.capabilityToken
734
1073
  });
735
- pendingInstalls.delete(requestId);
736
- ctx.ensured.add(`${options.connectionId}:${installFor}`);
1074
+ if (executed.ok && step === "enable") {
1075
+ ctx.ensured.add(key);
1076
+ } else if (executed.ok) {
1077
+ ctx.installed?.add(key);
1078
+ }
737
1079
  return jsonResult2(
738
1080
  executed.ok ? {
739
1081
  decision: "ALLOW",
740
1082
  toolName: installFor,
741
- message: `${installFor} is registered with Guardian. Retry the original call.`
1083
+ message: step === "enable" ? `${installFor} is switched on for this agent. Retry the original call.` : `${installFor} is registered with Guardian. Retry the original call; switching it on for this agent may need one more approval.`
742
1084
  } : { toolName: installFor, executionFailed: executed.reasonCode },
743
1085
  !executed.ok
744
1086
  );
745
1087
  }
746
- if (claimed.ok && claimed.decision === "ASK") {
1088
+ if (claimed.ok && claimed.decision === "ASK" || !claimed.ok && (claimed.reasonCode === "APPROVAL_PENDING" || claimed.reasonCode === "APPROVAL_REQUIRED")) {
747
1089
  return jsonResult2({
748
1090
  decision: "ASK",
749
1091
  toolName: installFor,
@@ -751,17 +1093,21 @@ function createGovernedProxy(options) {
751
1093
  message: "Still awaiting human approval."
752
1094
  });
753
1095
  }
1096
+ forgetHeld();
754
1097
  pendingInstalls.delete(requestId);
755
- return jsonResult2({
756
- decision: "DENY",
757
- toolName: installFor,
758
- reasonCode: claimed.ok ? claimed.reasonCode ?? "MISSING_CAPABILITY" : claimed.reasonCode
759
- });
1098
+ return jsonResult2(
1099
+ {
1100
+ decision: "DENY",
1101
+ toolName: installFor,
1102
+ reasonCode: claimed.ok ? claimed.reasonCode ?? "MISSING_CAPABILITY" : claimed.reasonCode
1103
+ },
1104
+ true
1105
+ );
760
1106
  }
761
1107
  return jsonResult2(
762
1108
  {
763
1109
  requestId,
764
- error: "No held call with this requestId in this proxy session. Nothing was forwarded."
1110
+ error: `This proxy holds no call with this requestId, so this claim forwarded nothing. If it was claimed before (in this or an earlier proxy session), the call may already have been forwarded: call ${RECEIPT_TOOL} with this requestId before proposing it again.`
765
1111
  },
766
1112
  true
767
1113
  );
@@ -785,13 +1131,14 @@ function createGovernedProxy(options) {
785
1131
  ...PROXY_GOVERNANCE_TOOLS
786
1132
  ];
787
1133
  },
788
- async callTool(name, args) {
1134
+ async callTool(name, args, callOptions) {
1135
+ const signal = callOptions?.signal;
789
1136
  if (name === CLAIM_TOOL) {
790
1137
  const requestId = args.requestId;
791
1138
  if (typeof requestId !== "string" || requestId.length === 0) {
792
1139
  return jsonResult2({ error: "requestId is required" }, true);
793
1140
  }
794
- return claim(requestId);
1141
+ return claim(requestId, signal);
795
1142
  }
796
1143
  if (name === RECEIPT_TOOL) {
797
1144
  const requestId = args.requestId;
@@ -819,16 +1166,21 @@ function createGovernedProxy(options) {
819
1166
  if (!tools.has(name)) {
820
1167
  return jsonResult2({ error: `unknown tool: ${name}` }, true);
821
1168
  }
1169
+ if (signal?.aborted) {
1170
+ return cancelledResult(name);
1171
+ }
1172
+ const forwarding = { sent: false };
822
1173
  try {
1174
+ const call = tracked();
823
1175
  const output = await runThroughGuardian(
824
- ctx,
1176
+ call.ctx,
825
1177
  name,
826
1178
  args,
827
- callUpstream(upstream, name, args)
1179
+ callUpstream(upstream, name, args, signal, forwarding)
828
1180
  );
829
- return toCallResult(name, args, output);
1181
+ return toCallResult(name, args, output, call.allowedRequestId());
830
1182
  } catch (err) {
831
- return failure(err, name);
1183
+ return failure(err, name, forwarding);
832
1184
  }
833
1185
  }
834
1186
  };
@@ -892,7 +1244,10 @@ function toolListFailure(target, err) {
892
1244
  function clientUpstream(client2) {
893
1245
  return {
894
1246
  listTools: () => listAllTools(client2),
895
- async callTool(name, args) {
1247
+ async callTool(name, args, options) {
1248
+ if (options?.signal?.aborted) {
1249
+ throw new UpstreamCallCancelled(name);
1250
+ }
896
1251
  return await client2.callTool({
897
1252
  name,
898
1253
  arguments: args
@@ -974,9 +1329,12 @@ if (config.mode.kind === "proxy") {
974
1329
  }));
975
1330
  server.setRequestHandler(
976
1331
  CallToolRequestSchema,
977
- async (request) => await proxy.callTool(
1332
+ async (request, extra) => await proxy.callTool(
978
1333
  request.params.name,
979
- request.params.arguments ?? {}
1334
+ request.params.arguments ?? {},
1335
+ // Cancelled (notifications/cancelled) or timed-out requests are
1336
+ // not forwarded if the signal fires before the call is sent.
1337
+ { signal: extra.signal }
980
1338
  )
981
1339
  );
982
1340
  } 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.3.4",
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.3.9"
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.3.4",
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.3.4",
30
30
  "transport": {
31
31
  "type": "stdio"
32
32
  },