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 +1 -1
- package/index.js +80 -35
- package/lib/arg-aliases.js +42 -3
- package/lib/article-id.js +17 -0
- package/lib/delivery-loop.js +22 -5
- package/package.json +1 -1
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` | **
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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.
|
|
1128
|
-
// pinned
|
|
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",
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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
|
-
"
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
5929
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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) {
|
package/lib/arg-aliases.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/delivery-loop.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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.`,
|