kibi-mcp 2.4.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 = [
@@ -79,7 +79,7 @@ export const PROMPTS = [
79
79
  "Treat this server as a branch-aware knowledge graph interface for software traceability.",
80
80
  "",
81
81
  "The server exposes a curated public tool surface for KB operations:",
82
- "- `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)",
83
83
  "- `kb_query`: Exact lookup of entities by type, ID, tags, or source file",
84
84
  "- `kb_status`: Branch, snapshot, and freshness inspection",
85
85
  "- `kb_find_gaps`: Bulk missing/present relationship analysis",
@@ -92,7 +92,7 @@ export const PROMPTS = [
92
92
  "Core modeling principles:",
93
93
  "- Kibi has eight entity types: common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).",
94
94
  "- Encode requirements as linked facts: `req --constrains--> fact` plus `req --requires_property--> fact`.",
95
- "- 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.",
96
96
  "- Low-confidence claims (< 0.7) are downgraded to `observation` facts to prevent false-positive contradictions.",
97
97
  "- Only strict domain facts participate in contradiction inference; observation and meta facts are non-blocking notes.",
98
98
  "- v1 contradictions are limited to exact-value, boolean/enum, numeric range, and polarity conflicts.",
@@ -116,8 +116,8 @@ export const PROMPTS = [
116
116
  "2. **Check schema status**: Call `kb_status` to see if a schema migration is required for the branch KB.",
117
117
  "3. **Create-before-link**: Create endpoint entities with `kb_upsert` before linking them.",
118
118
  "4. **Validate intent**: If creating links, call `kb_query` for both endpoint IDs first to ensure they exist.",
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_requirement`).",
120
- "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.",
121
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.",
122
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.",
123
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.",
@@ -191,7 +191,7 @@ function registerDocResources() {
191
191
  '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation; supplying `rules` skips the full-KB advisory scan',
192
192
  "",
193
193
  "## Model requirements as ontology predicates",
194
- '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-..." }`',
195
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",
196
196
  "3. If no candidate fits, apply or review the returned `review:ontology-gap` observation instead of silently writing prose",
197
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,9 +16,11 @@
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";
@@ -55,7 +57,7 @@ function withAgentVisibleText(content, data) {
55
57
  const text = summary.length > 0 ? `${payload}\n${summary}` : payload;
56
58
  return [{ type: "text", text }, ...nonTextParts];
57
59
  }
58
- // implements REQ-008
60
+ // implements REQ-002
59
61
  function debugLog(...args) {
60
62
  if (isMcpDebugEnabled()) {
61
63
  console.error(...args);
@@ -142,12 +144,33 @@ function localDecision(name, args) {
142
144
  const { [WORKSPACE_ROOT_ARGUMENT]: _ignored, ...rest } = args;
143
145
  return { kind: "local", args: rest };
144
146
  }
145
- /** A result's diagnostics name the workspace mismatch when routing fell back. */
147
+ /** A result's diagnostics carry a routing or detached-HEAD notice when one applies. */
146
148
  function withWorkspaceNotice(envelope, notice) {
147
149
  if (!notice)
148
150
  return envelope;
149
151
  return { ...envelope, diagnostics: [...envelope.diagnostics, notice] };
150
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
+ }
151
174
  // implements REQ-002
152
175
  export function addTool(server, name, description, inputSchema, handler,
153
176
  // INTENTIONAL: DEFAULT_TOOLS_RUNTIME is typed as ToolsRuntime<PrologProcess>; the
@@ -204,7 +227,8 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
204
227
  requiresProlog: false,
205
228
  execute: async (input, _context) => handler(input),
206
229
  };
207
- 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 });
208
232
  trackedRequests.set(requestId, handlerPromise);
209
233
  let resetAttempted = false;
210
234
  let resetSucceeded = false;
@@ -239,15 +263,18 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
239
263
  }
240
264
  });
241
265
  const data = operationData(result);
242
- const envelope = withWorkspaceNotice(toKibiResult(operationSpec, data), workspaceNotice);
266
+ const envelope = withWorkspaceNotice(withWorkspaceNotice(toKibiResult(operationSpec, data), workspaceNotice), observed.notice());
243
267
  // Log usage in diagnostic mode
244
268
  if (diagnosticModeEnabled) {
245
269
  await appendDiagnosticSuccessUsage({
246
270
  runtime,
247
- 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),
248
275
  requestId,
249
276
  args,
250
- businessArgs,
277
+ businessArgs: routedBusinessArgs(name, businessArgs),
251
278
  telemetry,
252
279
  startedAt,
253
280
  result: envelope,
@@ -277,10 +304,13 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
277
304
  if (diagnosticModeEnabled) {
278
305
  await appendDiagnosticErrorUsage({
279
306
  runtime,
280
- 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),
281
311
  requestId,
282
312
  args,
283
- businessArgs,
313
+ businessArgs: routedBusinessArgs(name, businessArgs),
284
314
  telemetry,
285
315
  startedAt,
286
316
  error,
@@ -288,7 +318,9 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
288
318
  });
289
319
  }
290
320
  if (isToolTimeoutError(error) &&
291
- 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)) {
292
324
  const recoveryActions = [
293
325
  {
294
326
  operation: "kb_status",
@@ -329,6 +361,25 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
329
361
  structuredContent: envelope,
330
362
  };
331
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
+ }
332
383
  throw error;
333
384
  }
334
385
  finally {
@@ -357,7 +408,9 @@ export function registerAllTools(server,
357
408
  // INTENTIONAL: same generic bridge cast as addTool — see comment there.
358
409
  runtime = DEFAULT_TOOLS_RUNTIME) {
359
410
  registerConfiguredTools(server, runtime, addTool);
360
- registerJobStatusTool(server);
411
+ if (enabledOptionalTools().has("kb_job_status")) {
412
+ registerJobStatusTool(server);
413
+ }
361
414
  }
362
415
  /**
363
416
  * kb_job_status polls background jobs started by long-running operations
package/dist/server.js CHANGED
@@ -31,8 +31,8 @@ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.me
31
31
  const VERSION = packageJson.version ?? "0.1.0";
32
32
  const SERVER_INSTRUCTIONS = [
33
33
  "Kibi is a branch-local requirements knowledge base for software traceability.",
34
- "Start with kb_search for discovery, then kb_query for exact lookups before any mutation.",
35
- "Model requirements as linked facts via kb_model_requirement or kb_upsert (create endpoints before linking them).",
34
+ "Start with kb_search (ask it a question; its answer layer names the governing requirements, facts, ADRs and tests), then kb_query for exact lookups before any mutation.",
35
+ "Model requirements as linked facts via kb_model (mode requirement) or kb_upsert (create endpoints before linking them); preflight writes with kb_upsert dryRun:true.",
36
36
  "Mutate sequentially with kb_upsert and kb_delete; validate with kb_check during iteration and run an unfiltered kb_check before completion.",
37
37
  "Confirm branch freshness with kb_status. For first-time repository setup, use the kibi-bootstrap prompt.",
38
38
  ].join(" ");
@@ -18,30 +18,50 @@
18
18
  import { getSpec } from "kibi-runtime";
19
19
  import { DIAGNOSTIC_MODE_ENABLED, DIAGNOSTIC_TELEMETRY_SCHEMA, } from "./diagnostics.js";
20
20
  import { withWorkspaceRootSchema } from "./server/workspace-router.js";
21
+ // implements REQ-kibi-mcp-tool-consolidation
22
+ /**
23
+ * The agent-facing tool list. Narrower operations (kb_skills_list,
24
+ * kb_semantic_advisor, kb_model_requirement, kb_suggest_predicates,
25
+ * kb_validate_upsert) stay in the operation catalog and on the CLI; MCP
26
+ * reaches them through kb_skills, kb_model and kb_upsert dryRun so hosts
27
+ * load fewer tool definitions.
28
+ */
21
29
  const MCP_TOOL_ORDER = [
22
30
  "kb_query",
23
31
  "kb_search",
24
32
  "kb_status",
25
- "kb_skills_list",
26
- "kb_skills_load",
27
- "kb_skills_read",
33
+ "kb_skills",
28
34
  "kb_find_gaps",
29
35
  "kb_coverage",
30
36
  "kb_graph",
31
- "kb_sparql_remote",
32
- "kb_semantic_advisor",
37
+ "kb_model",
33
38
  "kb_upsert",
34
- "kb_validate_upsert",
35
39
  "kb_delete",
36
40
  "kb_check",
37
41
  "kb_prepare_impact_review",
38
- "kb_model_requirement",
39
- "kb_suggest_predicates",
40
42
  "kb_plan_bootstrap",
41
43
  "kb_compile_intent",
42
44
  "kb_apply_plan",
43
45
  "kb_ingest_proof",
44
46
  ];
47
+ /** Tools registered only when named in KIBI_MCP_OPTIONAL_TOOLS. */
48
+ // implements REQ-kibi-mcp-tool-consolidation
49
+ export const OPTIONAL_TOOL_NAMES = [
50
+ "kb_sparql_remote",
51
+ "kb_job_status",
52
+ ];
53
+ /**
54
+ * Parse KIBI_MCP_OPTIONAL_TOOLS: a comma-separated list of optional tool
55
+ * names, or "all".
56
+ */
57
+ // implements REQ-kibi-mcp-tool-consolidation
58
+ export function enabledOptionalTools(value = process.env.KIBI_MCP_OPTIONAL_TOOLS) {
59
+ const requested = new Set((value ?? "")
60
+ .split(",")
61
+ .map((entry) => entry.trim())
62
+ .filter(Boolean));
63
+ return new Set(OPTIONAL_TOOL_NAMES.filter((name) => requested.has("all") || requested.has(name)));
64
+ }
45
65
  // implements REQ-002
46
66
  const TOOL_ANNOTATIONS = {
47
67
  kb_query: {
@@ -57,6 +77,20 @@ const TOOL_ANNOTATIONS = {
57
77
  idempotentHint: true,
58
78
  openWorldHint: false,
59
79
  },
80
+ kb_skills: {
81
+ title: "Read bundled Kibi skills",
82
+ readOnlyHint: true,
83
+ destructiveHint: false,
84
+ idempotentHint: true,
85
+ openWorldHint: false,
86
+ },
87
+ kb_model: {
88
+ title: "Model Kibi requirement prose",
89
+ readOnlyHint: true,
90
+ destructiveHint: false,
91
+ idempotentHint: true,
92
+ openWorldHint: false,
93
+ },
60
94
  kb_skills_list: {
61
95
  title: "List bundled Kibi skills",
62
96
  readOnlyHint: true,
@@ -151,7 +185,15 @@ const TOOL_ANNOTATIONS = {
151
185
  openWorldHint: false,
152
186
  },
153
187
  };
154
- const BASE_TOOLS = MCP_TOOL_ORDER.map((name) => {
188
+ function exposedToolNames(optional) {
189
+ return [
190
+ ...MCP_TOOL_ORDER,
191
+ ...(optional.has("kb_sparql_remote")
192
+ ? ["kb_sparql_remote"]
193
+ : []),
194
+ ];
195
+ }
196
+ function toolConfig(name) {
155
197
  const spec = getSpec(name);
156
198
  const effects = spec.declaredEffects;
157
199
  const derived = {
@@ -173,7 +215,13 @@ const BASE_TOOLS = MCP_TOOL_ORDER.map((name) => {
173
215
  ...derived,
174
216
  },
175
217
  };
176
- });
218
+ }
219
+ /** Catalog tool configs for the base list plus the given optional tools. */
220
+ // implements REQ-kibi-mcp-tool-consolidation
221
+ export function buildBaseTools(optional = enabledOptionalTools()) {
222
+ return exposedToolNames(optional).map(toolConfig);
223
+ }
224
+ const BASE_TOOLS = buildBaseTools();
177
225
  /**
178
226
  * Inject _diagnostic_telemetry schema into tool inputs when diagnostic mode is enabled.
179
227
  * Exported for unit coverage; TOOLS still applies it only when the server starts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "2.4.0",
3
+ "version": "3.0.0",
4
4
  "mcpName": "io.github.Looted/kibi-mcp",
5
5
  "dependencies": {
6
6
  "@modelcontextprotocol/sdk": "^1.30.0",
@@ -10,8 +10,8 @@
10
10
  "fast-glob": "^3.2.12",
11
11
  "gray-matter": "^4.0.3",
12
12
  "js-yaml": "^4.1.0",
13
- "kibi-runtime": "^2.2.0",
14
- "kibi-core": "^0.14.1",
13
+ "kibi-runtime": "^2.3.0",
14
+ "kibi-core": "^0.15.0",
15
15
  "mcpcat": "^0.1.12",
16
16
  "zod": "^4.3.6"
17
17
  },
@@ -29,7 +29,10 @@
29
29
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
30
  "prepack": "npm run build && node ../../scripts/verify-package-contract.mjs kibi-mcp"
31
31
  },
32
- "files": ["dist", "bin"],
32
+ "files": [
33
+ "dist",
34
+ "bin"
35
+ ],
33
36
  "engines": {
34
37
  "node": ">=22"
35
38
  },