loopctl-mcp-server 2.30.1 → 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 69 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 (69)
69
+ ## Tools (72)
70
70
 
71
71
  ### Project Tools
72
72
 
@@ -180,6 +180,14 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
180
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. |
181
181
  | `knowledge_ingestion_jobs` | List recent content ingestion jobs (last 7 days, max 50). |
182
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
+
183
191
  ### Knowledge Analytics Tools (orchestrator key)
184
192
 
185
193
  | Tool | Description |
package/index.js CHANGED
@@ -13,6 +13,12 @@ import {
13
13
  import { readFileSync } from "node:fs";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { dirname, join } from "node:path";
16
+ import {
17
+ projectsPath,
18
+ ingestionJobsPath,
19
+ llmUsagePath,
20
+ parseJsonResponseBody,
21
+ } from "./lib/http-helpers.js";
16
22
 
17
23
  // Single source of truth for the server version: the package.json this file
18
24
  // ships with (npm always includes package.json in the published tarball).
@@ -52,12 +58,22 @@ function resolveKey(keyOverride) {
52
58
  );
53
59
  }
54
60
 
55
- async function apiCall(method, path, body, keyOverride) {
61
+ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {}) {
56
62
  const url = `${getBaseUrl()}${path}`;
57
- 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);
58
68
 
59
69
  if (!key) {
60
- 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
+ };
61
77
  }
62
78
 
63
79
  const headers = {
@@ -109,26 +125,15 @@ async function apiCall(method, path, body, keyOverride) {
109
125
  if (contentType.includes("application/json")) {
110
126
  // A JSON content-type is no guarantee of a well-formed body: a transient Fly
111
127
  // edge 502/503 or a truncated/empty response can arrive with the JSON header.
112
- // Read the raw text and parse defensively so a malformed body becomes a
113
- // structured MCP error instead of an unhandled throw from response.json().
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().
114
131
  const raw = await response.text();
115
- if (raw.trim() === "") {
116
- return {
117
- error: true,
118
- status: response.status,
119
- body: `invalid/empty JSON response from server (HTTP ${response.status}): empty body`,
120
- };
121
- }
122
- try {
123
- responseBody = JSON.parse(raw);
124
- } catch {
125
- const snippet = raw.length > 200 ? `${raw.slice(0, 200)}... (truncated)` : raw;
126
- return {
127
- error: true,
128
- status: response.status,
129
- body: `invalid/empty JSON response from server (HTTP ${response.status}): ${snippet}`,
130
- };
132
+ const outcome = parseJsonResponseBody(raw, response.status);
133
+ if (outcome.error) {
134
+ return outcome;
131
135
  }
136
+ responseBody = outcome.parsed;
132
137
  } else {
133
138
  const text = await response.text();
134
139
  try {
@@ -210,12 +215,10 @@ async function getTenant() {
210
215
  return toContent(result);
211
216
  }
212
217
 
213
- async function listProjects({ page, page_size } = {}) {
214
- const params = new URLSearchParams();
215
- if (page != null) params.set("page", String(page));
216
- if (page_size != null) params.set("page_size", String(page_size));
217
- const query = params.toString() ? `?${params}` : "";
218
- 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));
219
222
  return toContent(result);
220
223
  }
221
224
 
@@ -1152,15 +1155,56 @@ async function knowledgeIngestBatch({ items, project_id, publish }) {
1152
1155
  return toContent(result);
1153
1156
  }
1154
1157
 
1155
- async function knowledgeIngestionJobs({ limit, offset, since_days } = {}) {
1156
- const params = new URLSearchParams();
1157
- if (limit != null) params.set("limit", String(limit));
1158
- if (offset != null) params.set("offset", String(offset));
1159
- if (since_days != null) params.set("since_days", String(since_days));
1160
- 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).
1161
1161
  const result = await apiCall(
1162
1162
  "GET",
1163
- `/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),
1164
1208
  null,
1165
1209
  process.env.LOOPCTL_ORCH_KEY,
1166
1210
  );
@@ -2443,10 +2487,14 @@ const TOOLS = [
2443
2487
  description:
2444
2488
  "Find distant-but-bridgeable article pairs in the optimal-novelty embedding band " +
2445
2489
  "(cosine distance min..max, default 0.3–0.7) — the creative sweet spot (neither banal " +
2446
- "nor nonsense). Returns { data: [{a, b, distance}], meta:{count} }. With bridge_path:true, " +
2447
- "only pairs also connected in the link graph (≤2 hops) are returned. Samples up to 1000 " +
2448
- "embedded published articles; paginate via limit/offset. For computational-creativity " +
2449
- "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).",
2450
2498
  inputSchema: {
2451
2499
  type: "object",
2452
2500
  properties: {
@@ -3312,6 +3360,76 @@ const TOOLS = [
3312
3360
  },
3313
3361
  },
3314
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
+
3315
3433
  // Knowledge Analytics Tools (orchestrator key)
3316
3434
  {
3317
3435
  name: "knowledge_curation_log",
@@ -3787,6 +3905,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3787
3905
  case "knowledge_ingestion_jobs":
3788
3906
  return await knowledgeIngestionJobs(args);
3789
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);
3917
+
3790
3918
  // Knowledge Analytics Tools
3791
3919
  case "knowledge_curation_log":
3792
3920
  return await knowledgeCurationLog(args);
@@ -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.1",
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
  ],