loopctl-mcp-server 2.90.0 → 2.91.1

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
@@ -342,7 +342,7 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
342
342
  | `memory_list` | List your own long-term memories, newest first, paginated with `meta.total_count/limit/offset` (the true scoped count, never silently capped by `limit`). Optional: `limit`, `offset`, `include_superseded`, `all_subjects` (superadmin only; ignored for non-superadmin keys). |
343
343
  | `memory_forget` | Delete one of your own long-term memories by id. A foreign-subject, foreign-tenant, or unknown id returns 404 (no existence leak). Required: `id`. |
344
344
  | `memory_promote` | Call at session end to compile this session's short-term (`session`-tier) memory into durable `long_term` memory — unlike `memory_remember` (a single explicit write), this compiles the whole session in one shot; fire it once at session end, not per turn. Returns 202 with `{session_id, status: "enqueued"}` — promotion runs asynchronously, so the resulting memory is recallable via `memory_recall` only after the worker drains. You can only promote your own sessions (scope resolved server-side from your key). Required: `session_id`. |
345
- | `recall_context` | ONE round-trip returning the re-ranked `global ∪ active-project` union of long-term MEMORY **and** KNOWLEDGE for `query` — what you previously assembled by calling `memory_recall` and `knowledge_search` separately. Pass `project_id` (from `resolve_project`) to merge global with that project on both sides; absent → global-only. The knowledge half is combined-search *summaries* (not full bodies — use `knowledge_context` for those). Response carries merged `results` (each tagged `source: memory\|knowledge`) plus the untouched per-source `memory`/`knowledge` envelopes; `meta.degraded?` flags a one-sided degrade (the other side is still returned — never a 500). Each per-source envelope's `meta.ann_iterative_scan` describes only THAT half's vector read, and the two are resolved independently, so they may differ. A blank query, or one over 500 chars, is a `422` up front. Required: `query`. Optional: `project_id`, `limit`. The top-level `meta.outcome` classifies the whole endpoint and the `memory` envelope carries its own; `meta.degraded_reason` names the strongest-remedy half when both degrade, and `meta.search_mode` names the lane that half actually served (`keyword_only`) or is `null` when it served nothing. Response also carries a SELECTION LEDGER: per item `rank`, `selection_reason` (`keyword`\|`semantic`\|`keyword+semantic`\|`keyword_fallback` for knowledge, `semantic`\|`ilike_fallback` for memory) and `tokens_estimate` (bytes/4, an estimate); in `meta`, `recall_id`, `candidates_considered`, `selected_count`, `tokens_selected`, `tokens_candidates`, `tokens_saved_vs_candidates`. The merged order is deterministic (score DESC, then source, then id), so an unchanged corpus renders a byte-identical `data` array — cache that, not the whole response, since `meta.recall_id` is new on every call. Keep `meta.recall_id` for `recall_referenced`. |
345
+ | `recall_context` | ONE round-trip returning the re-ranked `global ∪ active-project` union of long-term MEMORY **and** KNOWLEDGE for `query` — what you previously assembled by calling `memory_recall` and `knowledge_search` separately. Pass `project_id` (from `resolve_project`) to merge global with that project on both sides; absent → global-only. The knowledge half is combined-search *summaries* (not full bodies — use `knowledge_context` for those). Response carries merged `results` (each tagged `source: memory\|knowledge`) plus the untouched per-source `memory`/`knowledge` envelopes; `meta.degraded?` flags a one-sided degrade (the other side is still returned — never a 500). Each per-source envelope's `meta.ann_iterative_scan` describes only THAT half's vector read, and the two are resolved independently, so they may differ. A blank query, or one over 500 chars, is a `422` up front. Required: `query`. Optional: `project_id`, `limit`. The top-level `meta.outcome` classifies the whole endpoint and the `memory` envelope carries its own; `meta.degraded_reason` names the strongest-remedy half when both degrade, and `meta.search_mode` names the lane that half actually served (`keyword_only`) or is `null` when it served nothing. Response also carries a SELECTION LEDGER: per item `rank`, `selection_reason` (`keyword`\|`semantic`\|`keyword+semantic`\|`keyword_fallback` for knowledge, `semantic`\|`ilike_fallback` for memory) and `tokens_estimate` (bytes/4, an estimate); in `meta`, `recall_id`, `candidates_considered`, `selected_count`, `tokens_selected`, `tokens_candidates`, `tokens_saved_vs_candidates`. The merged order is deterministic (score DESC, then source, then id), so an unchanged corpus renders a byte-identical `data` array — cache that, not the whole response, since `meta.recall_id` is new on every call. Keep `meta.recall_id` for `recall_referenced`. DIVERSITY (#792): the knowledge half is over-fetched and then reduced, so two near-copies cannot spend two of your slots — an article already shown to this `session_id` is skipped, exact content-hash duplicates collapse, a candidate too similar to one already SELECTED is dropped, and every freed slot is REFILLED rather than left empty. `meta.diversity` reports each count; `meta.candidates_considered.knowledge` is the over-fetched pool and `meta.knowledge_count` is what survived. Selection changes WHAT you get, never the order. Optional `session_id`: an opaque per-session token (max 200 bytes) — pass the same value on every recall in a session to get containment-in-history; it is not an isolation boundary and a miss just re-surfaces an article. |
346
346
  | `recall_referenced` | Record which of the articles a recall SURFACED you actually USED — the third funnel stage (surfaced -> opened -> referenced), and the only one nothing else records. Pass the `meta.recall_id` from a `recall_context` response plus the ids you referenced -- the `article.id` of the `source: knowledge` items, since a `memory` item's id is not an article and is not referenceable; call it once, after writing your answer. Only ids THAT recall surfaced, in your own tenant, are accepted — anything else is `422` `not_surfaced` and NOTHING is written. Your key is stamped server-side, at most 50 ids per call, and repeats cannot inflate the metric (it counts distinct `(recall, article)` pairs). These rows are deliberately not reads: they never feed the heat index or any ranking. Required: `recall_id`, `article_ids`. Optional: `project_id`. |
347
347
  | `memory_graduate` | Graduate ONE of your long-term memories into a durable Knowledge Wiki article — the explicit, on-demand version of the hourly graduation sweep. Use when a private memory has proven valuable enough to become durable knowledge. **Visibility**: the graduated article stays **owner-visible** (`metadata.visibility: "owner"`, keyed to your subject) — discoverable by YOU, NOT peer-readable (graduation does not share a memory to teammates; `re_scope: "global"` widens only the project scope, not visibility). Scope is key-derived (you can only graduate your OWN memory; a foreign/unknown `memory_id` → 404). DEDUPED by the novelty gate: `data.verdict` is `created` (novel → published) or `gated_to_draft` (near-dup → review draft) with a new article (**201**), or `duplicate`/`deduplicated` (already represented → canonical article, nothing created) (**200**). By default the article inherits the memory's project scope; pass `re_scope: "global"` to promote a PROJECT memory to a tenant-wide article — only valid on its FIRST graduation, and only if the hourly sweep hasn't graduated it project-scoped first (`409` `already_graduated` otherwise). An already-graduated global memory re-graduates idempotently (**200**). `503` `gate_unavailable` if the embedding backend is down — retry later. Required: `memory_id`. Optional: `re_scope` (`inherit`\|`global`). |
348
348
 
package/index.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  GENERATED_TOOL_PREFIX,
44
44
  } from "./lib/generated-tools.js";
45
45
  import { createHandoff } from "./lib/handoff.js";
46
+ import { readPayloadFile } from "./lib/payload-path.js";
46
47
 
47
48
  // Single source of truth for the server version: the package.json this file
48
49
  // ships with (npm always includes package.json in the published tarball).
@@ -967,13 +968,7 @@ async function importStories({ project_id, payload, payload_path, merge }) {
967
968
 
968
969
  // Reads JSON payload from either an inline object or an absolute file path.
969
970
  // Returns the object on success, or an { error, body } shape on failure.
970
- //
971
- // Security: `payload_path` is read with the MCP process's filesystem
972
- // privileges. Because agents can set this argument via prompt injection,
973
- // we validate aggressively:
974
- // * require absolute path
975
- // * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
976
- // * stat first and cap at 5 MiB (server also enforces a body size limit)
971
+ // The file path is validated in lib/payload-path.js (see its security notes).
977
972
  async function resolvePayload(inline, payloadPath) {
978
973
  if (inline && typeof inline === "object") return inline;
979
974
  if (!payloadPath) {
@@ -983,53 +978,7 @@ async function resolvePayload(inline, payloadPath) {
983
978
  body: "Must provide either `payload` (object) or `payload_path` (absolute JSON file path).",
984
979
  };
985
980
  }
986
-
987
- const nodePath = await import("node:path");
988
- if (!nodePath.isAbsolute(payloadPath)) {
989
- return {
990
- error: true,
991
- status: 0,
992
- body: `payload_path must be absolute (got '${payloadPath}').`,
993
- };
994
- }
995
-
996
- const blockedPrefixes = ["/proc/", "/dev/", "/sys/", "/proc", "/dev", "/sys"];
997
- if (blockedPrefixes.some((p) => payloadPath === p.replace(/\/$/, "") || payloadPath.startsWith(p))) {
998
- return {
999
- error: true,
1000
- status: 0,
1001
- body: `payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`,
1002
- };
1003
- }
1004
-
1005
- const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
1006
- const fs = await import("node:fs/promises");
1007
-
1008
- try {
1009
- const stat = await fs.stat(payloadPath);
1010
- if (!stat.isFile()) {
1011
- return {
1012
- error: true,
1013
- status: 0,
1014
- body: `payload_path '${payloadPath}' is not a regular file.`,
1015
- };
1016
- }
1017
- if (stat.size > MAX_PAYLOAD_BYTES) {
1018
- return {
1019
- error: true,
1020
- status: 0,
1021
- body: `payload_path '${payloadPath}' is ${stat.size} bytes, exceeds max ${MAX_PAYLOAD_BYTES}.`,
1022
- };
1023
- }
1024
- const raw = await fs.readFile(payloadPath, "utf8");
1025
- return JSON.parse(raw);
1026
- } catch (err) {
1027
- return {
1028
- error: true,
1029
- status: 0,
1030
- body: `Could not read payload_path '${payloadPath}': ${err.message}`,
1031
- };
1032
- }
981
+ return readPayloadFile(payloadPath);
1033
982
  }
1034
983
 
1035
984
  // --- Story Tools ---
@@ -1928,7 +1877,7 @@ async function memoryRecall({ query, limit, include_superseded }) {
1928
1877
  return withRemediationNotice(result);
1929
1878
  }
1930
1879
 
1931
- async function recallContext({ query, project_id, limit }) {
1880
+ async function recallContext({ query, project_id, limit, session_id }) {
1932
1881
  // Merged recall (#411 Gap 2): ONE round-trip returning the re-ranked
1933
1882
  // global ∪ active-project union of long-term MEMORY and KNOWLEDGE. Scope
1934
1883
  // (tenant_id/subject_id) is derived server-side from the agent key; project_id is
@@ -1936,6 +1885,10 @@ async function recallContext({ query, project_id, limit }) {
1936
1885
  const payload = { query };
1937
1886
  if (project_id) payload.project_id = project_id;
1938
1887
  if (limit != null) payload.limit = limit;
1888
+ // #792: the containment-in-history key. Opaque and client-chosen; the server keys its
1889
+ // shown-set on (tenant, subject, session, article) — both server-derived halves of the
1890
+ // memory scope ahead of the token — so it is never an isolation boundary.
1891
+ if (session_id) payload.session_id = session_id;
1939
1892
 
1940
1893
  const result = await apiCall(
1941
1894
  "POST",
@@ -3923,7 +3876,7 @@ const TOOLS = [
3923
3876
  payload_path: {
3924
3877
  type: "string",
3925
3878
  description:
3926
- "Absolute path to a JSON file with the import payload. Avoids inline size limits for large epics. Ignored if `payload` is also passed.",
3879
+ "Absolute path to a .json file holding the import payload (an object with an `epics` array). Avoids inline size limits for large epics. Ignored if `payload` is also passed.",
3927
3880
  },
3928
3881
  merge: {
3929
3882
  type: "boolean",
@@ -5643,7 +5596,13 @@ const TOOLS = [
5643
5596
  "the whole response, since `meta.recall_id` is new on every call. KEEP " +
5644
5597
  "`meta.recall_id`: after you " +
5645
5598
  "answer, pass it to recall_referenced with the ids you actually used — that is the " +
5646
- "third funnel stage and nothing else records it.",
5599
+ "third funnel stage and nothing else records it. DIVERSITY (#792): the knowledge " +
5600
+ "half is over-fetched and then reduced, so two near-copies cannot spend two of your " +
5601
+ "slots — an article already shown to this `session_id` is skipped, exact " +
5602
+ "content-hash duplicates collapse, a candidate too similar to one already selected " +
5603
+ "is dropped, and every freed slot is REFILLED rather than left empty. " +
5604
+ "`meta.diversity` reports each count. Selection changes WHAT you get, never the " +
5605
+ "order, so the deterministic `data` array above still holds.",
5647
5606
  inputSchema: {
5648
5607
  type: "object",
5649
5608
  properties: {
@@ -5663,6 +5622,19 @@ const TOOLS = [
5663
5622
  description:
5664
5623
  "Optional: overall merged page size, clamped to [1, 50] (default 10).",
5665
5624
  },
5625
+ session_id: {
5626
+ type: "string",
5627
+ description:
5628
+ "Optional: an opaque token for THIS session (max 200 bytes). Pass the same " +
5629
+ "value on every recall in a session and the server skips articles it already " +
5630
+ "showed you, refilling the freed slot with the next distinct candidate — " +
5631
+ "which a client-side filter cannot do. It can never starve you: once a " +
5632
+ "session has exhausted the matching pool the highest-ranked repeats come " +
5633
+ "back, so an empty knowledge half always means the corpus, never the " +
5634
+ "suppression. Not an isolation boundary (history is keyed on tenant + " +
5635
+ "subject + session + article) and best-effort: a miss just re-surfaces an " +
5636
+ "article. Omit it to disable containment for that call.",
5637
+ },
5666
5638
  },
5667
5639
  required: ["query"],
5668
5640
  },
@@ -0,0 +1,96 @@
1
+ // Reads an import_stories payload from an absolute file path.
2
+ //
3
+ // Security: `payload_path` is read with the MCP process's filesystem privileges and an
4
+ // agent can set it via prompt injection, so no file CONTENT is ever returned, and a file
5
+ // that is not an import payload is never uploaded. The refusals still say which check
6
+ // failed (missing, not a file, too large, not JSON, wrong shape) for a .json path; that
7
+ // is metadata an agent needs to fix its own call, not content:
8
+ // * require an absolute path ending in .json, checked on the path AND on its realpath,
9
+ // so a symlink named x.json cannot point at /etc/passwd or /proc
10
+ // * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
11
+ // * stat first and cap at 5 MiB (server also enforces a body size limit)
12
+ // * never echo a JSON.parse or fs error message: V8's SyntaxError quotes the file's
13
+ // content ("root:x:0:0"... is not valid JSON), so only err.code is reported
14
+ // * require the Epic 12 import shape (an object with an `epics` array) before returning,
15
+ // so a valid JSON file that is not a payload (a credentials file) is never POSTed
16
+ //
17
+ // Returns the parsed payload on success, or an { error, status, body } shape on failure.
18
+
19
+ import nodePath from "node:path";
20
+ import defaultFs from "node:fs/promises";
21
+
22
+ export const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
23
+
24
+ const BLOCKED_ROOTS = ["/proc", "/dev", "/sys"];
25
+
26
+ function refuse(body) {
27
+ return { error: true, status: 0, body };
28
+ }
29
+
30
+ function isBlocked(p) {
31
+ return BLOCKED_ROOTS.some((root) => p === root || p.startsWith(root + "/"));
32
+ }
33
+
34
+ function hasJsonExtension(p) {
35
+ return nodePath.extname(p).toLowerCase() === ".json";
36
+ }
37
+
38
+ export async function readPayloadFile(payloadPath, { fs = defaultFs } = {}) {
39
+ if (typeof payloadPath !== "string" || !nodePath.isAbsolute(payloadPath)) {
40
+ return refuse(`payload_path must be absolute (got '${payloadPath}').`);
41
+ }
42
+
43
+ if (isBlocked(nodePath.resolve(payloadPath))) {
44
+ return refuse(`payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`);
45
+ }
46
+
47
+ if (!hasJsonExtension(payloadPath)) {
48
+ return refuse(`payload_path refused: '${payloadPath}' must be a .json file.`);
49
+ }
50
+
51
+ let realPath;
52
+ try {
53
+ realPath = await fs.realpath(payloadPath);
54
+ } catch (err) {
55
+ return refuse(`Could not read payload_path '${payloadPath}' (${err.code || "error"}).`);
56
+ }
57
+
58
+ if (isBlocked(realPath)) {
59
+ return refuse(`payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`);
60
+ }
61
+
62
+ if (!hasJsonExtension(realPath)) {
63
+ return refuse(`payload_path refused: '${payloadPath}' must be a .json file.`);
64
+ }
65
+
66
+ let raw;
67
+ try {
68
+ const stat = await fs.stat(realPath);
69
+ if (!stat.isFile()) {
70
+ return refuse(`payload_path '${payloadPath}' is not a regular file.`);
71
+ }
72
+ if (stat.size > MAX_PAYLOAD_BYTES) {
73
+ return refuse(
74
+ `payload_path '${payloadPath}' exceeds max ${MAX_PAYLOAD_BYTES} bytes.`,
75
+ );
76
+ }
77
+ raw = await fs.readFile(realPath, "utf8");
78
+ } catch (err) {
79
+ return refuse(`Could not read payload_path '${payloadPath}' (${err.code || "error"}).`);
80
+ }
81
+
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch {
86
+ return refuse(`payload_path '${payloadPath}' is not valid JSON.`);
87
+ }
88
+
89
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray(parsed.epics)) {
90
+ return refuse(
91
+ `payload_path '${payloadPath}' is not an import payload (expected an object with an "epics" array).`,
92
+ );
93
+ }
94
+
95
+ return parsed;
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.90.0",
3
+ "version": "2.91.1",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",