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 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 65 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
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 (65)
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 mark multiple stories as complete in a single API call. |
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, json, or obsidian). |
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
- const key = resolveKey(keyOverride);
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 { error: true, status: 0, body: "No API key configured. Set LOOPCTL_API_KEY, LOOPCTL_ORCH_KEY, or LOOPCTL_AGENT_KEY." };
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
- responseBody = await response.json();
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({ page, page_size } = {}) {
182
- const params = new URLSearchParams();
183
- if (page != null) params.set("page", String(page));
184
- if (page_size != null) params.set("page_size", String(page_size));
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({ limit, offset, since_days } = {}) {
1124
- const params = new URLSearchParams();
1125
- if (limit != null) params.set("limit", String(limit));
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
- `/api/v1/knowledge/ingestion-jobs${query}`,
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-4-5)." },
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 in a single API call. " +
1938
- "Each story entry needs a story_id, summary, and review_type. Uses the ORCH key.",
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-4-5, gpt-4o).",
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} }. With bridge_path:true, " +
2414
- "only pairs also connected in the link graph (≤2 hops) are returned. Samples up to 1000 " +
2415
- "embedded published articles; paginate via limit/offset. For computational-creativity " +
2416
- "ideation (remote-associates generator).",
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: "1.2.0",
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.30.0",
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
  ],