loopctl-mcp-server 2.30.0 → 2.31.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 +16 -4
- package/index.js +189 -28
- package/lib/http-helpers.js +106 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) server for [loopctl](https://loopctl.com) -- structural trust for AI development loops.
|
|
4
4
|
|
|
5
|
-
Wraps the loopctl REST API into
|
|
5
|
+
Wraps the loopctl REST API into 72 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -66,7 +66,7 @@ Or if installed locally:
|
|
|
66
66
|
|
|
67
67
|
Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
|
|
68
68
|
|
|
69
|
-
## Tools (
|
|
69
|
+
## Tools (72)
|
|
70
70
|
|
|
71
71
|
### Project Tools
|
|
72
72
|
|
|
@@ -117,7 +117,7 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
|
|
|
117
117
|
|
|
118
118
|
| Tool | Description |
|
|
119
119
|
|---|---|
|
|
120
|
-
| `bulk_mark_complete` | Bulk
|
|
120
|
+
| `bulk_mark_complete` | **Backfill-only.** Bulk-marks pre-existing, never-dispatched stories (pending, unassigned, no dispatch lineage) as complete in a single call. Dispatched stories are refused — they must go through the normal report → review → verify flow. |
|
|
121
121
|
| `verify_all_in_epic` | Bulk verify all reported_done, unverified stories in an epic. |
|
|
122
122
|
|
|
123
123
|
### Token Efficiency Tools
|
|
@@ -156,6 +156,8 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
|
|
|
156
156
|
| `knowledge_distant_pairs` | Distant-but-bridgeable article pairs in the optimal-novelty embedding band (cosine distance, default 0.3–0.7) — the creative sweet spot. Sampled from **agent's visible published articles**; agent callers see only their own and `shared` articles. `bridge_path: true` requires a ≤2-hop link path. Returns `{a, b, distance}` pairs, paginated. Optional: `min_distance`, `max_distance`, `bridge_path`, `limit` (default 20, max 100), `offset`. |
|
|
157
157
|
| `knowledge_novelty` | Score ideas by novelty: embeds each idea's text, returns `novelty_score` = cosine distance to the nearest **visible** prior proposal (0 = identical, higher = more novel, up to 2.0; `null` when the idea text is blank, no visible priors exist, or embedding fails — see `meta.prior_count`). Agent callers see only their own and `shared` articles as priors. Priors default to articles tagged `proposal`. Provide ideas as `texts` (strings) OR `ideas` (strings or objects), ≤50. Optional: `prior_tag`. |
|
|
158
158
|
| `knowledge_random_walk` | Random walk through the link graph from `start_id` (no cycles, up to `length` nodes), traversing only **agent's visible published articles**, surfacing unexpected connections. Agent callers see only their own and `shared` articles. Returns `{id, title, category}` in walk order. Required: `start_id`. Optional: `length` (default 4, max 25). |
|
|
159
|
+
| `knowledge_conflicts` | List potential-conflict article pairs — published articles flagged "too similar to comfortably coexist" by the auto-linker / nightly lint sweep, highest-overlap first. The KB only FLAGS the pair; it does NOT decide redundancy-vs-contradiction — that's your call with live context. Each entry has both articles (id/title/status/category) + similarity. Then merge (supersede one, `knowledge_create` the merged article, or PATCH) or reconcile if they genuinely disagree. Paginated with `total_count` in meta. Agent role. Optional: `limit` (default 50, max 1000, clamped), `offset`. |
|
|
160
|
+
| `knowledge_resolve_conflict` | Record YOUR verdict on a potential-conflict pair (from `knowledge_conflicts`). Dispositions: `dismiss` (false positive, drops from queue), `supersede` (one wins — pass `authoritative_article_id`; nightly executor links + retires loser, only at `confidence:"high"`, reversible/audited), `merge` (at high confidence an LLM synthesizes both into ONE new DRAFT, sources preserved, never auto-published). Non-destructive at agent role — you record intent, the privileged nightly job executes. Last-write-wins per pair. Required: `source_article_id`, `target_article_id`, `disposition`. Optional: `authoritative_article_id`, `classification`, `evidence`, `confidence`. |
|
|
159
161
|
| `knowledge_create` | Create a new knowledge article. File findings, document patterns, or record decisions. **Published immediately by default** (visible per `metadata.visibility` — default `owner` for agent authors, only visible to that agent; `shared` for visibility to all agents) — the response `note` says which outcome occurred. Pass `draft: true` to stage it for later review instead (publish afterwards with `knowledge_publish`). Pass `metadata: {visibility: "shared"}` to make the article visible to other agents; higher roles can set visibility and agent_id explicitly. Pass `idempotency_key` for idempotent capture (re-creating with the same key is a no-op returning the existing article — no partial duplicates). Optional: `category`, `tags`, `project_id`, `draft`, `idempotency_key`, `source_type`, `source_id`, `metadata`. |
|
|
160
162
|
| `knowledge_okf_export` | **Requires `LOOPCTL_USER_KEY`.** Export the wiki as a portable OKF (Open Knowledge Format) v0.1 bundle of markdown files. Writes to `out_dir`, or returns `{files, meta}` inline. |
|
|
161
163
|
| `knowledge_okf_import` | **Requires `LOOPCTL_USER_KEY`.** Import an OKF v0.1 bundle from a local directory. Creates or (with `merge`) updates articles; tolerates and preserves unknown frontmatter. |
|
|
@@ -173,11 +175,19 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
|
|
|
173
175
|
| `knowledge_bulk_delete` | **Requires `LOOPCTL_USER_KEY`.** Bulk archive (default, reversible) or IRREVERSIBLE hard-delete by selector. Provide exactly one selector: `article_ids` (list), `source_type`+`source_id` (every active article from a source), or `tag`+`confirm:true` (every active article with the tag — high blast radius). Default = set-based soft archive (idempotent; `meta.count`=archived, `meta.counts`/`meta.results` give the breakdown; ≤5000). **Dry-run** (`dry_run:true`) mutates nothing, returns `meta.would_affect` (with `hard:true` also a single-use `meta.token`, or `meta.confirm_hash` for oversized selectors). **Hard delete** (irreversible): dry-run with `hard:true` for a token, then call again with `hard:true`+`token` to FK-correctly delete the frozen id-set (links first, access events cascade). |
|
|
174
176
|
| `knowledge_drafts` | List draft (unpublished) knowledge articles with pagination. Optional: `limit` (default 20, max 1000 — over-max → 400, no silent clamp), `offset` (default 0), `project_id`. Returns `meta.total_count`. |
|
|
175
177
|
| `knowledge_lint` | Run a lint check on the knowledge wiki to identify stale or low-coverage articles. Optional: `project_id`, `stale_days`, `min_coverage`, `max_per_category` (default 50, max 500). True totals returned in `summary.total_per_category`. |
|
|
176
|
-
| `knowledge_export` | Export all knowledge articles as an OKF v0.1 bundle (gzipped tar archive, unbounded, bounded-memory streaming, fail-closed). Returns a curl command for direct download. Pass `format=json` for buffered in-memory JSON (convenience tool for file writers; capped at `export_max_buffered_export_articles` — returns 413 if over-cap). Optional: `project_id`, `format` (tar.gz default
|
|
178
|
+
| `knowledge_export` | Export all knowledge articles as an OKF v0.1 bundle (gzipped tar archive, unbounded, bounded-memory streaming, fail-closed). Returns a curl command for direct download — **the download requires `LOOPCTL_USER_KEY`** (an orchestrator key would 403). Pass `format=json` for buffered in-memory JSON (convenience tool for file writers; capped at `export_max_buffered_export_articles` — returns 413 if over-cap). Optional: `project_id`, `format` (`tar.gz` default or `json`). |
|
|
177
179
|
| `knowledge_ingest` | Submit a URL or raw content for knowledge extraction. Enqueues an Oban job. Extracted articles are **drafts by default** (lower-trust LLM output); pass `publish: true` to publish on extraction. Required: `source_type`. One of: `url` or `content`. Optional: `project_id`, `publish`. |
|
|
178
180
|
| `knowledge_ingest_batch` | Submit up to 50 ingestion items in a single request. Each item has the same shape as `knowledge_ingest` (incl. `publish`). Returns per-item results. Required: `items`. Optional: batch-level `project_id` / `publish` defaults. |
|
|
179
181
|
| `knowledge_ingestion_jobs` | List recent content ingestion jobs (last 7 days, max 50). |
|
|
180
182
|
|
|
183
|
+
### Per-tenant BYO LLM config + usage (Epic 28, #179)
|
|
184
|
+
|
|
185
|
+
| Tool | Description |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `llm_config` | Get the tenant's BYO Anthropic LLM config: per-operation models, `has_api_key`, and a masked last-4 hint. Never returns the key. Requires **user** key. |
|
|
188
|
+
| `set_llm_config` | Set/rotate the tenant's OWN Anthropic API key (stored encrypted, never returned) + the three per-operation models (`extraction_model`/`classification_model`/`merge_model`). Any subset; omitting `api_key` leaves it untouched. Requires **user** key. |
|
|
189
|
+
| `knowledge_llm_usage` | Per-tenant LLM token-usage summary, grouped by operation + model + source_type + day over an optional `from`/`to` range (defaults to a 90-day lookback; effective window echoed in `meta.from`/`meta.to`), with `limit`/`offset` pagination. Record-only (no budget enforcement). Requires orchestrator key. |
|
|
190
|
+
|
|
181
191
|
### Knowledge Analytics Tools (orchestrator key)
|
|
182
192
|
|
|
183
193
|
| Tool | Description |
|
|
@@ -186,6 +196,8 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
|
|
|
186
196
|
| `knowledge_article_stats` | Per-article usage stats: total accesses, unique agents, by-type breakdown, recent events. Required: `article_id`. |
|
|
187
197
|
| `knowledge_agent_usage` | Per-agent knowledge usage: total reads, unique articles, top read articles. Required: exactly one of `api_key_id` (credential) or `agent_id` (logical identity). Optional: `limit`, `since_days`. See Wiki Attribution section. |
|
|
188
198
|
| `knowledge_unused_articles` | Published articles with zero accesses in the window. Optional: `days_unused` (default 30), `limit` (default 50, max 200). |
|
|
199
|
+
| `knowledge_curation_log` | Concise human-readable log of KB CURATION adjustments — novelty-gate decisions (`gate_duplicate`/`gate_draft`) and conflict resolutions (`supersede`/`merge`/`dismiss`) — for analyzing the agents'-KB rollout, distinct from the verbose audit log. Each entry: `{at, kind, summary, refs, actor, confidence}`. **RECORDED ONLY when `settings.kb_curation_log` is on** (PATCH `/api/v1/admin/tenants/:id` with `settings:{kb_curation_log:true}`); off by default = no rows. Most recent first. Requires orchestrator role. Optional: `kind`, `since` (ISO8601), `limit` (default 50, max 500), `offset`. |
|
|
200
|
+
| `knowledge_retrieval_metrics` | Daily retrieval-PRECISION time series: for each day, the share of search results the agent then opened (search → get/context within a window). A proxy for whether retrieval is improving as the corpus is de-duplicated, better navigated (MOCs), and conflict-resolved. Most recent day first. Requires orchestrator role. Optional: `limit` (default 30, max 365), `offset`. |
|
|
189
201
|
|
|
190
202
|
### Discovery Tools
|
|
191
203
|
|
package/index.js
CHANGED
|
@@ -10,6 +10,23 @@ import {
|
|
|
10
10
|
ListToolsRequestSchema,
|
|
11
11
|
CallToolRequestSchema,
|
|
12
12
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
projectsPath,
|
|
18
|
+
ingestionJobsPath,
|
|
19
|
+
llmUsagePath,
|
|
20
|
+
parseJsonResponseBody,
|
|
21
|
+
} from "./lib/http-helpers.js";
|
|
22
|
+
|
|
23
|
+
// Single source of truth for the server version: the package.json this file
|
|
24
|
+
// ships with (npm always includes package.json in the published tarball).
|
|
25
|
+
// Keeping it derived prevents the handshake version from drifting from the
|
|
26
|
+
// published package version.
|
|
27
|
+
const SERVER_VERSION = JSON.parse(
|
|
28
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")
|
|
29
|
+
).version;
|
|
13
30
|
|
|
14
31
|
// ---------------------------------------------------------------------------
|
|
15
32
|
// HTTP helper — witness protocol state
|
|
@@ -41,12 +58,22 @@ function resolveKey(keyOverride) {
|
|
|
41
58
|
);
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
async function apiCall(method, path, body, keyOverride) {
|
|
61
|
+
async function apiCall(method, path, body, keyOverride, { exactKey = false } = {}) {
|
|
45
62
|
const url = `${getBaseUrl()}${path}`;
|
|
46
|
-
|
|
63
|
+
// Secret-managing tools pass exactKey:true so the request uses the EXACT
|
|
64
|
+
// role-pinned key (LOOPCTL_USER_KEY) and does NOT fall back to the global
|
|
65
|
+
// LOOPCTL_API_KEY override — a secret op must never silently run under a
|
|
66
|
+
// non-user global key (review #12).
|
|
67
|
+
const key = exactKey ? keyOverride : resolveKey(keyOverride);
|
|
47
68
|
|
|
48
69
|
if (!key) {
|
|
49
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
error: true,
|
|
72
|
+
status: 0,
|
|
73
|
+
body: exactKey
|
|
74
|
+
? "No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to manage LLM configuration."
|
|
75
|
+
: "No API key configured. Set LOOPCTL_API_KEY, LOOPCTL_ORCH_KEY, or LOOPCTL_AGENT_KEY.",
|
|
76
|
+
};
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
const headers = {
|
|
@@ -96,7 +123,17 @@ async function apiCall(method, path, body, keyOverride) {
|
|
|
96
123
|
let responseBody;
|
|
97
124
|
const contentType = response.headers.get("content-type") || "";
|
|
98
125
|
if (contentType.includes("application/json")) {
|
|
99
|
-
|
|
126
|
+
// A JSON content-type is no guarantee of a well-formed body: a transient Fly
|
|
127
|
+
// edge 502/503 or a truncated/empty response can arrive with the JSON header.
|
|
128
|
+
// Read the raw text and parse defensively (shared with the test suite via
|
|
129
|
+
// lib/http-helpers.js) so a malformed body becomes a structured MCP error
|
|
130
|
+
// instead of an unhandled throw from response.json().
|
|
131
|
+
const raw = await response.text();
|
|
132
|
+
const outcome = parseJsonResponseBody(raw, response.status);
|
|
133
|
+
if (outcome.error) {
|
|
134
|
+
return outcome;
|
|
135
|
+
}
|
|
136
|
+
responseBody = outcome.parsed;
|
|
100
137
|
} else {
|
|
101
138
|
const text = await response.text();
|
|
102
139
|
try {
|
|
@@ -178,12 +215,10 @@ async function getTenant() {
|
|
|
178
215
|
return toContent(result);
|
|
179
216
|
}
|
|
180
217
|
|
|
181
|
-
async function listProjects(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const query = params.toString() ? `?${params}` : "";
|
|
186
|
-
const result = await apiCall("GET", `/api/v1/projects${query}`);
|
|
218
|
+
async function listProjects(args = {}) {
|
|
219
|
+
// Query-string building lives in lib/http-helpers.js so the test suite exercises
|
|
220
|
+
// the same page/page_size logic the server ships (#247, mcp-01).
|
|
221
|
+
const result = await apiCall("GET", projectsPath(args));
|
|
187
222
|
return toContent(result);
|
|
188
223
|
}
|
|
189
224
|
|
|
@@ -1120,15 +1155,56 @@ async function knowledgeIngestBatch({ items, project_id, publish }) {
|
|
|
1120
1155
|
return toContent(result);
|
|
1121
1156
|
}
|
|
1122
1157
|
|
|
1123
|
-
async function knowledgeIngestionJobs(
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
if (offset != null) params.set("offset", String(offset));
|
|
1127
|
-
if (since_days != null) params.set("since_days", String(since_days));
|
|
1128
|
-
const query = params.toString() ? `?${params}` : "";
|
|
1158
|
+
async function knowledgeIngestionJobs(args = {}) {
|
|
1159
|
+
// Query-string building lives in lib/http-helpers.js so the test suite exercises
|
|
1160
|
+
// the same limit/offset/since_days logic the server ships (#248, mcp-02).
|
|
1129
1161
|
const result = await apiCall(
|
|
1130
1162
|
"GET",
|
|
1131
|
-
|
|
1163
|
+
ingestionJobsPath(args),
|
|
1164
|
+
null,
|
|
1165
|
+
process.env.LOOPCTL_ORCH_KEY,
|
|
1166
|
+
);
|
|
1167
|
+
return toContent(result);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// --- Per-tenant BYO LLM config + usage (Epic 28 residual, #179) ---
|
|
1171
|
+
|
|
1172
|
+
async function llmConfig() {
|
|
1173
|
+
// Reading/writing the tenant LLM config touches a stored secret → EXACT user
|
|
1174
|
+
// key only (bypass the global LOOPCTL_API_KEY override; fail fast if unset).
|
|
1175
|
+
const result = await apiCall(
|
|
1176
|
+
"GET",
|
|
1177
|
+
"/api/v1/tenants/me/llm-config",
|
|
1178
|
+
null,
|
|
1179
|
+
process.env.LOOPCTL_USER_KEY,
|
|
1180
|
+
{ exactKey: true },
|
|
1181
|
+
);
|
|
1182
|
+
return toContent(result);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function setLlmConfig({ api_key, extraction_model, classification_model, merge_model }) {
|
|
1186
|
+
const body = {};
|
|
1187
|
+
if (api_key != null) body.api_key = api_key;
|
|
1188
|
+
if (extraction_model !== undefined) body.extraction_model = extraction_model;
|
|
1189
|
+
if (classification_model !== undefined) body.classification_model = classification_model;
|
|
1190
|
+
if (merge_model !== undefined) body.merge_model = merge_model;
|
|
1191
|
+
// PATCH (partial-merge) + EXACT user key (review #12, #13).
|
|
1192
|
+
const result = await apiCall(
|
|
1193
|
+
"PATCH",
|
|
1194
|
+
"/api/v1/tenants/me/llm-config",
|
|
1195
|
+
body,
|
|
1196
|
+
process.env.LOOPCTL_USER_KEY,
|
|
1197
|
+
{ exactKey: true },
|
|
1198
|
+
);
|
|
1199
|
+
return toContent(result);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function knowledgeLlmUsage(args = {}) {
|
|
1203
|
+
// Query-string building lives in lib/http-helpers.js so the test suite exercises
|
|
1204
|
+
// the same from/to/limit/offset logic the server ships.
|
|
1205
|
+
const result = await apiCall(
|
|
1206
|
+
"GET",
|
|
1207
|
+
llmUsagePath(args),
|
|
1132
1208
|
null,
|
|
1133
1209
|
process.env.LOOPCTL_ORCH_KEY,
|
|
1134
1210
|
);
|
|
@@ -1837,7 +1913,7 @@ const TOOLS = [
|
|
|
1837
1913
|
properties: {
|
|
1838
1914
|
input_tokens: { type: "integer", description: "Total input tokens consumed." },
|
|
1839
1915
|
output_tokens: { type: "integer", description: "Total output tokens consumed." },
|
|
1840
|
-
model_name: { type: "string", description: "Model name (e.g. claude-sonnet-
|
|
1916
|
+
model_name: { type: "string", description: "Model name (e.g. claude-sonnet-5)." },
|
|
1841
1917
|
cost_millicents: { type: "integer", description: "Total cost in millicents (1/1000 of a cent)." },
|
|
1842
1918
|
},
|
|
1843
1919
|
},
|
|
@@ -1934,8 +2010,9 @@ const TOOLS = [
|
|
|
1934
2010
|
{
|
|
1935
2011
|
name: "bulk_mark_complete",
|
|
1936
2012
|
description:
|
|
1937
|
-
"Bulk mark multiple stories as complete
|
|
1938
|
-
"
|
|
2013
|
+
"Bulk mark multiple stories as complete (backfill-only for never-dispatched work). " +
|
|
2014
|
+
"ADMIN USE ONLY: backfill pre-existing completed work into pending stories that never entered the dispatch lifecycle. " +
|
|
2015
|
+
"For dispatched stories, use the normal report/review/verify flow. Each story entry needs a story_id, summary, and review_type. Uses the ORCH key.",
|
|
1939
2016
|
inputSchema: {
|
|
1940
2017
|
type: "object",
|
|
1941
2018
|
properties: {
|
|
@@ -2013,7 +2090,7 @@ const TOOLS = [
|
|
|
2013
2090
|
},
|
|
2014
2091
|
model_name: {
|
|
2015
2092
|
type: "string",
|
|
2016
|
-
description: "Name of the model used (e.g. claude-sonnet-
|
|
2093
|
+
description: "Name of the model used (e.g. claude-sonnet-5, gpt-4o).",
|
|
2017
2094
|
},
|
|
2018
2095
|
cost_millicents: {
|
|
2019
2096
|
type: "integer",
|
|
@@ -2410,10 +2487,14 @@ const TOOLS = [
|
|
|
2410
2487
|
description:
|
|
2411
2488
|
"Find distant-but-bridgeable article pairs in the optimal-novelty embedding band " +
|
|
2412
2489
|
"(cosine distance min..max, default 0.3–0.7) — the creative sweet spot (neither banal " +
|
|
2413
|
-
"nor nonsense). Returns { data: [{a, b, distance}], meta:{count} }.
|
|
2414
|
-
"
|
|
2415
|
-
"
|
|
2416
|
-
"
|
|
2490
|
+
"nor nonsense). Returns { data: [{a, b, distance}], meta:{count, has_more, total_count} }. " +
|
|
2491
|
+
"Paginate via meta.has_more (a limit+1 look-ahead) — NOT total_count, which is DEPRECATED " +
|
|
2492
|
+
"and always null here: unlike sibling offset/limit tools, an exact total is an " +
|
|
2493
|
+
"O(candidates²) cost (the pair set is a column-to-column self-join), so it was removed for " +
|
|
2494
|
+
"latency (loopctl #202/#203). With bridge_path:true, only pairs also connected in the link " +
|
|
2495
|
+
"graph (≤2 hops) are returned — that branch samples a smaller candidate slice, so it may " +
|
|
2496
|
+
"return fewer pairs. Samples up to 1000 embedded published articles (500 for bridge_path). " +
|
|
2497
|
+
"For computational-creativity ideation (remote-associates generator).",
|
|
2417
2498
|
inputSchema: {
|
|
2418
2499
|
type: "object",
|
|
2419
2500
|
properties: {
|
|
@@ -3279,6 +3360,76 @@ const TOOLS = [
|
|
|
3279
3360
|
},
|
|
3280
3361
|
},
|
|
3281
3362
|
|
|
3363
|
+
// Per-tenant BYO LLM config + usage (Epic 28 residual, #179)
|
|
3364
|
+
{
|
|
3365
|
+
name: "llm_config",
|
|
3366
|
+
description:
|
|
3367
|
+
"Get the tenant's BYO Anthropic LLM configuration: the per-operation model " +
|
|
3368
|
+
"choices, whether a key is configured (has_api_key), and a masked last-4 hint. " +
|
|
3369
|
+
"NEVER returns the key itself. Requires user role (LOOPCTL_USER_KEY).",
|
|
3370
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
3371
|
+
},
|
|
3372
|
+
{
|
|
3373
|
+
name: "set_llm_config",
|
|
3374
|
+
description:
|
|
3375
|
+
"Set/rotate the tenant's OWN Anthropic API key (stored encrypted, never returned) " +
|
|
3376
|
+
"and the three per-operation models (extraction/classification/merge). loopctl " +
|
|
3377
|
+
"fronts no LLM cost — the tenant's key bills the tenant. Any subset of fields may " +
|
|
3378
|
+
"be sent; omitting api_key leaves the existing key untouched. Model ids are " +
|
|
3379
|
+
"free-form (any model the key permits). Requires user role (LOOPCTL_USER_KEY).",
|
|
3380
|
+
inputSchema: {
|
|
3381
|
+
type: "object",
|
|
3382
|
+
properties: {
|
|
3383
|
+
api_key: {
|
|
3384
|
+
type: "string",
|
|
3385
|
+
description: "Anthropic API key (write-only; stored encrypted, never returned).",
|
|
3386
|
+
},
|
|
3387
|
+
extraction_model: {
|
|
3388
|
+
type: "string",
|
|
3389
|
+
description: "Model id for knowledge extraction (null → server default).",
|
|
3390
|
+
},
|
|
3391
|
+
classification_model: {
|
|
3392
|
+
type: "string",
|
|
3393
|
+
description: "Model id for category classification (null → server default).",
|
|
3394
|
+
},
|
|
3395
|
+
merge_model: {
|
|
3396
|
+
type: "string",
|
|
3397
|
+
description: "Model id for article merge synthesis (null → server default).",
|
|
3398
|
+
},
|
|
3399
|
+
},
|
|
3400
|
+
required: [],
|
|
3401
|
+
},
|
|
3402
|
+
},
|
|
3403
|
+
{
|
|
3404
|
+
name: "knowledge_llm_usage",
|
|
3405
|
+
description:
|
|
3406
|
+
"Per-tenant LLM token-usage summary, grouped by operation + model + source_type + " +
|
|
3407
|
+
"day over an optional date range, newest day first, with offset/limit pagination " +
|
|
3408
|
+
"over meta.total_count. When `from` is omitted it defaults to a 90-day lookback; the " +
|
|
3409
|
+
"EFFECTIVE window is echoed in meta.from/meta.to so you can detect that older usage " +
|
|
3410
|
+
"was excluded (pass an explicit `from` to widen it). Record-only — there is no budget " +
|
|
3411
|
+
"enforcement. Requires orchestrator role.",
|
|
3412
|
+
inputSchema: {
|
|
3413
|
+
type: "object",
|
|
3414
|
+
properties: {
|
|
3415
|
+
from: {
|
|
3416
|
+
type: "string",
|
|
3417
|
+
description: "Optional ISO 8601 lower bound (inclusive) on occurred_at.",
|
|
3418
|
+
},
|
|
3419
|
+
to: {
|
|
3420
|
+
type: "string",
|
|
3421
|
+
description: "Optional ISO 8601 upper bound (inclusive) on occurred_at.",
|
|
3422
|
+
},
|
|
3423
|
+
limit: {
|
|
3424
|
+
type: "integer",
|
|
3425
|
+
description: "Rows per page (default 50, clamped to 200).",
|
|
3426
|
+
},
|
|
3427
|
+
offset: { type: "integer", description: "Rows to skip (default 0)." },
|
|
3428
|
+
},
|
|
3429
|
+
required: [],
|
|
3430
|
+
},
|
|
3431
|
+
},
|
|
3432
|
+
|
|
3282
3433
|
// Knowledge Analytics Tools (orchestrator key)
|
|
3283
3434
|
{
|
|
3284
3435
|
name: "knowledge_curation_log",
|
|
@@ -3558,7 +3709,7 @@ const TOOLS = [
|
|
|
3558
3709
|
const server = new Server(
|
|
3559
3710
|
{
|
|
3560
3711
|
name: "loopctl",
|
|
3561
|
-
version:
|
|
3712
|
+
version: SERVER_VERSION,
|
|
3562
3713
|
},
|
|
3563
3714
|
{
|
|
3564
3715
|
capabilities: { tools: {} },
|
|
@@ -3578,7 +3729,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3578
3729
|
return await getTenant();
|
|
3579
3730
|
|
|
3580
3731
|
case "list_projects":
|
|
3581
|
-
return await listProjects();
|
|
3732
|
+
return await listProjects(args);
|
|
3582
3733
|
|
|
3583
3734
|
case "create_project":
|
|
3584
3735
|
return await createProject(args);
|
|
@@ -3752,7 +3903,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3752
3903
|
return await knowledgeIngestBatch(args);
|
|
3753
3904
|
|
|
3754
3905
|
case "knowledge_ingestion_jobs":
|
|
3755
|
-
return await knowledgeIngestionJobs();
|
|
3906
|
+
return await knowledgeIngestionJobs(args);
|
|
3907
|
+
|
|
3908
|
+
// Per-tenant BYO LLM config + usage (Epic 28, #179)
|
|
3909
|
+
case "llm_config":
|
|
3910
|
+
return await llmConfig();
|
|
3911
|
+
|
|
3912
|
+
case "set_llm_config":
|
|
3913
|
+
return await setLlmConfig(args);
|
|
3914
|
+
|
|
3915
|
+
case "knowledge_llm_usage":
|
|
3916
|
+
return await knowledgeLlmUsage(args);
|
|
3756
3917
|
|
|
3757
3918
|
// Knowledge Analytics Tools
|
|
3758
3919
|
case "knowledge_curation_log":
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Pure, importable HTTP helpers shared by index.js and the test suite.
|
|
2
|
+
//
|
|
3
|
+
// index.js is a stdio entry point with top-level await (importing it would boot
|
|
4
|
+
// the MCP server), so it cannot be imported directly by tests. Extracting the
|
|
5
|
+
// logic that actually had bugs — query-string building for the paginated tools
|
|
6
|
+
// and the defensive JSON parse in apiCall — into this side-effect-free module
|
|
7
|
+
// gives BOTH index.js and the tests a single source of truth. The tests exercise
|
|
8
|
+
// the SAME code the server ships, so a regression in this logic fails CI instead
|
|
9
|
+
// of silently passing against a hand-copied mirror.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a `?a=b&c=d` query string from an array of [key, value] pairs. Skips
|
|
13
|
+
* null/undefined values (so unset params are omitted) and stringifies the rest.
|
|
14
|
+
* Returns "" when nothing is set.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<[string, unknown]>} pairs
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function buildQuery(pairs) {
|
|
20
|
+
const params = new URLSearchParams();
|
|
21
|
+
for (const [key, value] of pairs) {
|
|
22
|
+
if (value != null) params.set(key, String(value));
|
|
23
|
+
}
|
|
24
|
+
const qs = params.toString();
|
|
25
|
+
return qs ? `?${qs}` : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Path for `list_projects`, honoring page/page_size (#247, mcp-01).
|
|
30
|
+
*
|
|
31
|
+
* @param {{ page?: number, page_size?: number }} [args]
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function projectsPath({ page, page_size } = {}) {
|
|
35
|
+
return `/api/v1/projects${buildQuery([
|
|
36
|
+
["page", page],
|
|
37
|
+
["page_size", page_size],
|
|
38
|
+
])}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Path for `knowledge_ingestion_jobs`, honoring limit/offset/since_days
|
|
43
|
+
* (#248, mcp-02).
|
|
44
|
+
*
|
|
45
|
+
* @param {{ limit?: number, offset?: number, since_days?: number }} [args]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function ingestionJobsPath({ limit, offset, since_days } = {}) {
|
|
49
|
+
return `/api/v1/knowledge/ingestion-jobs${buildQuery([
|
|
50
|
+
["limit", limit],
|
|
51
|
+
["offset", offset],
|
|
52
|
+
["since_days", since_days],
|
|
53
|
+
])}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Path for `knowledge_llm_usage`, honoring from/to/limit/offset (Epic 28, #179).
|
|
58
|
+
*
|
|
59
|
+
* @param {{ from?: string, to?: string, limit?: number, offset?: number }} [args]
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function llmUsagePath({ from, to, limit, offset } = {}) {
|
|
63
|
+
return `/api/v1/knowledge/llm-usage${buildQuery([
|
|
64
|
+
["from", from],
|
|
65
|
+
["to", to],
|
|
66
|
+
["limit", limit],
|
|
67
|
+
["offset", offset],
|
|
68
|
+
])}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Defensively parse the raw text body of a JSON-content-type HTTP response
|
|
73
|
+
* (#249, mcp-03).
|
|
74
|
+
*
|
|
75
|
+
* A JSON content-type header is no guarantee of a well-formed body: a transient
|
|
76
|
+
* Fly edge 502/503 or a truncated response can arrive with the JSON header but an
|
|
77
|
+
* empty or non-JSON body, which makes a bare `response.json()` throw an unhandled
|
|
78
|
+
* exception. This turns that into a STRUCTURED error the MCP tool layer already
|
|
79
|
+
* understands (`{ error: true, status, body }`), including the HTTP status and a
|
|
80
|
+
* raw-body snippet for debugging.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} rawText - the response body as text
|
|
83
|
+
* @param {number} status - the HTTP status code
|
|
84
|
+
* @returns {{ parsed: unknown } | { error: true, status: number, body: string }}
|
|
85
|
+
* `{ parsed }` on success, or a structured error object on empty/invalid JSON.
|
|
86
|
+
*/
|
|
87
|
+
export function parseJsonResponseBody(rawText, status) {
|
|
88
|
+
if (rawText.trim() === "") {
|
|
89
|
+
return {
|
|
90
|
+
error: true,
|
|
91
|
+
status,
|
|
92
|
+
body: `invalid/empty JSON response from server (HTTP ${status}): empty body`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return { parsed: JSON.parse(rawText) };
|
|
97
|
+
} catch {
|
|
98
|
+
const snippet =
|
|
99
|
+
rawText.length > 200 ? `${rawText.slice(0, 200)}... (truncated)` : rawText;
|
|
100
|
+
return {
|
|
101
|
+
error: true,
|
|
102
|
+
status,
|
|
103
|
+
body: `invalid/empty JSON response from server (HTTP ${status}): ${snippet}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopctl-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.1",
|
|
4
4
|
"description": "MCP server for loopctl — structural trust for AI development loops",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node index.js",
|
|
12
|
-
"test": "node --test"
|
|
12
|
+
"test": "node --test test/*.test.js",
|
|
13
|
+
"test:smoke": "node smoke_test.mjs"
|
|
13
14
|
},
|
|
14
15
|
"keywords": [
|
|
15
16
|
"mcp",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"index.js",
|
|
37
|
+
"lib",
|
|
36
38
|
"README.md",
|
|
37
39
|
"LICENSE"
|
|
38
40
|
],
|