kibi-mcp 2.3.0 → 3.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.
package/bin/kibi-mcp CHANGED
@@ -99,7 +99,8 @@ if (args.includes("-h") || args.includes("--help")) {
99
99
 
100
100
  if (args.includes("--diagnostic-mode")) {
101
101
  globalThis.__KIBI_MCP_DIAGNOSTIC_MODE = true;
102
- process.env.KIBI_WORKSPACE ??= process.cwd();
102
+ process.env.KIBI_WORKSPACE ??=
103
+ process.env.KIBI_MCP_ATTACH_ROOT?.trim() || process.cwd();
103
104
  process.env.KIBI_MCP_DIAGNOSTIC_MODE = "1";
104
105
  }
105
106
 
@@ -108,6 +109,26 @@ if (args.includes("-h") || args.includes("--help")) {
108
109
  process.stderr.write(json);
109
110
  }
110
111
 
112
+ // implements REQ-mcp-workspace-routing
113
+ // Host launchers name the workspace with KIBI_MCP_ATTACH_ROOT rather than
114
+ // KIBI_WORKSPACE: a pin disables per-call workspace routing. Attach after
115
+ // runtime resolution (which reads the launch directory) and only when no
116
+ // operator pin is set.
117
+ const attachRoot = process.env.KIBI_MCP_ATTACH_ROOT?.trim();
118
+ const pinned = ["KIBI_WORKSPACE", "KIBI_PROJECT_ROOT", "KIBI_ROOT"].some(
119
+ (key) => (process.env[key] ?? "").trim().length > 0,
120
+ );
121
+ if (attachRoot && !pinned) {
122
+ try {
123
+ process.chdir(attachRoot);
124
+ } catch (error) {
125
+ process.stderr.write(
126
+ `[KIBI-MCP] Cannot attach to ${attachRoot}: ${error?.message ?? String(error)}\n`,
127
+ );
128
+ process.exit(1);
129
+ }
130
+ }
131
+
111
132
  if (result.stale) {
112
133
  if (result.projectLocal && process.env.KIBI_MCP_PROJECT_LOCAL_REENTRY !== "1") {
113
134
  process.env.KIBI_MCP_PROJECT_LOCAL_REENTRY = "1";
@@ -179,8 +179,11 @@ function canonicalDiagnosticValue(value) {
179
179
  .map(([key, child]) => [key, canonicalDiagnosticValue(child)]));
180
180
  }
181
181
  function mutationFingerprint(args) {
182
+ // A kb_upsert dry run must fingerprint like the write it previews, so
183
+ // validation-before-upsert pairs the two.
184
+ const { dryRun: _dryRun, ...payload } = args;
182
185
  return createHash("sha256")
183
- .update(JSON.stringify(canonicalDiagnosticValue(args)))
186
+ .update(JSON.stringify(canonicalDiagnosticValue(payload)))
184
187
  .digest("hex");
185
188
  }
186
189
  function appendMutationFields(fields, args) {
@@ -311,7 +314,45 @@ function appendContradictionCheckFields(fields, contradictionCheck) {
311
314
  fields.semantic_property_key = propertyKey;
312
315
  }
313
316
  }
314
- export function deriveDiagnosticFields(toolName, args, telemetry, result) {
317
+ const MODEL_MODE_OPERATIONS = {
318
+ analyze: "kb_semantic_advisor",
319
+ requirement: "kb_model_requirement",
320
+ predicates: "kb_suggest_predicates",
321
+ };
322
+ const SKILL_ACTION_OPERATIONS = {
323
+ list: "kb_skills_list",
324
+ load: "kb_skills_load",
325
+ read: "kb_skills_read",
326
+ };
327
+ /** Composite tools report diagnostics under the operation they routed to. */
328
+ // implements REQ-kibi-mcp-tool-consolidation
329
+ export function routedOperationName(toolName, args) {
330
+ if (toolName === "kb_model" && typeof args.mode === "string")
331
+ return MODEL_MODE_OPERATIONS[args.mode] ?? toolName;
332
+ if (toolName === "kb_skills" && typeof args.action === "string")
333
+ return SKILL_ACTION_OPERATIONS[args.action] ?? toolName;
334
+ if (toolName === "kb_upsert" && args.dryRun === true)
335
+ return "kb_validate_upsert";
336
+ return toolName;
337
+ }
338
+ /**
339
+ * The business arguments the routed operation received: the composite's
340
+ * selector is dropped, so a usage entry matches the narrow CLI route's entry.
341
+ */
342
+ // implements REQ-kibi-mcp-tool-consolidation
343
+ export function routedBusinessArgs(toolName, args) {
344
+ if (routedOperationName(toolName, args) === toolName)
345
+ return args;
346
+ const selector = toolName === "kb_model"
347
+ ? "mode"
348
+ : toolName === "kb_skills"
349
+ ? "action"
350
+ : "dryRun";
351
+ const { [selector]: _selector, ...rest } = args;
352
+ return rest;
353
+ }
354
+ export function deriveDiagnosticFields(invokedToolName, args, telemetry, result) {
355
+ const toolName = routedOperationName(invokedToolName, args);
315
356
  const fields = {
316
357
  telemetry_status: telemetry ? "provided" : "missing",
317
358
  };
@@ -1,4 +1,31 @@
1
- import { CapabilityRegistryCache, ensureCapabilityRegistry, resolveBranchAttachment, } from "kibi-runtime";
1
+ import { CapabilityRegistryCache, declaresWriteEffect, detachedHeadWriteRefusal, ensureCapabilityRegistry, resolveBranchAttachment, resolveOperationAttachment, } from "kibi-runtime";
2
+ /**
3
+ * Branch attachment for one MCP operation. A detached HEAD keeps the exact
4
+ * contract for writes (refused with the way out) while KB reads attach the
5
+ * read-only snapshot compiled from the checkout, which the shared session
6
+ * engine then serves.
7
+ */
8
+ // implements REQ-branch-store-recovery-v4
9
+ function resolveMcpAttachment(workspaceRoot, spec) {
10
+ // Synchronous unless HEAD is detached: an attached checkout opens its
11
+ // operation context without extra event-loop turns.
12
+ const attachment = resolveBranchAttachment(workspaceRoot);
13
+ if (!("error" in attachment) || attachment.code !== "DETACHED_HEAD") {
14
+ return attachment;
15
+ }
16
+ if (declaresWriteEffect(spec.effects)) {
17
+ throw new Error(detachedHeadWriteRefusal(spec.name, workspaceRoot));
18
+ }
19
+ if (!spec.effects.includes("kb-read"))
20
+ return attachment;
21
+ return resolveOperationAttachment(workspaceRoot, spec).catch((error) => {
22
+ // Status diagnoses a snapshot that failed to compile instead of failing.
23
+ if (spec.requiresProlog)
24
+ throw error;
25
+ console.warn(`[KIBI-MCP] ${error instanceof Error ? error.message : String(error)}`);
26
+ return attachment;
27
+ });
28
+ }
2
29
  export function attachedContextWithProlog(withAttachment, prolog) {
3
30
  return { ...withAttachment, prolog };
4
31
  }
@@ -38,7 +65,8 @@ export function createMcpRuntime(session) {
38
65
  ...(git ? { git } : {}),
39
66
  ...(net ? { net } : {}),
40
67
  };
41
- const attachment = resolveBranchAttachment(context.workspaceRoot);
68
+ const resolved = resolveMcpAttachment(context.workspaceRoot, spec);
69
+ const attachment = resolved instanceof Promise ? await resolved : resolved;
42
70
  if (!("error" in attachment) && attachment.migrationRequired) {
43
71
  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.`);
44
72
  }
@@ -36,7 +36,7 @@ function renderToolsDoc() {
36
36
  lines.push("");
37
37
  lines.push("Modeling note: Kibi has eight core entity types grouped into common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).");
38
38
  lines.push("Only strict domain facts (`fact_kind: subject` + `property_value`) participate in contradiction inference; use `flag` for runtime/config gates and `fact_kind: observation` or `meta` for bug/workaround notes.");
39
- lines.push("Predicate flow: before writing ontology prose, call `kb_suggest_predicates`; apply a suggested `fact_kind: predicate` via `requires_predicate`, or record the returned `review:ontology-gap` observation when no predicate fits.");
39
+ lines.push("Predicate flow: before writing ontology prose, call `kb_model` (mode `predicates`); apply a suggested `fact_kind: predicate` via `requires_predicate`, or record the returned `review:ontology-gap` observation when no predicate fits.");
40
40
  return lines.join("\n");
41
41
  }
42
42
  export const PROMPTS = [
@@ -54,9 +54,13 @@ export const PROMPTS = [
54
54
  "",
55
55
  "Inspect `kb_status.bootstrap` and follow its typed `nextAction`. If infrastructure is missing, run `kibi init` before planning.",
56
56
  "",
57
+ "## Source interview",
58
+ "",
59
+ "Before planning, list the knowledge sources you can read (your MCP connectors for issue trackers, wikis, and specs, plus in-repo docs) and ask the user which hold product intent and which are authoritative, supporting, or stale. Read the confirmed sources and pass them as `bootstrapContext.knowledgeSources` with cited `bootstrapContext.intentClaims`. Kibi never contacts those sources itself.",
60
+ "",
57
61
  "## Preview and Approval",
58
62
  "",
59
- "Call `kb_plan_bootstrap` read-only. Ask only the bounded questions returned when its status is `needs_context`; do not invent a questionnaire.",
63
+ "Call `kb_plan_bootstrap` read-only. After the source interview, ask only the bounded questions returned when its status is `needs_context`.",
60
64
  "",
61
65
  "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`.",
62
66
  "",
@@ -75,7 +79,7 @@ export const PROMPTS = [
75
79
  "Treat this server as a branch-aware knowledge graph interface for software traceability.",
76
80
  "",
77
81
  "The server exposes a curated public tool surface for KB operations:",
78
- "- `kb_search`: Discovery across metadata and markdown body text",
82
+ "- `kb_search`: Ask a question or search; returns ranked matches plus an answer layer (governing requirements, linked facts, ADRs, scenarios and tests)",
79
83
  "- `kb_query`: Exact lookup of entities by type, ID, tags, or source file",
80
84
  "- `kb_status`: Branch, snapshot, and freshness inspection",
81
85
  "- `kb_find_gaps`: Bulk missing/present relationship analysis",
@@ -88,7 +92,7 @@ export const PROMPTS = [
88
92
  "Core modeling principles:",
89
93
  "- Kibi has eight entity types: common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).",
90
94
  "- Encode requirements as linked facts: `req --constrains--> fact` plus `req --requires_property--> fact`.",
91
- "- High-confidence `kb_model_requirement` output is deterministic; `/kibi-bootstrap` bootstrap writes still require preview and explicit approval.",
95
+ "- High-confidence `kb_model` (mode `requirement`) output is deterministic; `/kibi-bootstrap` bootstrap writes still require preview and explicit approval.",
92
96
  "- Low-confidence claims (< 0.7) are downgraded to `observation` facts to prevent false-positive contradictions.",
93
97
  "- Only strict domain facts participate in contradiction inference; observation and meta facts are non-blocking notes.",
94
98
  "- v1 contradictions are limited to exact-value, boolean/enum, numeric range, and polarity conflicts.",
@@ -112,8 +116,8 @@ export const PROMPTS = [
112
116
  "2. **Check schema status**: Call `kb_status` to see if a schema migration is required for the branch KB.",
113
117
  "3. **Create-before-link**: Create endpoint entities with `kb_upsert` before linking them.",
114
118
  "4. **Validate intent**: If creating links, call `kb_query` for both endpoint IDs first to ensure they exist.",
115
- "5. **Model requirements as facts**: For new/updated reqs, create/reuse fact entities first, then express req semantics with `constrains` + `requires_property` (automated via `kb_model_requirement`).",
116
- "6. **Suggest predicates before prose**: For ontology-lane requirements, spell out the prose claim and call `kb_suggest_predicates` before writing `fact_kind: observation`. Apply the selected `fact_kind: predicate` applyPlan, then attach the returned `relationshipPlan` as `requires_predicate` while preserving existing req metadata; use the returned `review:ontology-gap` observation when no predicate fits.",
119
+ "5. **Model requirements as facts**: For new/updated reqs, create/reuse fact entities first, then express req semantics with `constrains` + `requires_property` (automated via `kb_model` (mode `requirement`)).",
120
+ "6. **Suggest predicates before prose**: For ontology-lane requirements, spell out the prose claim and call `kb_model` (mode `predicates`) before writing `fact_kind: observation`. Apply the selected `fact_kind: predicate` applyPlan, then attach the returned `relationshipPlan` as `requires_predicate` while preserving existing req metadata; use the returned `review:ontology-gap` observation when no predicate fits.",
117
121
  "7. **Plan repairs**: Requirement `kb_coverage` returns `repairPlan`; require `repairPlan.scope.complete`, apply only a batch whose state is `ready`, and treat every batch as non-auto-applicable guidance.",
118
122
  "8. **Mutate sequentially**: Query current endpoints, validate each payload, then call `kb_upsert` one at a time or `kb_delete` for explicit removals. Re-run `kb_coverage` after each batch rather than continuing from a stale plan.",
119
123
  "9. **Targeted checks**: Run `kb_check` after meaningful mutations; specify only the rules you need so scoped validation stays fast and skips the full-KB advisory scan.",
@@ -187,7 +191,7 @@ function registerDocResources() {
187
191
  '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation; supplying `rules` skips the full-KB advisory scan',
188
192
  "",
189
193
  "## Model requirements as ontology predicates",
190
- '1. Spell out the requirement prose and call `kb_suggest_predicates` with `{ "text": "...", "requirementId": "REQ-..." }`',
194
+ '1. Spell out the requirement prose and call `kb_model` with `{ "mode": "predicates", "text": "...", "requirementId": "REQ-..." }`',
191
195
  "2. If candidates are returned, apply the top or user-selected `structuredContent.applyPlan` to create `fact_kind: predicate`, then attach `structuredContent.relationshipPlan` with `requires_predicate` while preserving existing req metadata",
192
196
  "3. If no candidate fits, apply or review the returned `review:ontology-gap` observation instead of silently writing prose",
193
197
  "",
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
20
20
  import process from "node:process";
21
21
  import { EngineClient } from "kibi-runtime";
22
22
  import { PrologProcess } from "kibi-runtime";
23
- import { copyCleanSnapshot, ensureBranchStoreManifest, getBranchDiagnostic, isValidBranchName, resolveActiveBranch, resolveBranchAttachment, } from "kibi-runtime";
23
+ import { copyCleanSnapshot, ensureBranchStoreManifest, getBranchDiagnostic, isValidBranchName, resolveActiveBranch, resolveReadBranchAttachment, } from "kibi-runtime";
24
24
  import { getBranchOverride, isMcpDebugEnabled } from "../env.js";
25
25
  import { resolveKbPath, resolveWorkspaceRoot } from "../workspace.js";
26
26
  import { KbRefreshError, describeBranchKbStamp, readBranchKbStamp, sameBranchKbStamp, } from "./kb-freshness.js";
@@ -32,7 +32,9 @@ const defaultSessionDeps = {
32
32
  getBranchDiagnostic,
33
33
  isValidBranchName,
34
34
  resolveActiveBranch,
35
- resolveBranchAttachment,
35
+ // The session engine serves reads; on a detached HEAD it attaches the
36
+ // checkout's read-only snapshot, and writes are refused before reaching it.
37
+ resolveBranchAttachment: resolveReadBranchAttachment,
36
38
  resolveKbPath,
37
39
  resolveWorkspaceRoot,
38
40
  };
@@ -48,7 +50,7 @@ const state = {
48
50
  isShuttingDown: false,
49
51
  shutdownTimeout: null,
50
52
  };
51
- // implements REQ-008
53
+ // implements REQ-core-prolog-process-management
52
54
  export function getPrologProcess() {
53
55
  return state.prologProcess;
54
56
  }
@@ -56,11 +58,11 @@ export function getPrologProcess() {
56
58
  export function getActiveBranchName() {
57
59
  return state.activeBranchName;
58
60
  }
59
- // implements REQ-008
61
+ // implements REQ-mcp-kb-freshness
60
62
  export function getAttachedBranchKbPath() {
61
63
  return state.attachedBranchKbPath;
62
64
  }
63
- // implements REQ-008
65
+ // implements REQ-core-prolog-process-management
64
66
  export function getIsShuttingDown() {
65
67
  return state.isShuttingDown;
66
68
  }
@@ -70,7 +72,7 @@ export const inFlightRequests = new Map();
70
72
  export function updateAttachedBranchStamp(stamp) {
71
73
  state.attachedBranchStamp = stamp;
72
74
  }
73
- // implements REQ-008
75
+ // implements REQ-core-prolog-process-management
74
76
  export function resetSessionStateForTests() {
75
77
  state.prologProcess = null;
76
78
  state.isInitialized = false;
@@ -87,15 +89,15 @@ export function resetSessionStateForTests() {
87
89
  }
88
90
  }
89
91
  export function _setSessionDepsForTests(
90
- // implements REQ-008
92
+ // implements REQ-core-prolog-process-management
91
93
  overrides) {
92
94
  sessionDeps = { ...sessionDeps, ...overrides };
93
95
  }
94
96
  export function _resetSessionDepsForTests() {
95
- // implements REQ-008
97
+ // implements REQ-core-prolog-process-management
96
98
  sessionDeps = { ...defaultSessionDeps };
97
99
  }
98
- // implements REQ-008
100
+ // implements REQ-core-prolog-process-management
99
101
  export function _setPrologProcessForTests(process) {
100
102
  state.prologProcess = process;
101
103
  }
@@ -105,7 +107,7 @@ function debugLog(...args) {
105
107
  }
106
108
  }
107
109
  export function ensureBranchKbExists(workspaceRoot, branch) {
108
- // implements REQ-008
110
+ // implements REQ-branch-store-recovery-v4
109
111
  if (!sessionDeps.isValidBranchName(branch)) {
110
112
  throw new Error(`Invalid branch name: ${branch}`);
111
113
  }
@@ -175,7 +177,7 @@ export async function initiateGracefulShutdown(exitCode = 0) {
175
177
  // Exit
176
178
  process.exit(exitCode);
177
179
  }
178
- // implements REQ-008
180
+ // implements REQ-core-prolog-process-management
179
181
  export async function resetProlog(reason) {
180
182
  debugLog(`[KIBI-MCP] Resetting Prolog worker: ${reason}`);
181
183
  state.prologResetGeneration += 1;
@@ -222,7 +224,7 @@ async function refreshAttachedBranchKbWithRetry(prolog, kbPath, currentStamp, as
222
224
  function usesBranchKbPath(kbPath) {
223
225
  return (kbPath.includes("/.kb/branches/") || kbPath.includes("\\.kb\\branches\\"));
224
226
  }
225
- // implements REQ-008
227
+ // implements REQ-core-prolog-process-management
226
228
  async function ensurePrologUnsafe() {
227
229
  const generationAtStart = state.prologResetGeneration;
228
230
  const workspaceRoot = sessionDeps.resolveWorkspaceRoot();
@@ -1,5 +1,6 @@
1
1
  import { getSpec, statusSpec } from "kibi-runtime";
2
- import { executeApplyPlan, executeCompileIntent, executeDelete, executeIngestProof, executeUpsert, validateUpsertSpec, } from "kibi-runtime";
2
+ import { MODEL_ROUTES, SKILL_ROUTES, dispatchComposite, executeApplyPlan, executeCompileIntent, executeDelete, executeIngestProof, executeUpsert, } from "kibi-runtime";
3
+ import { OPTIONAL_TOOL_NAMES, enabledOptionalTools } from "../tools-config.js";
3
4
  import { startJob } from "./jobs.js";
4
5
  // implements REQ-002, REQ-013
5
6
  export function registerConfiguredTools(server, runtime, registerTool) {
@@ -21,6 +22,12 @@ export function registerConfiguredTools(server, runtime, registerTool) {
21
22
  prolog: prologFor(context),
22
23
  });
23
24
  const register = ({ name, execute }) => {
25
+ // Optional tools (kb_sparql_remote) are registered only when enabled;
26
+ // every other configured tool must have a definition.
27
+ if (OPTIONAL_TOOL_NAMES.includes(name) &&
28
+ !runtime.tools.some((tool) => tool.name === name)) {
29
+ return;
30
+ }
24
31
  const definition = toolDef(name);
25
32
  const publicSpec = getSpec(name);
26
33
  const spec = {
@@ -55,16 +62,21 @@ export function registerConfiguredTools(server, runtime, registerTool) {
55
62
  statusSpec.execute(args, context),
56
63
  });
57
64
  register({
58
- name: "kb_skills_list",
59
- execute: async (_context, args) => runtime.handleKbSkillsList(args),
60
- });
61
- register({
62
- name: "kb_skills_load",
63
- execute: async (_context, args) => runtime.handleKbSkillsLoad(args),
64
- });
65
- register({
66
- name: "kb_skills_read",
67
- execute: async (_context, args) => runtime.handleKbSkillsRead(args),
65
+ name: "kb_skills",
66
+ execute: async (context, args) => dispatchComposite("action", {
67
+ list: {
68
+ ...SKILL_ROUTES.list,
69
+ execute: async (input) => runtime.handleKbSkillsList(input),
70
+ },
71
+ load: {
72
+ ...SKILL_ROUTES.load,
73
+ execute: async (input) => runtime.handleKbSkillsLoad(input),
74
+ },
75
+ read: {
76
+ ...SKILL_ROUTES.read,
77
+ execute: async (input) => runtime.handleKbSkillsRead(input),
78
+ },
79
+ }, args, context),
68
80
  });
69
81
  register({
70
82
  name: "kb_find_gaps",
@@ -83,17 +95,26 @@ export function registerConfiguredTools(server, runtime, registerTool) {
83
95
  execute: async (context, args) => runtime.handleSparql(args, context),
84
96
  });
85
97
  register({
86
- name: "kb_semantic_advisor",
87
- execute: async (context, args) => runtime.handleKbSemanticAdvisor(args, context),
98
+ name: "kb_model",
99
+ execute: async (context, args) => dispatchComposite("mode", {
100
+ analyze: {
101
+ ...MODEL_ROUTES.analyze,
102
+ execute: async (input) => runtime.handleKbSemanticAdvisor(input, context),
103
+ },
104
+ requirement: {
105
+ ...MODEL_ROUTES.requirement,
106
+ execute: async (input) => runtime.handleKbModelRequirement(prologFor(context), input, withSessionProlog(context)),
107
+ },
108
+ predicates: {
109
+ ...MODEL_ROUTES.predicates,
110
+ execute: async (input) => runtime.handleKbSuggestPredicates(prologFor(context), input, withSessionProlog(context)),
111
+ },
112
+ }, args, context),
88
113
  });
89
114
  register({
90
115
  name: "kb_upsert",
91
116
  execute: async (context, args) => executeUpsert(args, withSessionProlog(context)),
92
117
  });
93
- register({
94
- name: "kb_validate_upsert",
95
- execute: async (context, args) => validateUpsertSpec.execute(args, withSessionProlog(context)),
96
- });
97
118
  register({
98
119
  name: "kb_delete",
99
120
  execute: async (context, args) => executeDelete(args, withSessionProlog(context)),
@@ -102,7 +123,9 @@ export function registerConfiguredTools(server, runtime, registerTool) {
102
123
  name: "kb_check",
103
124
  execute: async (context, args) => {
104
125
  const { async: asyncMode, ...checkArgs } = args;
105
- if (asyncMode !== true) {
126
+ // Without a registered kb_job_status nothing could poll the receipt,
127
+ // so async falls back to a synchronous check.
128
+ if (asyncMode !== true || !enabledOptionalTools().has("kb_job_status")) {
106
129
  return runtime.handleKbCheck(prologFor(context), args);
107
130
  }
108
131
  // Long-KB full checks can exceed the tool timeout. Detach into a job
@@ -115,14 +138,6 @@ export function registerConfiguredTools(server, runtime, registerTool) {
115
138
  name: "kb_prepare_impact_review",
116
139
  execute: async (context, args) => getSpec("kb_prepare_impact_review").execute(args, context),
117
140
  });
118
- register({
119
- name: "kb_model_requirement",
120
- execute: async (context, args) => runtime.handleKbModelRequirement(prologFor(context), args, withSessionProlog(context)),
121
- });
122
- register({
123
- name: "kb_suggest_predicates",
124
- execute: async (context, args) => runtime.handleKbSuggestPredicates(prologFor(context), args, withSessionProlog(context)),
125
- });
126
141
  register({
127
142
  name: "kb_plan_bootstrap",
128
143
  execute: async (context, args) => runtime.handleKbPlanBootstrap(args, context),
@@ -25,7 +25,7 @@ const defaultToolsServerDeps = {
25
25
  };
26
26
  let sessionModulePromise = null;
27
27
  const prologPorts = new WeakMap();
28
- // implements REQ-008
28
+ // implements REQ-002
29
29
  export function _setToolsServerDepsForTests(deps, resetPromise = false) {
30
30
  defaultToolsServerDeps.getSessionModule =
31
31
  deps.getSessionModule ?? defaultToolsServerDeps.getSessionModule;
@@ -16,14 +16,17 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import process from "node:process";
19
- import { executeOperation } from "kibi-runtime";
19
+ import { QUERY_LIMIT_EXCEEDED_CODE, detachedReadOnlyDiagnostic, executeOperation, queryLimitExceededOf, } from "kibi-runtime";
20
20
  import { operationData, toKibiResult } from "kibi-runtime";
21
+ import { routedBusinessArgs, routedOperationName } from "../diagnostics.js";
21
22
  import { isMcpDebugEnabled } from "../env.js";
23
+ import { enabledOptionalTools } from "../tools-config.js";
22
24
  import { appendDiagnosticErrorUsage, appendDiagnosticSuccessUsage, } from "./diagnostic-usage.js";
23
25
  import { getJob, jobSnapshot } from "./jobs.js";
24
26
  import { jsonSchemaToZod } from "./json-schema-to-zod.js";
25
27
  import { registerConfiguredTools } from "./tool-registration.js";
26
28
  import { DEFAULT_TOOLS_RUNTIME, _resetSessionModulePromise, _setToolsServerDepsForTests, } from "./tools-runtime.js";
29
+ import { WORKSPACE_ROOT_ARGUMENT, getWorkspaceRouter, } from "./workspace-router.js";
27
30
  export { jsonSchemaToZod } from "./json-schema-to-zod.js";
28
31
  export { _resetSessionModulePromise, _setToolsServerDepsForTests };
29
32
  const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
@@ -54,7 +57,7 @@ function withAgentVisibleText(content, data) {
54
57
  const text = summary.length > 0 ? `${payload}\n${summary}` : payload;
55
58
  return [{ type: "text", text }, ...nonTextParts];
56
59
  }
57
- // implements REQ-008
60
+ // implements REQ-002
58
61
  function debugLog(...args) {
59
62
  if (isMcpDebugEnabled()) {
60
63
  console.error(...args);
@@ -115,23 +118,93 @@ async function withToolTimeout(toolName, operation, onTimeout) {
115
118
  }
116
119
  }
117
120
  }
121
+ /**
122
+ * A module that vanished after startup means the package was upgraded or
123
+ * reinstalled under a running server; only a restart loads the new install.
124
+ */
125
+ function staleInstallHint(error) {
126
+ const code = error.code;
127
+ const missingModule = code === "ERR_MODULE_NOT_FOUND" ||
128
+ code === "MODULE_NOT_FOUND" ||
129
+ /Cannot find (module|package)/.test(error.message);
130
+ return missingModule
131
+ ? " — this Kibi MCP server is running from files that are no longer installed (Kibi was likely upgraded or reinstalled after it started). Restart the Kibi MCP server; the project-local kibi CLI works in the meantime."
132
+ : "";
133
+ }
134
+ /**
135
+ * The decision when no workspace router is installed (unit tests, embedded
136
+ * use): every call is local, and `workspaceRoot` is dropped so handlers never
137
+ * see a routing-only argument. kb_check keeps it: there it also names the
138
+ * tree to inspect for impact diagnostics.
139
+ */
140
+ function localDecision(name, args) {
141
+ if (!(WORKSPACE_ROOT_ARGUMENT in args) || name === "kb_check") {
142
+ return { kind: "local", args };
143
+ }
144
+ const { [WORKSPACE_ROOT_ARGUMENT]: _ignored, ...rest } = args;
145
+ return { kind: "local", args: rest };
146
+ }
147
+ /** A result's diagnostics carry a routing or detached-HEAD notice when one applies. */
148
+ function withWorkspaceNotice(envelope, notice) {
149
+ if (!notice)
150
+ return envelope;
151
+ return { ...envelope, diagnostics: [...envelope.diagnostics, notice] };
152
+ }
153
+ /**
154
+ * Observe the context an operation opens so its envelope can say when a
155
+ * detached HEAD answered from the read-only snapshot, and which store.
156
+ */
157
+ // implements REQ-branch-store-recovery-v4
158
+ function observeDetachedReads(base) {
159
+ let notice;
160
+ return {
161
+ runtime: {
162
+ // Read the attachment at close (success or error) so opening the
163
+ // context costs no extra event-loop turn.
164
+ open: (spec, options) => base.open(spec, options),
165
+ afterSuccess: (spec, context) => base.afterSuccess(spec, context),
166
+ close: (context, outcome) => {
167
+ notice = detachedReadOnlyDiagnostic(context.branchAttachment);
168
+ return base.close(context, outcome);
169
+ },
170
+ },
171
+ notice: () => notice,
172
+ };
173
+ }
118
174
  // implements REQ-002
119
175
  export function addTool(server, name, description, inputSchema, handler,
120
176
  // INTENTIONAL: DEFAULT_TOOLS_RUNTIME is typed as ToolsRuntime<PrologProcess>; the
121
177
  // generic TProlog parameter exists so tests can inject a mock type. The cast is safe
122
178
  // because the runtime object satisfies the full ToolsRuntime contract at runtime.
123
179
  runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
124
- const wrappedHandler = async (args) => {
180
+ const wrappedHandler = async (rawArgs) => {
125
181
  const startedAt = new Date();
126
182
  const diagnosticModeEnabled = runtime.diagnosticModeEnabled();
127
- const { businessArgs, telemetry } = diagnosticModeEnabled
128
- ? runtime.extractToolCallPayload(args)
129
- : { businessArgs: args, telemetry: null };
183
+ let args = rawArgs;
184
+ let workspaceNotice;
185
+ let businessArgs = rawArgs;
186
+ let telemetry = null;
130
187
  try {
131
188
  // Validate that args is a valid object
132
189
  if (typeof args !== "object" || args === null) {
133
190
  throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
134
191
  }
192
+ // Answer from the workspace the caller is working in. A call routed to
193
+ // another workspace's kibi-mcp returns that server's result as is; the
194
+ // child logs its own usage and the parent records nothing for it.
195
+ // Without a router the decision is synchronous, so a plain call reaches
196
+ // its handler without an extra microtask.
197
+ const router = getWorkspaceRouter();
198
+ const routing = router
199
+ ? await router.route(name, args)
200
+ : localDecision(name, args);
201
+ if (routing.kind === "remote")
202
+ return routing.result;
203
+ args = routing.args;
204
+ workspaceNotice = routing.notice;
205
+ ({ businessArgs, telemetry } = diagnosticModeEnabled
206
+ ? runtime.extractToolCallPayload(args)
207
+ : { businessArgs: args, telemetry: null });
135
208
  // Check if shutting down before processing
136
209
  if (await runtime.isShuttingDown()) {
137
210
  throw new Error(`Tool ${name} rejected: server is shutting down`);
@@ -154,7 +227,8 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
154
227
  requiresProlog: false,
155
228
  execute: async (input, _context) => handler(input),
156
229
  };
157
- const handlerPromise = executeOperation(runtime.operationRuntime, operationSpec, businessArgs, { signal: controller.signal });
230
+ const observed = observeDetachedReads(runtime.operationRuntime);
231
+ const handlerPromise = executeOperation(observed.runtime, operationSpec, businessArgs, { signal: controller.signal });
158
232
  trackedRequests.set(requestId, handlerPromise);
159
233
  let resetAttempted = false;
160
234
  let resetSucceeded = false;
@@ -189,15 +263,18 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
189
263
  }
190
264
  });
191
265
  const data = operationData(result);
192
- const envelope = toKibiResult(operationSpec, data);
266
+ const envelope = withWorkspaceNotice(withWorkspaceNotice(toKibiResult(operationSpec, data), workspaceNotice), observed.notice());
193
267
  // Log usage in diagnostic mode
194
268
  if (diagnosticModeEnabled) {
195
269
  await appendDiagnosticSuccessUsage({
196
270
  runtime,
197
- toolName: name,
271
+ // Usage evidence records the routed operation (kb_model mode
272
+ // predicates logs as kb_suggest_predicates) so telemetry
273
+ // acceptance keeps reading one operation per entry.
274
+ toolName: routedOperationName(name, businessArgs),
198
275
  requestId,
199
276
  args,
200
- businessArgs,
277
+ businessArgs: routedBusinessArgs(name, businessArgs),
201
278
  telemetry,
202
279
  startedAt,
203
280
  result: envelope,
@@ -227,10 +304,13 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
227
304
  if (diagnosticModeEnabled) {
228
305
  await appendDiagnosticErrorUsage({
229
306
  runtime,
230
- toolName: name,
307
+ // Usage evidence records the routed operation (kb_model mode
308
+ // predicates logs as kb_suggest_predicates) so telemetry
309
+ // acceptance keeps reading one operation per entry.
310
+ toolName: routedOperationName(name, businessArgs),
231
311
  requestId,
232
312
  args,
233
- businessArgs,
313
+ businessArgs: routedBusinessArgs(name, businessArgs),
234
314
  telemetry,
235
315
  startedAt,
236
316
  error,
@@ -238,7 +318,9 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
238
318
  });
239
319
  }
240
320
  if (isToolTimeoutError(error) &&
241
- isMutationEffect(operationSpec.effects)) {
321
+ isMutationEffect(operationSpec.effects) &&
322
+ // A kb_upsert dry run writes nothing, so its outcome is known.
323
+ !(name === "kb_upsert" && businessArgs.dryRun === true)) {
242
324
  const recoveryActions = [
243
325
  {
244
326
  operation: "kb_status",
@@ -279,6 +361,25 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
279
361
  structuredContent: envelope,
280
362
  };
281
363
  }
364
+ const limitExceeded = queryLimitExceededOf(error);
365
+ if (error instanceof Error && limitExceeded !== null) {
366
+ // A read stopped at its engine limit has no answer: say which
367
+ // limit stopped it rather than failing like a broken tool.
368
+ const envelope = withWorkspaceNotice(withWorkspaceNotice(toKibiResult(operationSpec, null, {
369
+ status: "error",
370
+ error: {
371
+ code: QUERY_LIMIT_EXCEEDED_CODE,
372
+ message: error.message,
373
+ retryable: false,
374
+ details: { limitExceeded },
375
+ },
376
+ }), workspaceNotice), observed.notice());
377
+ return {
378
+ content: [{ type: "text", text: JSON.stringify(envelope) }],
379
+ structuredContent: envelope,
380
+ isError: true,
381
+ };
382
+ }
282
383
  throw error;
283
384
  }
284
385
  finally {
@@ -292,7 +393,7 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
292
393
  if (err.stack) {
293
394
  debugLog(`[KIBI-MCP] Tool ${name} stack:`, err.stack);
294
395
  }
295
- throw new Error(`Tool ${name} failed: ${err.message}`, { cause: err });
396
+ throw new Error(`Tool ${name} failed: ${err.message}${staleInstallHint(err)}`, { cause: err });
296
397
  }
297
398
  };
298
399
  server.registerTool(name, {
@@ -307,7 +408,9 @@ export function registerAllTools(server,
307
408
  // INTENTIONAL: same generic bridge cast as addTool — see comment there.
308
409
  runtime = DEFAULT_TOOLS_RUNTIME) {
309
410
  registerConfiguredTools(server, runtime, addTool);
310
- registerJobStatusTool(server);
411
+ if (enabledOptionalTools().has("kb_job_status")) {
412
+ registerJobStatusTool(server);
413
+ }
311
414
  }
312
415
  /**
313
416
  * kb_job_status polls background jobs started by long-running operations