loopctl-mcp-server 2.112.0 → 2.113.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/index.js CHANGED
@@ -15,6 +15,7 @@ import os from "node:os";
15
15
  import crypto from "node:crypto";
16
16
  import path from "node:path";
17
17
  import { applyArgAliases } from "./lib/arg-aliases.js";
18
+ import { articleId } from "./lib/article-id.js";
18
19
  import { clientContextHeader } from "./lib/client-context.js";
19
20
  import { resolveClaimSessionId } from "./lib/claim-session.js";
20
21
  import { degradedSearchNotice } from "./lib/search-notices.js";
@@ -1668,8 +1669,10 @@ async function knowledgeStats({ project_id }) {
1668
1669
  }
1669
1670
 
1670
1671
  async function knowledgeGraph({ article_id, depth, project_id }) {
1672
+ const art = articleId(article_id);
1673
+ if (art.refusal) return toContent(art.refusal);
1671
1674
  const params = new URLSearchParams();
1672
- params.set("article_id", article_id);
1675
+ params.set("article_id", art.id);
1673
1676
  if (depth != null) params.set("depth", String(depth));
1674
1677
  if (project_id) params.set("project_id", project_id);
1675
1678
  const result = await apiCall(
@@ -1682,11 +1685,13 @@ async function knowledgeGraph({ article_id, depth, project_id }) {
1682
1685
  }
1683
1686
 
1684
1687
  async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
1688
+ const art = articleId(article_id);
1689
+ if (art.refusal) return toContent(art.refusal);
1685
1690
  const params = new URLSearchParams();
1686
1691
  if (limit != null) params.set("limit", String(limit));
1687
1692
  if (threshold != null) params.set("threshold", String(threshold));
1688
1693
  const qs = params.toString();
1689
- const base = `/api/v1/knowledge/articles/${article_id}/suggested_links`;
1694
+ const base = `/api/v1/knowledge/articles/${art.id}/suggested_links`;
1690
1695
  const result = await apiCall(
1691
1696
  "GET",
1692
1697
  qs ? `${base}?${qs}` : base,
@@ -1871,6 +1876,8 @@ async function knowledgeHeatIndex({ category, limit, since }) {
1871
1876
  }
1872
1877
 
1873
1878
  async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
1879
+ const art = articleId(article_id, { prefix: true });
1880
+ if (art.refusal) return toContent(art.refusal);
1874
1881
  const params = new URLSearchParams();
1875
1882
  // 0 is meaningful on both (whole body / start at the beginning), so test for
1876
1883
  // null/undefined rather than truthiness.
@@ -1879,7 +1886,7 @@ async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offs
1879
1886
  if (body_offset !== undefined && body_offset !== null)
1880
1887
  params.set("body_offset", String(body_offset));
1881
1888
  const qs = params.toString();
1882
- const base = `/api/v1/knowledge/progressive/${article_id}`;
1889
+ const base = `/api/v1/knowledge/progressive/${art.id}`;
1883
1890
  const result = await apiCall(
1884
1891
  "GET",
1885
1892
  qs ? `${base}?${qs}` : base,
@@ -1942,6 +1949,11 @@ async function knowledgeGet({
1942
1949
  body_max_bytes,
1943
1950
  body_offset,
1944
1951
  }) {
1952
+ // A missing id went out as /undefined and a malformed one as whatever it spelled
1953
+ // ('', '#', '..', 'x?y' each reach a different route); the server's answer read as
1954
+ // "this article does not exist". Refused here with status 0, before any request.
1955
+ const art = articleId(article_id, { prefix: true });
1956
+ if (art.refusal) return toContent(art.refusal);
1945
1957
  const params = new URLSearchParams();
1946
1958
  if (project_id) params.set("project_id", project_id);
1947
1959
  if (story_id) params.set("story_id", story_id);
@@ -1953,7 +1965,7 @@ async function knowledgeGet({
1953
1965
  if (body_offset !== undefined && body_offset !== null)
1954
1966
  params.set("body_offset", String(body_offset));
1955
1967
  const qs = params.toString();
1956
- const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
1968
+ const path = qs ? `/api/v1/articles/${art.id}?${qs}` : `/api/v1/articles/${art.id}`;
1957
1969
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
1958
1970
  return toContent(result);
1959
1971
  }
@@ -2031,6 +2043,8 @@ async function knowledgeCreate({
2031
2043
  // provided fields change. Agent role — KB-content curation, visibility-scoped
2032
2044
  // server-side (another agent's private/owner memory 404s).
2033
2045
  async function knowledgeUpdate({ article_id, title, body, category, tags, metadata }) {
2046
+ const art = articleId(article_id);
2047
+ if (art.refusal) return toContent(art.refusal);
2034
2048
  const payload = {};
2035
2049
  if (title != null) payload.title = title;
2036
2050
  if (body != null) payload.body = body;
@@ -2040,7 +2054,7 @@ async function knowledgeUpdate({ article_id, title, body, category, tags, metada
2040
2054
 
2041
2055
  const result = await apiCall(
2042
2056
  "PATCH",
2043
- `/api/v1/articles/${article_id}`,
2057
+ `/api/v1/articles/${art.id}`,
2044
2058
  payload,
2045
2059
  process.env.LOOPCTL_AGENT_KEY,
2046
2060
  );
@@ -2231,7 +2245,9 @@ async function memoryGraduate({ memory_id, re_scope }) {
2231
2245
  // --- Knowledge Management Tools (orch key) ---
2232
2246
 
2233
2247
  async function knowledgePublish({ article_id }) {
2234
- const result = await apiCall("POST", `/api/v1/articles/${article_id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
2248
+ const art = articleId(article_id);
2249
+ if (art.refusal) return toContent(art.refusal);
2250
+ const result = await apiCall("POST", `/api/v1/articles/${art.id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
2235
2251
  return toContent(result);
2236
2252
  }
2237
2253
 
@@ -2286,9 +2302,11 @@ async function knowledgeBulkUnpublish({ article_ids }) {
2286
2302
  }
2287
2303
 
2288
2304
  async function knowledgeUnpublish({ article_id }) {
2305
+ const art = articleId(article_id);
2306
+ if (art.refusal) return toContent(art.refusal);
2289
2307
  const result = await apiCall(
2290
2308
  "POST",
2291
- `/api/v1/articles/${article_id}/unpublish`,
2309
+ `/api/v1/articles/${art.id}/unpublish`,
2292
2310
  null,
2293
2311
  process.env.LOOPCTL_USER_KEY
2294
2312
  );
@@ -2299,9 +2317,11 @@ async function knowledgeUnpublish({ article_id }) {
2299
2317
  // audited, visibility-scoped server-side). NOT reversible in code — #606/#605: `:archived`
2300
2318
  // is a terminal status. The row survives; nothing automated brings it back.
2301
2319
  async function knowledgeArchive({ article_id }) {
2320
+ const art = articleId(article_id);
2321
+ if (art.refusal) return toContent(art.refusal);
2302
2322
  const result = await apiCall(
2303
2323
  "POST",
2304
- `/api/v1/articles/${article_id}/archive`,
2324
+ `/api/v1/articles/${art.id}/archive`,
2305
2325
  null,
2306
2326
  process.env.LOOPCTL_AGENT_KEY
2307
2327
  );
@@ -2312,9 +2332,11 @@ async function knowledgeArchive({ article_id }) {
2312
2332
  // one member of that family that undoes: nothing is destroyed and nothing is rebuilt, so
2313
2333
  // knowledge_unsuppress restores the article to every read path immediately.
2314
2334
  async function knowledgeSuppress({ article_id, reason }) {
2335
+ const art = articleId(article_id);
2336
+ if (art.refusal) return toContent(art.refusal);
2315
2337
  const result = await apiCall(
2316
2338
  "POST",
2317
- `/api/v1/articles/${article_id}/suppress`,
2339
+ `/api/v1/articles/${art.id}/suppress`,
2318
2340
  { reason },
2319
2341
  process.env.LOOPCTL_AGENT_KEY
2320
2342
  );
@@ -2322,9 +2344,11 @@ async function knowledgeSuppress({ article_id, reason }) {
2322
2344
  }
2323
2345
 
2324
2346
  async function knowledgeUnsuppress({ article_id }) {
2347
+ const art = articleId(article_id);
2348
+ if (art.refusal) return toContent(art.refusal);
2325
2349
  const result = await apiCall(
2326
2350
  "POST",
2327
- `/api/v1/articles/${article_id}/unsuppress`,
2351
+ `/api/v1/articles/${art.id}/unsuppress`,
2328
2352
  null,
2329
2353
  process.env.LOOPCTL_AGENT_KEY
2330
2354
  );
@@ -2333,9 +2357,11 @@ async function knowledgeUnsuppress({ article_id }) {
2333
2357
 
2334
2358
  // #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
2335
2359
  async function knowledgeDelete({ article_id }) {
2360
+ const art = articleId(article_id);
2361
+ if (art.refusal) return toContent(art.refusal);
2336
2362
  const result = await apiCall(
2337
2363
  "DELETE",
2338
- `/api/v1/articles/${article_id}`,
2364
+ `/api/v1/articles/${art.id}`,
2339
2365
  null,
2340
2366
  process.env.LOOPCTL_AGENT_KEY
2341
2367
  );
@@ -2812,9 +2838,11 @@ async function knowledgeCurationLog({ kind, since, limit, offset } = {}) {
2812
2838
  }
2813
2839
 
2814
2840
  async function knowledgeArticleStats({ article_id }) {
2841
+ const art = articleId(article_id, { prefix: true });
2842
+ if (art.refusal) return toContent(art.refusal);
2815
2843
  const result = await apiCall(
2816
2844
  "GET",
2817
- `/api/v1/knowledge/articles/${article_id}/stats`,
2845
+ `/api/v1/knowledge/articles/${art.id}/stats`,
2818
2846
  null,
2819
2847
  process.env.LOOPCTL_ORCH_KEY
2820
2848
  );
@@ -5542,7 +5570,8 @@ const TOOLS = [
5542
5570
  article_id: {
5543
5571
  type: "string",
5544
5572
  format: "uuid",
5545
- description: "Starting article UUID (required).",
5573
+ description:
5574
+ "Starting article UUID (required). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
5546
5575
  },
5547
5576
  depth: {
5548
5577
  type: "integer",
@@ -5575,7 +5604,8 @@ const TOOLS = [
5575
5604
  article_id: {
5576
5605
  type: "string",
5577
5606
  format: "uuid",
5578
- description: "The article to suggest links for (required).",
5607
+ description:
5608
+ "The article to suggest links for (required). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
5579
5609
  },
5580
5610
  limit: { type: "integer", description: "Max candidates (default 5)." },
5581
5611
  threshold: {
@@ -5925,8 +5955,8 @@ const TOOLS = [
5925
5955
  properties: {
5926
5956
  article_id: {
5927
5957
  type: "string",
5928
- format: "uuid",
5929
- description: "The UUID of the article to open (from a progressive index stub).",
5958
+ description:
5959
+ "The UUID of the article to open (from a progressive index stub). Or a prefix of at least 8 hex digits (the server matches its first 8). A missing, blank, shorter or non-hex value is refused locally with status 0 and no request is sent.",
5930
5960
  },
5931
5961
  body_max_bytes: {
5932
5962
  type: "integer",
@@ -5985,7 +6015,10 @@ const TOOLS = [
5985
6015
  description:
5986
6016
  "The UUID of the article. A unique ID PREFIX (>= 8 hex characters) also " +
5987
6017
  "resolves, so copy what you have rather than reconstructing 36 characters " +
5988
- "from memory; an ambiguous prefix is a 404, never a guess.",
6018
+ "from memory; an ambiguous prefix is a 404, never a guess. The server matches a " +
6019
+ "prefix on its first 8 hex digits only. Surrounding whitespace is trimmed. A " +
6020
+ "missing or blank id, fewer than 8 hex digits, or any character other than hex " +
6021
+ "digits and dashes is refused locally with status 0 and no request is sent.",
5989
6022
  },
5990
6023
  links: {
5991
6024
  type: "string",
@@ -6232,7 +6265,8 @@ const TOOLS = [
6232
6265
  properties: {
6233
6266
  article_id: {
6234
6267
  type: "string",
6235
- description: "The UUID of the article to edit (preserved across the update).",
6268
+ description:
6269
+ "The UUID of the article to edit (preserved across the update). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6236
6270
  },
6237
6271
  title: {
6238
6272
  type: "string",
@@ -6610,7 +6644,8 @@ const TOOLS = [
6610
6644
  properties: {
6611
6645
  article_id: {
6612
6646
  type: "string",
6613
- description: "The UUID of the draft article to publish.",
6647
+ description:
6648
+ "The UUID of the draft article to publish. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6614
6649
  },
6615
6650
  },
6616
6651
  required: ["article_id"],
@@ -6678,7 +6713,8 @@ const TOOLS = [
6678
6713
  properties: {
6679
6714
  article_id: {
6680
6715
  type: "string",
6681
- description: "The UUID of the published article to unpublish.",
6716
+ description:
6717
+ "The UUID of the published article to unpublish. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6682
6718
  },
6683
6719
  },
6684
6720
  required: ["article_id"],
@@ -6701,7 +6737,8 @@ const TOOLS = [
6701
6737
  properties: {
6702
6738
  article_id: {
6703
6739
  type: "string",
6704
- description: "The UUID of the article to archive.",
6740
+ description:
6741
+ "The UUID of the article to archive. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6705
6742
  },
6706
6743
  },
6707
6744
  required: ["article_id"],
@@ -6736,7 +6773,8 @@ const TOOLS = [
6736
6773
  properties: {
6737
6774
  article_id: {
6738
6775
  type: "string",
6739
- description: "The UUID of the article to take out of retrieval.",
6776
+ description:
6777
+ "The UUID of the article to take out of retrieval. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6740
6778
  },
6741
6779
  reason: {
6742
6780
  type: "string",
@@ -6764,7 +6802,8 @@ const TOOLS = [
6764
6802
  properties: {
6765
6803
  article_id: {
6766
6804
  type: "string",
6767
- description: "The UUID of the article to restore to retrieval.",
6805
+ description:
6806
+ "The UUID of the article to restore to retrieval. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6768
6807
  },
6769
6808
  },
6770
6809
  required: ["article_id"],
@@ -6786,7 +6825,8 @@ const TOOLS = [
6786
6825
  properties: {
6787
6826
  article_id: {
6788
6827
  type: "string",
6789
- description: "The UUID of the article to delete.",
6828
+ description:
6829
+ "The UUID of the article to delete. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6790
6830
  },
6791
6831
  },
6792
6832
  required: ["article_id"],
@@ -8092,7 +8132,8 @@ const TOOLS = [
8092
8132
  properties: {
8093
8133
  article_id: {
8094
8134
  type: "string",
8095
- description: "The UUID of the article to inspect.",
8135
+ description:
8136
+ "The UUID of the article to inspect. Or a prefix of at least 8 hex digits (the server matches its first 8). A missing, blank, shorter or non-hex value is refused locally with status 0 and no request is sent.",
8096
8137
  },
8097
8138
  },
8098
8139
  required: ["article_id"],
@@ -9904,6 +9945,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9904
9945
  );
9905
9946
  },
9906
9947
  declaredToolArgs(name),
9948
+ name,
9907
9949
  );
9908
9950
 
9909
9951
  switch (name) {
@@ -66,8 +66,26 @@ const ARG_ALIASES = {
66
66
  limit: ["max_results"],
67
67
  };
68
68
 
69
+ // TOOL-SCOPED aliases, keyed by tool name, applied only when the called tool declares the
70
+ // canonical and not the alias. `id` cannot go in the global table above: memory_forget and
71
+ // others declare `id` as their own parameter, and the drift guard refuses it there.
72
+ //
73
+ // `article_id <- id` on knowledge_get is measured: 17 of 96 knowledge_get calls in mac-mini's
74
+ // session transcripts (2026-10-01) passed `{"id": ...}`, the key every search result carries
75
+ // its id under, and the request went out as `/api/v1/articles/undefined`. All 30
76
+ // knowledge_get 404s in production for 2026-09-25..30 were that request.
77
+ // knowledge_progressive_drill is the same mechanism: its documented input is a progressive
78
+ // index stub, which carries the id under the same `id` key. A write verb never joins: there
79
+ // the alias would turn a harmless miss into a write against an id copied from the wrong row.
80
+ const TOOL_SCOPED_ALIASES = {
81
+ knowledge_get: { article_id: ["id"] },
82
+ knowledge_progressive_drill: { article_id: ["id"] },
83
+ };
84
+
85
+ // Whitespace-only is blank, matching articleId(), which trims: otherwise a " " article_id
86
+ // blocks the rescue here and is then refused as missing there, discarding a good `id`.
69
87
  function isBlank(v) {
70
- return v === undefined || v === null || v === "";
88
+ return v === undefined || v === null || (typeof v === "string" && v.trim() === "");
71
89
  }
72
90
 
73
91
  /**
@@ -78,8 +96,11 @@ function isBlank(v) {
78
96
  * supplied, only a fill of a declared key is reported as a rescue — the rest are inert
79
97
  * conveniences. Omit it and every fill is reported (the conservative default for a tool
80
98
  * whose schema is not known, e.g. the per-tenant `cr_*` tools).
99
+ *
100
+ * `toolName` selects that tool's TOOL_SCOPED_ALIASES, which apply only when BOTH `declared`
101
+ * and `toolName` are given.
81
102
  */
82
- function applyArgAliases(args, onAliasUsed, declared) {
103
+ function applyArgAliases(args, onAliasUsed, declared, toolName) {
83
104
  if (!args || typeof args !== "object" || Array.isArray(args)) return args;
84
105
 
85
106
  const out = { ...args };
@@ -109,6 +130,24 @@ function applyArgAliases(args, onAliasUsed, declared) {
109
130
  }
110
131
  }
111
132
 
133
+ const scoped =
134
+ declaredSet !== null && toolName && Object.hasOwn(TOOL_SCOPED_ALIASES, toolName)
135
+ ? TOOL_SCOPED_ALIASES[toolName]
136
+ : {};
137
+ for (const [canonical, aliases] of Object.entries(scoped)) {
138
+ if (!declaredSet.has(canonical) || !isBlank(out[canonical])) continue;
139
+ const alias = aliases.find((a) => !declaredSet.has(a) && !isBlank(out[a]));
140
+ if (alias === undefined) continue;
141
+ out[canonical] = out[alias];
142
+ if (typeof onAliasUsed === "function") {
143
+ try {
144
+ onAliasUsed({ canonical, alias });
145
+ } catch {
146
+ // Telemetry must never break a tool call.
147
+ }
148
+ }
149
+ }
150
+
112
151
  // Some callers send a numeric arg as a string ("6"). Coerce so downstream validation and
113
152
  // the outbound query string both see a number rather than rejecting or double-encoding.
114
153
  if (typeof out.limit === "string" && /^\d+$/.test(out.limit.trim())) {
@@ -118,4 +157,4 @@ function applyArgAliases(args, onAliasUsed, declared) {
118
157
  return out;
119
158
  }
120
159
 
121
- export { ARG_ALIASES, applyArgAliases };
160
+ export { ARG_ALIASES, TOOL_SCOPED_ALIASES, applyArgAliases };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * An `article_id` bound for a URL path or query: trimmed (an id pasted with a trailing newline
3
+ * is the id), then held to the shared `uuid()` check in lib/delivery-loop.js. Returns `{ id }`
4
+ * to send, or `{ refusal }` (status 0, value never echoed).
5
+ *
6
+ * The reads whose server path resolves an id prefix (#652: knowledge_get,
7
+ * knowledge_progressive_drill, knowledge_article_stats) pass `{ prefix: true }`; every other
8
+ * article verb takes a full UUID. A missing id used to go out as `/undefined` and come back as
9
+ * a 404 that agents read as "the article does not exist".
10
+ */
11
+ import { uuid } from "./delivery-loop.js";
12
+
13
+ export function articleId(value, { prefix = false } = {}) {
14
+ const trimmed = typeof value === "string" ? value.trim() : value;
15
+ const refusal = uuid(trimmed, "article_id", { prefix });
16
+ return refusal ? { refusal } : { id: trimmed };
17
+ }
@@ -48,7 +48,9 @@ function refuse(body) {
48
48
  }
49
49
 
50
50
  /**
51
- * A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`.
51
+ * A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`
52
+ * here, and `article_id` on the knowledge verbs through `lib/article-id.js` (which adds the
53
+ * `prefix` grammar its reads accept).
52
54
  *
53
55
  * EXPORTED, because it is one check and not a pattern to copy. `update_story`
54
56
  * (`lib/story-update.js`) interpolates a `story_id` into a path exactly as the verbs below do
@@ -111,15 +113,30 @@ function refuse(body) {
111
113
  * into the transcript.
112
114
  */
113
115
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
114
-
115
- export function uuid(value, field) {
116
- if (typeof value !== "string" || value.trim() === "") {
116
+ const PREFIX_RE = /^[0-9a-f-]+$/i;
117
+
118
+ // `prefix: true` also accepts what the server's id-prefix fallback resolves (#652,
119
+ // `Knowledge.id_prefix_range/1`, reached by knowledge_get, knowledge_progressive_drill and
120
+ // knowledge_article_stats): hex digits and dashes with at least 8 hex digits, dashless or
121
+ // partial. The server compares only the FIRST 8 hex digits and resolves when exactly one
122
+ // article has them: the rest of a longer value is not checked, so a full-length id with a
123
+ // wrong tail resolves to the article owning its first 8 (#652's design, for confabulated tails).
124
+ // Only hex and dashes are accepted, so nothing in a prefix can change the route ('#', '?', '/').
125
+ export function uuid(value, field, { prefix = false } = {}) {
126
+ if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
117
127
  return refuse(`\`${field}\` is required.`);
118
128
  }
119
129
 
130
+ if (typeof value !== "string") {
131
+ return refuse(`\`${field}\` must be a string; got a ${Array.isArray(value) ? "array" : typeof value}.`);
132
+ }
133
+
134
+ if (prefix && PREFIX_RE.test(value) && value.replace(/-/g, "").length >= 8) return null;
135
+
120
136
  if (!UUID_RE.test(value)) {
121
137
  return refuse(
122
- `\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper). ` +
138
+ `\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper)` +
139
+ (prefix ? " or a prefix of at least 8 hex digits. " : ". ") +
123
140
  `Got a ${value.length}-character string that is not one. The value is not repeated ` +
124
141
  `here: a malformed id is often something pasted into the wrong argument, and a tool ` +
125
142
  `result lands in the transcript.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.112.0",
3
+ "version": "2.113.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",