@xultrax-web/agent-memory-mcp 0.11.6 → 0.12.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.
Files changed (2) hide show
  1. package/dist/index.js +161 -37
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
25
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
- import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
26
+ import { CallToolRequestSchema, CreateMessageResultSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
27
27
  import Fuse from "fuse.js";
28
28
  import matter from "gray-matter";
29
29
  import { spawnSync } from "node:child_process";
@@ -615,27 +615,27 @@ export function toolDeleteMemory(args) {
615
615
  const fp = memoryFilePath(name);
616
616
  if (!existsSync(fp))
617
617
  return `Memory "${name}" not found.`;
618
- // v0.11.3 · receipt-gated path. If a receipt is supplied, validate it
619
- // against the current rule set + required caveats. If validation fails,
620
- // refuse the delete with a clear reason. If no receipt is supplied,
621
- // proceed (back-compat) but log so audit can surface the gap. v0.12
622
- // will require receipts unconditionally for destructive ops.
618
+ // v0.12.0 · receipt REQUIRED for delete_memory. The v0.11.x back-compat
619
+ // path (delete without receipt) is removed. Callers MUST first call
620
+ // check_action({action_type: 'deletions'}) to obtain a fresh receipt,
621
+ // then pass it to delete_memory as the `receipt` argument.
623
622
  const receipt = parseReceiptArg(args.receipt);
624
- if (receipt) {
625
- const v = validateReceipt(receipt, {
626
- required_caveats: [{ type: "action_type", value: "deletions" }],
627
- });
628
- if (!v.valid) {
629
- logEvent("delete_denied", { name, reason: v.reason, receipt_id: receipt.id });
630
- throw new Error(`delete_memory refused · receipt invalid (${v.reason}). ` +
631
- `Call check_action({action: 'delete memory ${name}', action_type: 'deletions'}) ` +
632
- `to get a fresh receipt.`);
633
- }
634
- logEvent("delete_approved_via_receipt", { name, receipt_id: receipt.id });
635
- }
636
- else {
637
- logEvent("delete_without_receipt", { name });
623
+ if (!receipt) {
624
+ logEvent("delete_refused_no_receipt", { name });
625
+ throw new Error(`delete_memory refused · receipt required (v0.12.0+). ` +
626
+ `Call check_action({action: 'delete memory ${name}', action_type: 'deletions'}) ` +
627
+ `first, then pass the issued receipt as the 'receipt' argument to delete_memory.`);
628
+ }
629
+ const v = validateReceipt(receipt, {
630
+ required_caveats: [{ type: "action_type", value: "deletions" }],
631
+ });
632
+ if (!v.valid) {
633
+ logEvent("delete_denied", { name, reason: v.reason, receipt_id: receipt.id });
634
+ throw new Error(`delete_memory refused · receipt invalid (${v.reason}). ` +
635
+ `Call check_action({action: 'delete memory ${name}', action_type: 'deletions'}) ` +
636
+ `to get a fresh receipt.`);
638
637
  }
638
+ logEvent("delete_approved_via_receipt", { name, receipt_id: receipt.id });
639
639
  return withLock(() => {
640
640
  ensureTrash();
641
641
  // Trash filename: <unix-ms>-<name>.md so restore can pick the
@@ -644,12 +644,9 @@ export function toolDeleteMemory(args) {
644
644
  const trashPath = join(TRASH_DIR, `${ts}-${name}.md`);
645
645
  renameSync(fp, trashPath);
646
646
  removeIndexEntryUnlocked(name);
647
- logEvent("delete", { name, trash: `${ts}-${name}.md`, gated: !!receipt });
647
+ logEvent("delete", { name, trash: `${ts}-${name}.md`, gated: true });
648
648
  log("debug", "delete_memory", { name });
649
- const gateMsg = receipt
650
- ? ` (gated by receipt ${receipt.id})`
651
- : " (no receipt · v0.11.3 back-compat path)";
652
- return `Moved "${name}" to trash${gateMsg}. Restore with: agent-memory restore ${name}`;
649
+ return `Moved "${name}" to trash (gated by receipt ${receipt.id}). Restore with: agent-memory restore ${name}`;
653
650
  });
654
651
  }
655
652
  function toolRestoreMemory(args) {
@@ -1504,15 +1501,116 @@ export function checkActionAgainstRules(action, actionType) {
1504
1501
  }
1505
1502
  return { hard, soft, rules_evaluated: rules.length };
1506
1503
  }
1507
- function toolCheckAction(args) {
1504
+ /**
1505
+ * Tier-2 Sampling enrichment · runs ONE rule's natural-language
1506
+ * applies_when conditions past an LLM via MCP sampling/createMessage.
1507
+ * The server makes the request; the client decides (per MCP spec)
1508
+ * whether to forward to its LLM, prompt the user, or refuse.
1509
+ *
1510
+ * On any error (client lacks sampling, user refused, unparseable
1511
+ * response), returns null — Tier-2 silently degrades to "no extra
1512
+ * violations found" and we ship the Tier-1 result.
1513
+ */
1514
+ function clientSupportsSampling() {
1515
+ // server.getClientCapabilities() is undefined before the MCP initialize
1516
+ // handshake; once initialized, returns the capabilities the client
1517
+ // declared. We only call Sampling if `sampling` is in there — saves a
1518
+ // round-trip and prevents test harnesses (which don't respond to
1519
+ // sampling/createMessage) from hanging.
1520
+ try {
1521
+ const caps = server.getClientCapabilities();
1522
+ return !!caps?.sampling;
1523
+ }
1524
+ catch {
1525
+ return false;
1526
+ }
1527
+ }
1528
+ async function runTier2Sampling(rule, action, actionType) {
1529
+ if (!rule.applies_when || rule.applies_when.length === 0)
1530
+ return null;
1531
+ if (!clientSupportsSampling())
1532
+ return null;
1533
+ const prompt = `You are evaluating whether a proposed action violates an operator rule.\n\n` +
1534
+ `RULE:\n` +
1535
+ ` name: ${rule.name}\n` +
1536
+ ` description: ${rule.description}\n` +
1537
+ ` severity: ${rule.severity ?? "soft"}\n` +
1538
+ ` applies_when:\n` +
1539
+ rule.applies_when.map((s) => ` - ${s}`).join("\n") +
1540
+ `\n\nPROPOSED ACTION:\n` +
1541
+ ` ${action}\n` +
1542
+ ` (category: ${actionType})\n\n` +
1543
+ `Does the proposed action match any of the "applies_when" conditions?\n` +
1544
+ `Respond with strict JSON only, no commentary: {"violates": true|false, "reason": "..."}.\n` +
1545
+ `If the action is ambiguous, answer false.`;
1546
+ try {
1547
+ const result = await server.request({
1548
+ method: "sampling/createMessage",
1549
+ params: {
1550
+ messages: [{ role: "user", content: { type: "text", text: prompt } }],
1551
+ systemPrompt: "You are a strict policy evaluator. Reply with JSON only.",
1552
+ maxTokens: 200,
1553
+ modelPreferences: { intelligencePriority: 0.8, speedPriority: 0.4 },
1554
+ },
1555
+ }, CreateMessageResultSchema);
1556
+ const text = result.content.type === "text" ? result.content.text : "";
1557
+ // Tolerate a stray code-fence around the JSON.
1558
+ const cleaned = text.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
1559
+ const parsed = JSON.parse(cleaned);
1560
+ if (parsed.violates === true) {
1561
+ return {
1562
+ rule: rule.name,
1563
+ severity: rule.severity ?? "soft",
1564
+ reason: `Sampling judgment: ${parsed.reason ?? "applies_when matched"}`,
1565
+ };
1566
+ }
1567
+ return null;
1568
+ }
1569
+ catch (err) {
1570
+ // Sampling unsupported on this client, user refused, response
1571
+ // unparseable, or any other transport-level failure. Degrade
1572
+ // silently to Tier-1 only · we never block a check_action call
1573
+ // because Tier-2 couldn't run.
1574
+ log("debug", "tier2_sampling_skipped", {
1575
+ rule: rule.name,
1576
+ error: err instanceof Error ? err.message : String(err),
1577
+ });
1578
+ return null;
1579
+ }
1580
+ }
1581
+ async function toolCheckAction(args) {
1508
1582
  const action = String(args.action ?? "").trim();
1509
1583
  const actionType = String(args.action_type ?? "").trim();
1510
1584
  const sessionId = typeof args.session_id === "string" ? args.session_id.trim() : "";
1585
+ // Tier-2 Sampling is opt-out: defaults to true on clients that support
1586
+ // it; gracefully degrades on clients that don't. Set to false to skip
1587
+ // the LLM round-trip entirely (e.g. for batched/script use).
1588
+ const tier2Enabled = args.use_sampling !== false;
1511
1589
  if (!action)
1512
1590
  throw new Error("action is required (the proposed action description)");
1513
1591
  if (!actionType)
1514
1592
  throw new Error("action_type is required (e.g. 'deletions', 'commits', 'file_writes', 'chat_responses')");
1515
1593
  const { hard, soft, rules_evaluated } = checkActionAgainstRules(action, actionType);
1594
+ // Tier-2: run Sampling for any rule with applies_when that DIDN'T
1595
+ // already match deterministically. Rules already flagged in Tier-1
1596
+ // don't need a Sampling round-trip (we know they violate).
1597
+ if (tier2Enabled) {
1598
+ const tier1HitRules = new Set([...hard.map((v) => v.rule), ...soft.map((v) => v.rule)]);
1599
+ const rules = loadAllRules();
1600
+ const tier2Candidates = rules.filter((r) => r.applies_when &&
1601
+ r.applies_when.length > 0 &&
1602
+ !tier1HitRules.has(r.name) &&
1603
+ (!r.enforce_on || r.enforce_on.length === 0 || r.enforce_on.includes(actionType)));
1604
+ for (const rule of tier2Candidates) {
1605
+ const violation = await runTier2Sampling(rule, action, actionType);
1606
+ if (violation) {
1607
+ if (violation.severity === "hard")
1608
+ hard.push(violation);
1609
+ else
1610
+ soft.push(violation);
1611
+ }
1612
+ }
1613
+ }
1516
1614
  if (hard.length > 0) {
1517
1615
  const result = {
1518
1616
  approved: false,
@@ -1681,7 +1779,14 @@ function recentDenials() {
1681
1779
  }));
1682
1780
  }
1683
1781
  function recentUnreceiptedDeletes() {
1684
- const records = readEventLog({ tail: AUDIT_EVENT_TAIL, action: "delete_without_receipt" });
1782
+ // v0.11.x emitted "delete_without_receipt" when an unreceipted delete
1783
+ // succeeded. v0.12.0 emits "delete_refused_no_receipt" when refusing.
1784
+ // We surface BOTH event types so an audit run against pre-v0.12 logs
1785
+ // still reports historical unreceipted deletes correctly.
1786
+ const records = [
1787
+ ...readEventLog({ tail: AUDIT_EVENT_TAIL, action: "delete_without_receipt" }),
1788
+ ...readEventLog({ tail: AUDIT_EVENT_TAIL, action: "delete_refused_no_receipt" }),
1789
+ ];
1685
1790
  return records.map((r) => ({
1686
1791
  ts: String(r.ts),
1687
1792
  name: String(r.name ?? ""),
@@ -2087,7 +2192,7 @@ function actionColor(action) {
2087
2192
  // -------------------------------------------------------------
2088
2193
  // Server wiring
2089
2194
  // -------------------------------------------------------------
2090
- const server = new Server({ name: "agent-memory", version: "0.11.6" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
2195
+ const server = new Server({ name: "agent-memory", version: "0.12.0" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
2091
2196
  // -------------------------------------------------------------
2092
2197
  // Resource URI scheme
2093
2198
  // -------------------------------------------------------------
@@ -2411,18 +2516,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2411
2516
  {
2412
2517
  name: "delete_memory",
2413
2518
  description: "Move a memory to .trash/ (soft delete). The file is removed from the index but recoverable via restore_memory until you manually empty .trash/. " +
2414
- "v0.11.3+ accepts an optional `receipt` argument · pass a Compliance Receipt from check_action({action_type: 'deletions'}) to gate the delete against the rule store. " +
2519
+ "v0.12.0+ · receipt REQUIRED. Caller MUST first call check_action({action: 'delete memory <name>', action_type: 'deletions'}) to obtain a fresh Compliance Receipt, then pass it to this tool as `receipt`. " +
2415
2520
  "Receipts must carry the caveat {type: 'action_type', value: 'deletions'} or the delete refuses. " +
2416
- "Receipts not supplied are accepted (back-compat) but logged · v0.12 will require them.",
2521
+ "Migration from v0.11.x: previously unreceipted deletes were accepted with a warning · now they throw. Add a check_action call before each delete_memory call.",
2417
2522
  inputSchema: {
2418
2523
  type: "object",
2419
2524
  properties: {
2420
2525
  name: { type: "string", description: "The memory's name slug" },
2421
2526
  receipt: {
2422
- description: "Optional Compliance Receipt (object or JSON string) from check_action. v0.11.3 logs unreceipted deletes but doesn't block them yet.",
2527
+ description: "REQUIRED · Compliance Receipt (object or JSON string) from check_action with action_type=deletions. Without this, the delete is refused.",
2423
2528
  },
2424
2529
  },
2425
- required: ["name"],
2530
+ required: ["name", "receipt"],
2426
2531
  },
2427
2532
  },
2428
2533
  {
@@ -2611,7 +2716,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2611
2716
  " - APPROVES: returns a short-lived Compliance Receipt (HMAC-signed, 60s default) the agent can pass to destructive tools (e.g. delete_memory) as proof of compliance.\n" +
2612
2717
  " - DENIES: returns structured hard_violations (severity:hard rules that block) and/or soft_warnings (severity:soft rules that warn but allow).\n\n" +
2613
2718
  "Tier 1 (deterministic) matches the action against rule.matches regexes + rule.enforce_on category filter. Works on every MCP client.\n" +
2614
- "Tier 2 (Sampling-enriched LLM judgment on rule.applies_when) ships in v0.11.3.x for clients that support Sampling (Claude Desktop, VS Code Copilot).",
2719
+ "Tier 2 (v0.11.7+) calls back to the client via MCP sampling/createMessage to judge rule.applies_when natural-language conditions. Auto-enabled on clients that declared the sampling capability; silently skipped on clients that didn't.",
2615
2720
  inputSchema: {
2616
2721
  type: "object",
2617
2722
  properties: {
@@ -2627,6 +2732,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2627
2732
  type: "string",
2628
2733
  description: "Optional session identifier · binds the issued receipt to this session via a caveat.",
2629
2734
  },
2735
+ use_sampling: {
2736
+ type: "boolean",
2737
+ description: "Opt out of Tier-2 Sampling enrichment (default true). Set false for batched/scripted use where the Sampling round-trip would add latency. CLI invocations default this to false automatically.",
2738
+ },
2630
2739
  },
2631
2740
  required: ["action", "action_type"],
2632
2741
  },
@@ -2721,7 +2830,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2721
2830
  result = toolEmitCompanions(args);
2722
2831
  break;
2723
2832
  case "check_action":
2724
- result = toolCheckAction(args);
2833
+ result = await toolCheckAction(args);
2725
2834
  break;
2726
2835
  case "audit":
2727
2836
  result = toolAudit(args);
@@ -2881,7 +2990,19 @@ async function cliMain(command, rest) {
2881
2990
  const name = positional[0];
2882
2991
  if (!name)
2883
2992
  throw new Error("Usage: agent-memory delete <name>");
2884
- process.stdout.write(toolDeleteMemory({ name }) + "\n");
2993
+ // v0.12.0+ · delete_memory requires a Compliance Receipt. The CLI
2994
+ // is the trusted operator path (a human is running the command,
2995
+ // not an AI agent), so we auto-issue a CLI-scoped receipt rather
2996
+ // than make the operator chain `check-action` then paste JSON.
2997
+ // MCP callers (AI agents) still must go through check_action
2998
+ // explicitly — this short-circuit only fires from the CLI binary.
2999
+ const receipt = issueReceipt({
3000
+ caveats: [
3001
+ { type: "action_type", value: "deletions" },
3002
+ { type: "issued_by", value: "cli" },
3003
+ ],
3004
+ });
3005
+ process.stdout.write(toolDeleteMemory({ name, receipt: JSON.stringify(receipt) }) + "\n");
2885
3006
  return 0;
2886
3007
  }
2887
3008
  case "restore": {
@@ -3018,11 +3139,14 @@ async function cliMain(command, rest) {
3018
3139
  if (!action || !actionType) {
3019
3140
  throw new Error("Usage: agent-memory check-action '<action description>' --type <action_type> [--session <id>]");
3020
3141
  }
3021
- process.stdout.write(toolCheckAction({
3142
+ process.stdout.write((await toolCheckAction({
3022
3143
  action,
3023
3144
  action_type: actionType,
3024
3145
  session_id: flags.session ? String(flags.session) : undefined,
3025
- }) + "\n");
3146
+ // CLI invocations don't have a Sampling-capable client attached,
3147
+ // so skip Tier 2 to avoid a timeout · keeps the CLI fast.
3148
+ use_sampling: false,
3149
+ })) + "\n");
3026
3150
  return 0;
3027
3151
  }
3028
3152
  case "audit": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xultrax-web/agent-memory-mcp",
3
- "version": "0.11.6",
3
+ "version": "0.12.0",
4
4
  "mcpName": "io.github.xultrax-web/agent-memory-mcp",
5
5
  "description": "Codify how you work. Every AI tool obeys. Markdown rules + cross-tool companion files (AGENTS.md/CLAUDE.md/.cursor/rules/.gemini) + Compliance Receipts for protocol-level enforcement of destructive ops. Reference implementation of CRP 1.0. Works on every MCP client (no Sampling required).",
6
6
  "type": "module",