loopctl-mcp-server 2.112.0 → 2.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -199,7 +199,7 @@ tool parameter.
199
199
  | `list_projects` | List all projects in the current tenant. |
200
200
  | `resolve_project` | Resolve a repo to its `project_id` in one cheap call — provide any of `slug`, `repo_url` (`git@github.com:owner/repo.git`, `https://github.com/owner/repo`, and bare `owner/repo` all match), or `name`. Precedence `slug > repo_url > name`, first match wins. Use the returned `id` to scope captures/recall (`memory_*`, `recall_context`). `404` `not_found` if nothing matches, `422` `no_identifier` if none supplied, `409` if a fuzzy identifier matches more than one active project. |
201
201
  | `create_project` | Create a new **work** project (epics/stories/custody). Requires orchestrator+ **and** a `human_anchored` tenant; an agent-rooted tenant gets `403 custody_tier_required` and should use `create_kb_scope`. |
202
- | `delete_project` | **Requires `LOOPCTL_USER_KEY`.** Delete a project and all of its dependent resources (epics, stories, audit entries). Irreversible — orchestrator role is not sufficient. |
202
+ | `delete_project` | **Needs a user-role key:** `LOOPCTL_USER_KEY` verbatim when set, else the default key (orchestrator role is 403 `insufficient_role`). Archives the project (status `archived`); no rows are removed and its slug stays taken. |
203
203
  | `get_progress` | Get progress summary for a project, including story counts by status. Pass `include_cost=true` for cost data. |
204
204
  | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). For large payloads, use `payload_path` to read JSON from disk instead of passing it inline. With `merge: true`, `report_orphans: true` adds `stories_orphaned` (the project's stories the payload does not mention; nothing is detached) — a signal only on a FULL round-trip of an export; without it the key is absent (#880). |
205
205
 
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";
@@ -945,13 +946,16 @@ async function channelGraduate({ post_id, title, tags, category }) {
945
946
  return toContent(result);
946
947
  }
947
948
 
949
+ // Pinned like `userKeyApiCall` (LOOPCTL_USER_KEY verbatim when set, so a global
950
+ // LOOPCTL_API_KEY cannot displace it), spelled out so the route sweep sees the call.
948
951
  async function deleteProject({ project_id }) {
949
- const result = await apiCall(
950
- "DELETE",
951
- `/api/v1/projects/${project_id}`,
952
- null,
953
- process.env.LOOPCTL_USER_KEY
954
- );
952
+ const path = `/api/v1/projects/${project_id}`;
953
+ const key = process.env.LOOPCTL_USER_KEY;
954
+
955
+ const result = key
956
+ ? await apiCall("DELETE", path, null, key, { exactKey: true, keyHint: "LOOPCTL_USER_KEY" })
957
+ : await apiCall("DELETE", path, null);
958
+
955
959
  return toContent(result);
956
960
  }
957
961
 
@@ -1124,8 +1128,8 @@ async function getStory({ story_id }) {
1124
1128
  // Reads travel on the default key (any role passes). Create and update go through
1125
1129
  // `orchestratorPinnedApiCall`: the named orchestrator or user key VERBATIM, so a global
1126
1130
  // LOOPCTL_API_KEY of a lesser role cannot displace it, and the global key when neither is set. The deletes go through `userKeyApiCall`, pinned to LOOPCTL_USER_KEY so a
1127
- // global LOOPCTL_API_KEY cannot displace it: they destroy rows. (`delete_project` is not
1128
- // pinned this way.)
1131
+ // global LOOPCTL_API_KEY cannot displace it: they destroy rows. `delete_project` (an
1132
+ // archive) is pinned the same way, because its gate is `role: :user`.
1129
1133
 
1130
1134
  // ONE pinning rule for the epic and story writes: the env var `hint()` names is sent VERBATIM
1131
1135
  // when it is set, so a global LOOPCTL_API_KEY of a lesser role cannot displace it, and with none
@@ -1668,8 +1672,10 @@ async function knowledgeStats({ project_id }) {
1668
1672
  }
1669
1673
 
1670
1674
  async function knowledgeGraph({ article_id, depth, project_id }) {
1675
+ const art = articleId(article_id);
1676
+ if (art.refusal) return toContent(art.refusal);
1671
1677
  const params = new URLSearchParams();
1672
- params.set("article_id", article_id);
1678
+ params.set("article_id", art.id);
1673
1679
  if (depth != null) params.set("depth", String(depth));
1674
1680
  if (project_id) params.set("project_id", project_id);
1675
1681
  const result = await apiCall(
@@ -1682,11 +1688,13 @@ async function knowledgeGraph({ article_id, depth, project_id }) {
1682
1688
  }
1683
1689
 
1684
1690
  async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
1691
+ const art = articleId(article_id);
1692
+ if (art.refusal) return toContent(art.refusal);
1685
1693
  const params = new URLSearchParams();
1686
1694
  if (limit != null) params.set("limit", String(limit));
1687
1695
  if (threshold != null) params.set("threshold", String(threshold));
1688
1696
  const qs = params.toString();
1689
- const base = `/api/v1/knowledge/articles/${article_id}/suggested_links`;
1697
+ const base = `/api/v1/knowledge/articles/${art.id}/suggested_links`;
1690
1698
  const result = await apiCall(
1691
1699
  "GET",
1692
1700
  qs ? `${base}?${qs}` : base,
@@ -1871,6 +1879,8 @@ async function knowledgeHeatIndex({ category, limit, since }) {
1871
1879
  }
1872
1880
 
1873
1881
  async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
1882
+ const art = articleId(article_id, { prefix: true });
1883
+ if (art.refusal) return toContent(art.refusal);
1874
1884
  const params = new URLSearchParams();
1875
1885
  // 0 is meaningful on both (whole body / start at the beginning), so test for
1876
1886
  // null/undefined rather than truthiness.
@@ -1879,7 +1889,7 @@ async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offs
1879
1889
  if (body_offset !== undefined && body_offset !== null)
1880
1890
  params.set("body_offset", String(body_offset));
1881
1891
  const qs = params.toString();
1882
- const base = `/api/v1/knowledge/progressive/${article_id}`;
1892
+ const base = `/api/v1/knowledge/progressive/${art.id}`;
1883
1893
  const result = await apiCall(
1884
1894
  "GET",
1885
1895
  qs ? `${base}?${qs}` : base,
@@ -1942,6 +1952,11 @@ async function knowledgeGet({
1942
1952
  body_max_bytes,
1943
1953
  body_offset,
1944
1954
  }) {
1955
+ // A missing id went out as /undefined and a malformed one as whatever it spelled
1956
+ // ('', '#', '..', 'x?y' each reach a different route); the server's answer read as
1957
+ // "this article does not exist". Refused here with status 0, before any request.
1958
+ const art = articleId(article_id, { prefix: true });
1959
+ if (art.refusal) return toContent(art.refusal);
1945
1960
  const params = new URLSearchParams();
1946
1961
  if (project_id) params.set("project_id", project_id);
1947
1962
  if (story_id) params.set("story_id", story_id);
@@ -1953,7 +1968,7 @@ async function knowledgeGet({
1953
1968
  if (body_offset !== undefined && body_offset !== null)
1954
1969
  params.set("body_offset", String(body_offset));
1955
1970
  const qs = params.toString();
1956
- const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
1971
+ const path = qs ? `/api/v1/articles/${art.id}?${qs}` : `/api/v1/articles/${art.id}`;
1957
1972
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
1958
1973
  return toContent(result);
1959
1974
  }
@@ -2031,6 +2046,8 @@ async function knowledgeCreate({
2031
2046
  // provided fields change. Agent role — KB-content curation, visibility-scoped
2032
2047
  // server-side (another agent's private/owner memory 404s).
2033
2048
  async function knowledgeUpdate({ article_id, title, body, category, tags, metadata }) {
2049
+ const art = articleId(article_id);
2050
+ if (art.refusal) return toContent(art.refusal);
2034
2051
  const payload = {};
2035
2052
  if (title != null) payload.title = title;
2036
2053
  if (body != null) payload.body = body;
@@ -2040,7 +2057,7 @@ async function knowledgeUpdate({ article_id, title, body, category, tags, metada
2040
2057
 
2041
2058
  const result = await apiCall(
2042
2059
  "PATCH",
2043
- `/api/v1/articles/${article_id}`,
2060
+ `/api/v1/articles/${art.id}`,
2044
2061
  payload,
2045
2062
  process.env.LOOPCTL_AGENT_KEY,
2046
2063
  );
@@ -2231,7 +2248,9 @@ async function memoryGraduate({ memory_id, re_scope }) {
2231
2248
  // --- Knowledge Management Tools (orch key) ---
2232
2249
 
2233
2250
  async function knowledgePublish({ article_id }) {
2234
- const result = await apiCall("POST", `/api/v1/articles/${article_id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
2251
+ const art = articleId(article_id);
2252
+ if (art.refusal) return toContent(art.refusal);
2253
+ const result = await apiCall("POST", `/api/v1/articles/${art.id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
2235
2254
  return toContent(result);
2236
2255
  }
2237
2256
 
@@ -2286,9 +2305,11 @@ async function knowledgeBulkUnpublish({ article_ids }) {
2286
2305
  }
2287
2306
 
2288
2307
  async function knowledgeUnpublish({ article_id }) {
2308
+ const art = articleId(article_id);
2309
+ if (art.refusal) return toContent(art.refusal);
2289
2310
  const result = await apiCall(
2290
2311
  "POST",
2291
- `/api/v1/articles/${article_id}/unpublish`,
2312
+ `/api/v1/articles/${art.id}/unpublish`,
2292
2313
  null,
2293
2314
  process.env.LOOPCTL_USER_KEY
2294
2315
  );
@@ -2299,9 +2320,11 @@ async function knowledgeUnpublish({ article_id }) {
2299
2320
  // audited, visibility-scoped server-side). NOT reversible in code — #606/#605: `:archived`
2300
2321
  // is a terminal status. The row survives; nothing automated brings it back.
2301
2322
  async function knowledgeArchive({ article_id }) {
2323
+ const art = articleId(article_id);
2324
+ if (art.refusal) return toContent(art.refusal);
2302
2325
  const result = await apiCall(
2303
2326
  "POST",
2304
- `/api/v1/articles/${article_id}/archive`,
2327
+ `/api/v1/articles/${art.id}/archive`,
2305
2328
  null,
2306
2329
  process.env.LOOPCTL_AGENT_KEY
2307
2330
  );
@@ -2312,9 +2335,11 @@ async function knowledgeArchive({ article_id }) {
2312
2335
  // one member of that family that undoes: nothing is destroyed and nothing is rebuilt, so
2313
2336
  // knowledge_unsuppress restores the article to every read path immediately.
2314
2337
  async function knowledgeSuppress({ article_id, reason }) {
2338
+ const art = articleId(article_id);
2339
+ if (art.refusal) return toContent(art.refusal);
2315
2340
  const result = await apiCall(
2316
2341
  "POST",
2317
- `/api/v1/articles/${article_id}/suppress`,
2342
+ `/api/v1/articles/${art.id}/suppress`,
2318
2343
  { reason },
2319
2344
  process.env.LOOPCTL_AGENT_KEY
2320
2345
  );
@@ -2322,9 +2347,11 @@ async function knowledgeSuppress({ article_id, reason }) {
2322
2347
  }
2323
2348
 
2324
2349
  async function knowledgeUnsuppress({ article_id }) {
2350
+ const art = articleId(article_id);
2351
+ if (art.refusal) return toContent(art.refusal);
2325
2352
  const result = await apiCall(
2326
2353
  "POST",
2327
- `/api/v1/articles/${article_id}/unsuppress`,
2354
+ `/api/v1/articles/${art.id}/unsuppress`,
2328
2355
  null,
2329
2356
  process.env.LOOPCTL_AGENT_KEY
2330
2357
  );
@@ -2333,9 +2360,11 @@ async function knowledgeUnsuppress({ article_id }) {
2333
2360
 
2334
2361
  // #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
2335
2362
  async function knowledgeDelete({ article_id }) {
2363
+ const art = articleId(article_id);
2364
+ if (art.refusal) return toContent(art.refusal);
2336
2365
  const result = await apiCall(
2337
2366
  "DELETE",
2338
- `/api/v1/articles/${article_id}`,
2367
+ `/api/v1/articles/${art.id}`,
2339
2368
  null,
2340
2369
  process.env.LOOPCTL_AGENT_KEY
2341
2370
  );
@@ -2812,9 +2841,11 @@ async function knowledgeCurationLog({ kind, since, limit, offset } = {}) {
2812
2841
  }
2813
2842
 
2814
2843
  async function knowledgeArticleStats({ article_id }) {
2844
+ const art = articleId(article_id, { prefix: true });
2845
+ if (art.refusal) return toContent(art.refusal);
2815
2846
  const result = await apiCall(
2816
2847
  "GET",
2817
- `/api/v1/knowledge/articles/${article_id}/stats`,
2848
+ `/api/v1/knowledge/articles/${art.id}/stats`,
2818
2849
  null,
2819
2850
  process.env.LOOPCTL_ORCH_KEY
2820
2851
  );
@@ -4211,13 +4242,13 @@ const TOOLS = [
4211
4242
  {
4212
4243
  name: "delete_project",
4213
4244
  description:
4214
- "Delete a project and all of its dependent resources (epics, stories, audit entries scoped to it). REQUIRES LOOPCTL_USER_KEY to be set in the MCP server env (user role — orchestrator role is NOT sufficient for this destructive operation). The deletion is irreversible.",
4245
+ "ARCHIVE a project (DELETE /api/v1/projects/:id sets status to archived; it removes no rows). Its epics, stories and audit entries stay, and an already-archived project is a no-op. The SLUG stays taken: slugs are unique per tenant across archived projects too, resolve_project by that slug still returns the archived project, create_project with it is refused 422, and no API call changes or releases a slug, so a new project needs a different one. The way back: restore_kb_scope for a kb scope; a work project is restored by PATCH /api/v1/projects/:id with status active, which no MCP tool reaches yet. Sent with LOOPCTL_USER_KEY verbatim when it is set (a global LOOPCTL_API_KEY does not displace it), else with the default key. Refusals: 403 insufficient_role below user role, 403 custody_tier_required on an agent-rooted tenant (the human-anchor gate), 404 for an id not in your tenant.",
4215
4246
  inputSchema: {
4216
4247
  type: "object",
4217
4248
  properties: {
4218
4249
  project_id: {
4219
4250
  type: "string",
4220
- description: "The UUID of the project to delete.",
4251
+ description: "The UUID of the project to archive.",
4221
4252
  },
4222
4253
  },
4223
4254
  required: ["project_id"],
@@ -5542,7 +5573,8 @@ const TOOLS = [
5542
5573
  article_id: {
5543
5574
  type: "string",
5544
5575
  format: "uuid",
5545
- description: "Starting article UUID (required).",
5576
+ description:
5577
+ "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
5578
  },
5547
5579
  depth: {
5548
5580
  type: "integer",
@@ -5575,7 +5607,8 @@ const TOOLS = [
5575
5607
  article_id: {
5576
5608
  type: "string",
5577
5609
  format: "uuid",
5578
- description: "The article to suggest links for (required).",
5610
+ description:
5611
+ "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
5612
  },
5580
5613
  limit: { type: "integer", description: "Max candidates (default 5)." },
5581
5614
  threshold: {
@@ -5925,8 +5958,8 @@ const TOOLS = [
5925
5958
  properties: {
5926
5959
  article_id: {
5927
5960
  type: "string",
5928
- format: "uuid",
5929
- description: "The UUID of the article to open (from a progressive index stub).",
5961
+ description:
5962
+ "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
5963
  },
5931
5964
  body_max_bytes: {
5932
5965
  type: "integer",
@@ -5985,7 +6018,10 @@ const TOOLS = [
5985
6018
  description:
5986
6019
  "The UUID of the article. A unique ID PREFIX (>= 8 hex characters) also " +
5987
6020
  "resolves, so copy what you have rather than reconstructing 36 characters " +
5988
- "from memory; an ambiguous prefix is a 404, never a guess.",
6021
+ "from memory; an ambiguous prefix is a 404, never a guess. The server matches a " +
6022
+ "prefix on its first 8 hex digits only. Surrounding whitespace is trimmed. A " +
6023
+ "missing or blank id, fewer than 8 hex digits, or any character other than hex " +
6024
+ "digits and dashes is refused locally with status 0 and no request is sent.",
5989
6025
  },
5990
6026
  links: {
5991
6027
  type: "string",
@@ -6232,7 +6268,8 @@ const TOOLS = [
6232
6268
  properties: {
6233
6269
  article_id: {
6234
6270
  type: "string",
6235
- description: "The UUID of the article to edit (preserved across the update).",
6271
+ description:
6272
+ "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
6273
  },
6237
6274
  title: {
6238
6275
  type: "string",
@@ -6610,7 +6647,8 @@ const TOOLS = [
6610
6647
  properties: {
6611
6648
  article_id: {
6612
6649
  type: "string",
6613
- description: "The UUID of the draft article to publish.",
6650
+ description:
6651
+ "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
6652
  },
6615
6653
  },
6616
6654
  required: ["article_id"],
@@ -6678,7 +6716,8 @@ const TOOLS = [
6678
6716
  properties: {
6679
6717
  article_id: {
6680
6718
  type: "string",
6681
- description: "The UUID of the published article to unpublish.",
6719
+ description:
6720
+ "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
6721
  },
6683
6722
  },
6684
6723
  required: ["article_id"],
@@ -6701,7 +6740,8 @@ const TOOLS = [
6701
6740
  properties: {
6702
6741
  article_id: {
6703
6742
  type: "string",
6704
- description: "The UUID of the article to archive.",
6743
+ description:
6744
+ "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
6745
  },
6706
6746
  },
6707
6747
  required: ["article_id"],
@@ -6736,7 +6776,8 @@ const TOOLS = [
6736
6776
  properties: {
6737
6777
  article_id: {
6738
6778
  type: "string",
6739
- description: "The UUID of the article to take out of retrieval.",
6779
+ description:
6780
+ "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
6781
  },
6741
6782
  reason: {
6742
6783
  type: "string",
@@ -6764,7 +6805,8 @@ const TOOLS = [
6764
6805
  properties: {
6765
6806
  article_id: {
6766
6807
  type: "string",
6767
- description: "The UUID of the article to restore to retrieval.",
6808
+ description:
6809
+ "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
6810
  },
6769
6811
  },
6770
6812
  required: ["article_id"],
@@ -6786,7 +6828,8 @@ const TOOLS = [
6786
6828
  properties: {
6787
6829
  article_id: {
6788
6830
  type: "string",
6789
- description: "The UUID of the article to delete.",
6831
+ description:
6832
+ "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
6833
  },
6791
6834
  },
6792
6835
  required: ["article_id"],
@@ -8092,7 +8135,8 @@ const TOOLS = [
8092
8135
  properties: {
8093
8136
  article_id: {
8094
8137
  type: "string",
8095
- description: "The UUID of the article to inspect.",
8138
+ description:
8139
+ "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
8140
  },
8097
8141
  },
8098
8142
  required: ["article_id"],
@@ -9904,6 +9948,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9904
9948
  );
9905
9949
  },
9906
9950
  declaredToolArgs(name),
9951
+ name,
9907
9952
  );
9908
9953
 
9909
9954
  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.114.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",