kibi-mcp 0.22.0 → 2.0.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.
Files changed (47) hide show
  1. package/bin/kibi-mcp +12 -11
  2. package/dist/diagnostics.js +68 -8
  3. package/dist/env.js +5 -1
  4. package/dist/runtime/mcp-runtime.js +39 -3
  5. package/dist/server/docs.js +31 -42
  6. package/dist/server/icons.js +27 -0
  7. package/dist/server/jobs.js +107 -0
  8. package/dist/server/json-schema-to-zod.js +33 -9
  9. package/dist/server/session.js +129 -133
  10. package/dist/server/tool-registration.js +46 -9
  11. package/dist/server/tools-runtime.js +10 -10
  12. package/dist/server/tools.js +143 -7
  13. package/dist/server.js +16 -1
  14. package/dist/startup-resolution.js +15 -17
  15. package/dist/tools/check-impact.js +1 -1
  16. package/dist/tools/check.js +1 -1
  17. package/dist/tools/core-module.js +19 -8
  18. package/dist/tools/coverage.js +1 -1
  19. package/dist/tools/delete.js +1 -1
  20. package/dist/tools/discovery-adapter.js +5 -0
  21. package/dist/tools/entity-query.js +1 -1
  22. package/dist/tools/find-gaps.js +9 -1
  23. package/dist/tools/graph.js +9 -1
  24. package/dist/tools/model-requirement.js +2 -2
  25. package/dist/tools/mutation-context.js +15 -9
  26. package/dist/tools/plan-bootstrap.js +21 -0
  27. package/dist/tools/prolog-list.js +1 -1
  28. package/dist/tools/query.js +1 -1
  29. package/dist/tools/relationship-validation.js +1 -1
  30. package/dist/tools/search.js +1 -1
  31. package/dist/tools/semantic-advisor.js +1 -1
  32. package/dist/tools/skills.js +2 -2
  33. package/dist/tools/sparql.js +1 -1
  34. package/dist/tools/status.js +1 -1
  35. package/dist/tools/suggest-predicates.js +1 -1
  36. package/dist/tools/symbols.js +310 -68
  37. package/dist/tools/upsert.js +3 -3
  38. package/dist/tools/validate-upsert.js +1 -1
  39. package/dist/tools-config.js +77 -3
  40. package/dist/workspace.js +10 -8
  41. package/package.json +15 -9
  42. package/dist/tools/autopilot-candidates.js +0 -554
  43. package/dist/tools/autopilot-discovery.js +0 -898
  44. package/dist/tools/autopilot-generate.js +0 -47
  45. package/dist/tools/autopilot-test-compat.js +0 -88
  46. package/dist/tools/autopilot-test-parsers.js +0 -120
  47. package/dist/tools/check-config.js +0 -67
package/bin/kibi-mcp CHANGED
@@ -19,11 +19,11 @@
19
19
  const args = globalThis.Bun?.argv?.slice(2) ?? process.argv.slice(2);
20
20
 
21
21
  async function importStartupResolution() {
22
- try {
23
- return await import("../dist/startup-resolution.js");
24
- } catch {
25
- return await import("../src/startup-resolution.ts");
26
- }
22
+ // Published packages contain the compiled runtime only. Do not replace a
23
+ // real dependency/import failure with a source-tree fallback: doing so
24
+ // hides package contract errors and makes startup depend on an unpackaged
25
+ // TypeScript tree.
26
+ return await import("../dist/startup-resolution.js");
27
27
  }
28
28
 
29
29
  async function serverEntrypointUrl() {
@@ -32,7 +32,10 @@ async function serverEntrypointUrl() {
32
32
  if (existsSync(distUrl)) {
33
33
  return distUrl.href;
34
34
  }
35
- return new URL("../src/server.ts", import.meta.url).href;
35
+ throw new Error(
36
+ `[KIBI-MCP] Missing compiled server entrypoint: ${distUrl.pathname}. ` +
37
+ "Run the package build before launching kibi-mcp.",
38
+ );
36
39
  }
37
40
 
38
41
  async function buildResolution() {
@@ -64,11 +67,9 @@ async function importServer(entrypoint) {
64
67
  const { pathToFileURL } = await import("node:url");
65
68
  return await import(pathToFileURL(entrypoint).href);
66
69
  }
67
- try {
68
- return await import("../dist/server.js");
69
- } catch {
70
- return await import("../src/server.ts");
71
- }
70
+ // Preserve the original import error. In particular, an incompatible
71
+ // kibi-cli export must not be replaced with a misleading source fallback.
72
+ return await import("../dist/server.js");
72
73
  }
73
74
 
74
75
  function printHelp() {
@@ -140,12 +140,50 @@ function appendMutationFields(fields, args) {
140
140
  fields.mutation_target = `${type}:${id}`;
141
141
  }
142
142
  const RECEIPT_GAP_CODES = new Set([
143
- "missing_verification_receipt",
144
- "stale_verification_receipt",
145
- "failed_verification_receipt",
146
- "invalid_verification_receipt",
147
- "verification_snapshot_unavailable",
143
+ "missing_proof_receipt",
144
+ "stale_proof_receipt",
145
+ "failed_proof_receipt",
146
+ "invalid_proof_receipt",
147
+ "proof_contract_mismatch",
148
+ "proof_snapshot_unavailable",
148
149
  ]);
150
+ const MAX_COVERAGE_RECEIPT_GAPS = 50;
151
+ function coverageReceiptGapDetails(rows) {
152
+ const details = rows
153
+ .flatMap((row) => {
154
+ const codes = [...new Set(stringArray(row.proofGaps))]
155
+ .filter((code) => RECEIPT_GAP_CODES.has(code))
156
+ .sort();
157
+ if (codes.length === 0)
158
+ return [];
159
+ const stages = isRecord(row.proofStages) ? row.proofStages : undefined;
160
+ const passingE2e = isRecord(stages?.passingE2e)
161
+ ? stages.passingE2e
162
+ : undefined;
163
+ const testIds = [
164
+ "missingReceiptTests",
165
+ "staleReceiptTests",
166
+ "failedReceiptTests",
167
+ "invalidReceiptTests",
168
+ "contractMismatchReceiptTests",
169
+ "snapshotUnavailableTests",
170
+ ].flatMap((key) => stringArray(passingE2e?.[key]));
171
+ const requirementId = typeof row.id === "string" ? row.id : "unknown";
172
+ return [
173
+ {
174
+ requirementId,
175
+ testIds: [...new Set(testIds)].sort(),
176
+ codes,
177
+ },
178
+ ];
179
+ })
180
+ .sort((left, right) => left.requirementId.localeCompare(right.requirementId));
181
+ return {
182
+ details: details.slice(0, MAX_COVERAGE_RECEIPT_GAPS),
183
+ total: details.length,
184
+ truncated: details.length > MAX_COVERAGE_RECEIPT_GAPS,
185
+ };
186
+ }
149
187
  function appendCoverageFields(fields, args, structuredContent) {
150
188
  const rows = Array.isArray(structuredContent.rows)
151
189
  ? structuredContent.rows.filter(isRecord)
@@ -166,14 +204,18 @@ function appendCoverageFields(fields, args, structuredContent) {
166
204
  fields.coverage_proof_gap_count = gapCodes.length;
167
205
  fields.coverage_gap_codes = uniqueGapCodes;
168
206
  fields.coverage_receipt_gap_count = gapCodes.filter((gap) => RECEIPT_GAP_CODES.has(gap)).length;
207
+ const receiptGaps = coverageReceiptGapDetails(rows);
208
+ fields.coverage_receipt_gaps = receiptGaps.details;
209
+ fields.coverage_receipt_gap_total = receiptGaps.total;
210
+ fields.coverage_receipt_gaps_truncated = receiptGaps.truncated;
169
211
  if (typeof scope?.complete === "boolean") {
170
212
  fields.coverage_scope_complete = scope.complete;
171
213
  }
172
214
  const metadata = isRecord(structuredContent.meta)
173
215
  ? structuredContent.meta
174
216
  : undefined;
175
- if (typeof metadata?.verificationSnapshot === "string") {
176
- fields.coverage_verification_snapshot = metadata.verificationSnapshot;
217
+ if (typeof metadata?.proofSnapshot === "string") {
218
+ fields.coverage_proof_snapshot = metadata.proofSnapshot;
177
219
  }
178
220
  }
179
221
  function appendPredicateSuggestionFields(fields, structuredContent) {
@@ -234,7 +276,25 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
234
276
  fields.actor_id = telemetry.actor_id;
235
277
  }
236
278
  }
237
- const structuredContent = structuredContentFrom(result);
279
+ const envelope = structuredContentFrom(result);
280
+ const structuredContent = envelope?.kibiProtocol === 1 && isRecord(envelope.data)
281
+ ? envelope.data
282
+ : envelope;
283
+ if (envelope?.kibiProtocol === 1) {
284
+ fields.protocol_version = envelope.kibiProtocol;
285
+ fields.result_version = envelope.resultVersion ?? null;
286
+ fields.result_status = envelope.status ?? null;
287
+ fields.effect_failures = Array.isArray(envelope.effects)
288
+ ? envelope.effects.filter((effect) => effect !== null &&
289
+ typeof effect === "object" &&
290
+ !Array.isArray(effect) &&
291
+ effect.status === "failed")
292
+ : [];
293
+ fields.followed_next_actions = Array.isArray(telemetry?.followed_next_actions)
294
+ ? telemetry.followed_next_actions
295
+ : [];
296
+ fields.unsafe_original_retry = telemetry?.unsafe_original_retry === true;
297
+ }
238
298
  if (toolName === "kb_query" || toolName === "kb_search") {
239
299
  const resultCount = Number(structuredContent?.count ?? 0);
240
300
  fields.result_count = resultCount;
package/dist/env.js CHANGED
@@ -36,7 +36,11 @@ export function isMcpDebugEnabled() {
36
36
  }
37
37
  export function getBranchOverride() {
38
38
  // implements REQ-002
39
- return getTrimmedEnvValue("KIBI_BRANCH");
39
+ // Branch names are identities, not display labels. Preserve the value
40
+ // exactly so the Git-ref validator can reject whitespace or other invalid
41
+ // input instead of silently attaching a different branch.
42
+ const value = getEnvValue("KIBI_BRANCH");
43
+ return value === undefined || value.length === 0 ? undefined : value;
40
44
  }
41
45
  export function getKbPlPathOverride() {
42
46
  // implements REQ-002
@@ -1,3 +1,7 @@
1
+ import { resolveBranchAttachment } from "kibi-runtime";
2
+ export function attachedContextWithProlog(withAttachment, prolog) {
3
+ return { ...withAttachment, prolog };
4
+ }
1
5
  // implements REQ-kibi-operation-interface-parity
2
6
  export function createMcpRuntime(session) {
3
7
  const sessionPrologs = new WeakMap();
@@ -11,18 +15,50 @@ export function createMcpRuntime(session) {
11
15
  signal: options.signal ?? session.signal ?? new AbortController().signal,
12
16
  clock: options.clock ?? session.clock ?? (() => new Date()),
13
17
  ...(fs ? { fs } : {}),
18
+ ...(fs ? { sourceFirst: true } : {}),
14
19
  ...(git ? { git } : {}),
15
20
  ...(net ? { net } : {}),
16
21
  };
22
+ const attachment = resolveBranchAttachment(context.workspaceRoot);
23
+ if (!("error" in attachment) && attachment.migrationRequired) {
24
+ console.warn(`[KIBI-MCP] Legacy branch attachment: Git '${attachment.gitBranch}' is reading KB '${attachment.kbBranch}'. Run 'kibi branch migrate --from ${attachment.kbBranch} --to ${attachment.gitBranch} --apply'; writes are blocked until then.`);
25
+ }
26
+ const withAttachment = "error" in attachment
27
+ ? context
28
+ : { ...context, branchAttachment: attachment };
17
29
  if (!spec.requiresProlog) {
18
- return context;
30
+ // Preserve explicit host/test injection without asking the shared MCP
31
+ // session to initialise a store merely to report its condition.
32
+ const providedProlog = options.prolog;
33
+ let lazyProlog;
34
+ const lazyContext = {};
35
+ const ensureProlog = async () => {
36
+ if (providedProlog !== undefined)
37
+ return providedProlog;
38
+ if (lazyProlog !== undefined)
39
+ return session.adaptProlog(lazyProlog);
40
+ lazyProlog = await session.ensureProlog();
41
+ if (lazyContext.current !== undefined)
42
+ sessionPrologs.set(lazyContext.current, lazyProlog);
43
+ return session.adaptProlog(lazyProlog);
44
+ };
45
+ const operationContext = {
46
+ ...withAttachment,
47
+ ...(options.prolog ? { prolog: options.prolog } : {}),
48
+ ensureProlog,
49
+ };
50
+ lazyContext.current = operationContext;
51
+ return operationContext;
52
+ }
53
+ if (!("error" in attachment) && attachment.migrationRequired) {
54
+ throw new Error(`Legacy branch attachment requires explicit migration before '${spec.name}'; run kibi branch migrate --from ${attachment.kbBranch} --to ${attachment.gitBranch} --apply`);
19
55
  }
20
56
  if (options.prolog) {
21
- return { ...context, prolog: options.prolog };
57
+ return attachedContextWithProlog(withAttachment, options.prolog);
22
58
  }
23
59
  const sessionProlog = await session.ensureProlog();
24
60
  const operationContext = {
25
- ...context,
61
+ ...withAttachment,
26
62
  prolog: session.adaptProlog(sessionProlog),
27
63
  };
28
64
  sessionPrologs.set(operationContext, sessionProlog);
@@ -16,6 +16,7 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import { TOOLS } from "../tools-config.js";
19
+ import { KIBI_ICONS } from "./icons.js";
19
20
  const ACTIVE_TOOLS = TOOLS;
20
21
  function renderToolsDoc() {
21
22
  const lines = [
@@ -41,58 +42,32 @@ function renderToolsDoc() {
41
42
  export const PROMPTS = [
42
43
  // implements REQ-002, REQ-013, REQ-mcp-search-discovery
43
44
  {
44
- name: "init-kibi",
45
- description: "Activation workflow to populate a new or empty Kibi KB from an existing repository.",
45
+ name: "kibi-bootstrap",
46
+ title: "Bootstrap Kibi knowledge",
47
+ description: "Bootstrap Kibi knowledge for an existing repository through the canonical planner and apply operation.",
46
48
  text: [
47
- "# Kibi Interactive Activation Workflow",
49
+ "# Kibi Bootstrap",
48
50
  "",
49
- "Use this post-hoc workflow to onboard a new or empty repository into Kibi through interactive discovery.",
51
+ "Route an explicit bootstrap request to the canonical `kibi-bootstrap` skill and planner.",
50
52
  "",
51
- "## Step 1: Gather Declared Context",
53
+ "## Route",
52
54
  "",
53
- "The agent must ask at most 4 bounded questions to gather declared intent from the user:",
54
- "1. **Project Summary**: What is the core purpose of this project?",
55
- "2. **Source of Truth**: Where is the primary documentation (canonical requirements, ADRs)?",
56
- "3. **Priority Root**: In a monorepo, which package should be prioritized?",
57
- "4. **Verification Anchors**: Where are the primary tests or verification configs located?",
55
+ "Inspect `kb_status.bootstrap` and follow its typed `nextAction`. If infrastructure is missing, run `kibi init` before planning.",
58
56
  "",
59
- "## Step 2: Synthesize Candidates (read-only)",
57
+ "## Preview and Approval",
60
58
  "",
61
- "Call `kb_autopilot_generate` with the gathered context to synthesize candidate entities.",
59
+ "Call `kb_plan_bootstrap` read-only. Ask only the bounded questions returned when its status is `needs_context`; do not invent a questionnaire.",
62
60
  "",
63
- "This tool is **read-only**. It returns additive `structuredContent` with:",
64
- "- `promptBlock`: review text that must be surfaced before writes",
65
- "- `recommendedActions`: agent-facing next steps, including any REQ/SCEN/TEST authoring routed for manual handling",
66
- "- `declaredContext`: the user-provided bootstrap context",
67
- "- `confidence`: confidence summary for the generated output",
68
- "- `bootstrapMode`: current KB state (e.g., `root_uninitialized`)",
69
- "- `candidates`: synthesized entities grounded in declared context and source evidence",
70
- "- `applyPlan`: exact sequential `kb_upsert` payloads for approved candidates",
71
- "- `discoverySummary`: source-backed discovery notes",
61
+ "Show the exact returned `structuredContent.plan`, its actions, and canonical hash. Get explicit user approval, then pass that plan object unchanged to `kb_apply_plan`.",
72
62
  "",
73
- "## Step 3: Preview and Approval",
63
+ "## Verify and repair",
74
64
  "",
75
- "Surface the `promptBlock`, a summary of `candidates`, and the exact `structuredContent.applyPlan` payloads. Wait for explicit user approval before proceeding to writes.",
76
- "",
77
- "## Step 4: Apply Candidates",
78
- "",
79
- "Apply candidates sequentially using `kb_upsert`.",
80
- "1. Execute `structuredContent.applyPlan` sequentially in listed order.",
81
- "2. Confirm success of each `kb_upsert` before moving to the next.",
82
- "3. Run `kb_check` after the batch to verify KB integrity.",
83
- "",
84
- "## Rules",
85
- "- Never apply bootstrap writes without user-facing preview and explicit approval.",
86
- "- `kb_autopilot_generate` is strictly read-only; synthesis is the backend, not the actor.",
87
- "- MCP tools and the trusted project-local CLI are peer surfaces; choose by what is visible and approved here.",
88
- "- If Kibi MCP tools are visible and approved, use MCP.",
89
- "- Otherwise, in a trusted workspace, use the project-local CLI's dedicated JSON routes with `--input <file|->`.",
90
- "- If neither interface is available, Kibi operation is blocked; tell the operator to enable MCP or the trusted project-local CLI. Do not infer MCP availability from config file existence.",
91
- "- Do not read or edit `.kb/` files directly. Query before mutate. Run `kb_upsert` sequentially. Run `kb_check` before completion.",
65
+ "Inspect the typed `kb_apply_plan` result, follow any `nextActions` for recovery, and finish with `kb_check` and `kb_status`. Never read or edit `.kb` directly or reconstruct a plan.",
92
66
  ].join("\n"),
93
67
  },
94
68
  {
95
69
  name: "kibi_overview",
70
+ title: "Kibi usage overview",
96
71
  description: "High-level model for using kibi-mcp safely and effectively.",
97
72
  text: [
98
73
  "# kibi-mcp Overview",
@@ -113,7 +88,7 @@ export const PROMPTS = [
113
88
  "Core modeling principles:",
114
89
  "- Kibi has eight entity types: common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).",
115
90
  "- Encode requirements as linked facts: `req --constrains--> fact` plus `req --requires_property--> fact`.",
116
- "- High-confidence `kb_model_requirement` output is deterministic; `/init-kibi` bootstrap writes still require preview and explicit approval.",
91
+ "- High-confidence `kb_model_requirement` output is deterministic; `/kibi-bootstrap` bootstrap writes still require preview and explicit approval.",
117
92
  "- Low-confidence claims (< 0.7) are downgraded to `observation` facts to prevent false-positive contradictions.",
118
93
  "- Only strict domain facts participate in contradiction inference; observation and meta facts are non-blocking notes.",
119
94
  "- v1 contradictions are limited to exact-value, boolean/enum, numeric range, and polarity conflicts.",
@@ -127,6 +102,7 @@ export const PROMPTS = [
127
102
  },
128
103
  {
129
104
  name: "kibi_workflow",
105
+ title: "Kibi workflow steps",
130
106
  description: "Step-by-step call order for discovery, mutation, and verification.",
131
107
  text: [
132
108
  "# kibi-mcp Workflow",
@@ -149,6 +125,7 @@ export const PROMPTS = [
149
125
  },
150
126
  {
151
127
  name: "kibi_constraints",
128
+ title: "Kibi constraints",
152
129
  description: "Operational limits, validation rules, and mutation gotchas.",
153
130
  text: [
154
131
  "# kibi-mcp Constraints",
@@ -236,6 +213,7 @@ function registerDocResources() {
236
213
  {
237
214
  uri: "kibi://docs/overview",
238
215
  name: "kibi docs overview",
216
+ title: "Kibi server overview",
239
217
  description: "Full server description, purpose, and scope.",
240
218
  mimeType: "text/markdown",
241
219
  text: overview,
@@ -243,6 +221,7 @@ function registerDocResources() {
243
221
  {
244
222
  uri: "kibi://docs/tools",
245
223
  name: "kibi docs tools",
224
+ title: "Kibi tools reference",
246
225
  description: "Available tools with summaries and required parameters.",
247
226
  mimeType: "text/markdown",
248
227
  text: renderToolsDoc(),
@@ -250,6 +229,7 @@ function registerDocResources() {
250
229
  {
251
230
  uri: "kibi://docs/errors",
252
231
  name: "kibi docs errors",
232
+ title: "Kibi error guide",
253
233
  description: "Common error modes and suggested recovery actions.",
254
234
  mimeType: "text/markdown",
255
235
  text: errors,
@@ -257,6 +237,7 @@ function registerDocResources() {
257
237
  {
258
238
  uri: "kibi://docs/examples",
259
239
  name: "kibi docs examples",
240
+ title: "Kibi usage examples",
260
241
  description: "Concrete tool call sequences for common tasks.",
261
242
  mimeType: "text/markdown",
262
243
  text: examples,
@@ -266,7 +247,10 @@ function registerDocResources() {
266
247
  export const DOC_RESOURCES = registerDocResources();
267
248
  export function setupDocsAndPrompts(server) {
268
249
  for (const prompt of PROMPTS) {
269
- server.prompt(prompt.name, prompt.description, async () => ({
250
+ server.registerPrompt(prompt.name, {
251
+ title: prompt.title,
252
+ description: prompt.description,
253
+ }, async () => ({
270
254
  messages: [
271
255
  {
272
256
  role: "user",
@@ -276,7 +260,12 @@ export function setupDocsAndPrompts(server) {
276
260
  }));
277
261
  }
278
262
  for (const resource of DOC_RESOURCES) {
279
- server.resource(resource.name, resource.uri, { description: resource.description, mimeType: resource.mimeType }, async () => ({
263
+ server.registerResource(resource.name, resource.uri, {
264
+ title: resource.title,
265
+ description: resource.description,
266
+ mimeType: resource.mimeType,
267
+ icons: KIBI_ICONS,
268
+ }, async () => ({
280
269
  contents: [
281
270
  {
282
271
  uri: resource.uri,
@@ -0,0 +1,27 @@
1
+ /*
2
+ Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ Copyright (C) 2026 Piotr Franczyk
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ const KIBI_LOGO_MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 308 309"><rect width="308" height="309" rx="64" fill="#1D1E23"/><ellipse cx="113.3" cy="150.9" rx="51.3" ry="51.5" fill="#A2D3F4"/><path d="M228.1 197.8L227.6 165.5H156.5L95.4 165.3V135.1H156.5L227.5 135.1V101.2H252.2V197.8Z" fill="#A2D3F4"/></svg>`;
19
+ const KIBI_ICON_SRC = `data:image/svg+xml;base64,${Buffer.from(KIBI_LOGO_MARK_SVG, "utf8").toString("base64")}`;
20
+ export const KIBI_ICONS = [
21
+ {
22
+ src: KIBI_ICON_SRC,
23
+ mimeType: "image/svg+xml",
24
+ sizes: ["any"],
25
+ theme: "dark",
26
+ },
27
+ ];
@@ -0,0 +1,107 @@
1
+ /*
2
+ Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ Copyright (C) 2026 Piotr Franczyk
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ import process from "node:process";
19
+ const MAX_FINISHED_JOBS = 32;
20
+ const jobs = new Map();
21
+ let jobCounter = 0;
22
+ function nextJobId(tool) {
23
+ jobCounter += 1;
24
+ const random = Math.random().toString(36).slice(2, 10);
25
+ return `job-${tool}-${jobCounter.toString(36)}-${random}`;
26
+ }
27
+ function evictOldestFinished() {
28
+ for (const [id, record] of jobs) {
29
+ if (jobs.size <= MAX_FINISHED_JOBS)
30
+ break;
31
+ if (record.status !== "running") {
32
+ jobs.delete(id);
33
+ }
34
+ }
35
+ }
36
+ /**
37
+ * Start a background job for `tool`. The factory's promise runs detached;
38
+ * callers return the `kibi.job.v1` receipt immediately so the MCP request is
39
+ * never held open for the duration of the operation.
40
+ */
41
+ // implements REQ-002
42
+ export function startJob(tool, factory) {
43
+ const jobId = nextJobId(tool);
44
+ const record = {
45
+ jobId,
46
+ tool,
47
+ startedAt: new Date().toISOString(),
48
+ status: "running",
49
+ };
50
+ jobs.set(jobId, record);
51
+ void (async () => {
52
+ try {
53
+ const result = await factory();
54
+ record.status = "succeeded";
55
+ record.finishedAt = new Date().toISOString();
56
+ record.result =
57
+ result !== null && typeof result === "object"
58
+ ? result
59
+ : { value: result };
60
+ }
61
+ catch (error) {
62
+ record.status = "failed";
63
+ record.finishedAt = new Date().toISOString();
64
+ record.error = error instanceof Error ? error.message : String(error);
65
+ }
66
+ finally {
67
+ evictOldestFinished();
68
+ }
69
+ })().catch((error) => {
70
+ // Defensive: the internal bookkeeping above must never crash the server
71
+ // process via an unhandled rejection.
72
+ process.stderr.write(`[kibi-mcp] job bookkeeping error for ${jobId}: ${error instanceof Error ? error.message : String(error)}\n`);
73
+ });
74
+ return {
75
+ kibiProtocol: 1,
76
+ jobVersion: "kibi.job.v1",
77
+ jobId,
78
+ tool,
79
+ status: "running",
80
+ pollWith: "kb_job_status",
81
+ };
82
+ }
83
+ // implements REQ-002
84
+ export function getJob(jobId) {
85
+ return jobs.get(jobId);
86
+ }
87
+ /** Public JSON shape for kb_job_status responses. */
88
+ // implements REQ-002
89
+ export function jobSnapshot(job) {
90
+ return {
91
+ kibiProtocol: 1,
92
+ jobVersion: "kibi.job.v1",
93
+ jobId: job.jobId,
94
+ tool: job.tool,
95
+ status: job.status,
96
+ startedAt: job.startedAt,
97
+ ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
98
+ ...(job.result === undefined ? {} : { result: job.result }),
99
+ ...(job.error === undefined ? {} : { error: job.error }),
100
+ };
101
+ }
102
+ /** Test seam: drop all job state. */
103
+ // implements REQ-002
104
+ export function resetJobsForTests() {
105
+ jobs.clear();
106
+ jobCounter = 0;
107
+ }
@@ -1,4 +1,18 @@
1
1
  import { z } from "zod";
2
+ export function firstDefined(items) {
3
+ return items[0];
4
+ }
5
+ export function describedAnySchema(description) {
6
+ const anySchema = z.any();
7
+ return description ? anySchema.describe(description) : anySchema;
8
+ }
9
+ export function singleLiteralOrAny(literalSchemas, description) {
10
+ const single = firstDefined(literalSchemas);
11
+ if (!single) {
12
+ return describedAnySchema(description);
13
+ }
14
+ return description ? single.describe(description) : single;
15
+ }
2
16
  function hasRequiredProperties(value, schema) {
3
17
  if (schema === null || typeof schema !== "object")
4
18
  return false;
@@ -64,16 +78,24 @@ export function jsonSchemaToZod(schema) {
64
78
  }
65
79
  const literalSchemas = literals.map((value) => z.literal(value));
66
80
  if (literalSchemas.length === 1) {
67
- const single = literalSchemas[0];
68
- if (!single) {
69
- return description ? z.any().describe(description) : z.any();
70
- }
71
- return description ? single.describe(description) : single;
81
+ return singleLiteralOrAny(literalSchemas, description);
72
82
  }
73
83
  const union = z.union(literalSchemas);
74
84
  return description ? union.describe(description) : union;
75
85
  }
76
- const schemaType = typeof obj.type === "string" ? obj.type : undefined;
86
+ const schemaTypes = Array.isArray(obj.type)
87
+ ? obj.type.filter((value) => typeof value === "string")
88
+ : typeof obj.type === "string"
89
+ ? [obj.type]
90
+ : [];
91
+ // JSON Schema nullable fields are represented as a type union in the
92
+ // catalog. Build a real Zod union so MCP publishes and validates the same
93
+ // nullability instead of silently degrading the field to z.any().
94
+ if (schemaTypes.length > 1) {
95
+ const variants = schemaTypes.map((type) => jsonSchemaToZod({ ...obj, type }));
96
+ return z.union(variants);
97
+ }
98
+ const schemaType = schemaTypes[0];
77
99
  switch (schemaType) {
78
100
  case "object": {
79
101
  const properties = obj.properties && typeof obj.properties === "object"
@@ -178,10 +200,12 @@ export function jsonSchemaToZod(schema) {
178
200
  const description = typeof obj.description === "string" ? obj.description : undefined;
179
201
  return description ? b.describe(description) : b;
180
202
  }
181
- default: {
182
- const anySchema = z.any();
203
+ case "null": {
204
+ const n = z.null();
183
205
  const description = typeof obj.description === "string" ? obj.description : undefined;
184
- return description ? anySchema.describe(description) : anySchema;
206
+ return description ? n.describe(description) : n;
185
207
  }
208
+ default:
209
+ return describedAnySchema(typeof obj.description === "string" ? obj.description : undefined);
186
210
  }
187
211
  }