kibi-mcp 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/kibi-mcp CHANGED
@@ -16,9 +16,36 @@
16
16
  You should have received a copy of the GNU Affero General Public License
17
17
  along with this program. If not, see <https://www.gnu.org/licenses/>.
18
18
  */
19
- import { startServer } from "../dist/server.js";
19
+ const args = globalThis.Bun?.argv?.slice(2) ?? process.argv.slice(2);
20
20
 
21
- if (process.env.KIBI_MCP_DEBUG) {
21
+ function printHelp() {
22
+ process.stdout.write(`Usage: kibi-mcp [options]
23
+
24
+ Starts the Kibi MCP server over stdio.
25
+
26
+ Options:
27
+ --diagnostic-mode enable diagnostic logging to .kb/usage.log
28
+ -h, --help show help
29
+ `);
30
+ }
31
+
32
+ if (args.includes("-h") || args.includes("--help")) {
33
+ printHelp();
34
+ process.exitCode = 0;
35
+ } else {
36
+ if (args.includes("--diagnostic-mode") && !process.argv.includes("--diagnostic-mode")) {
37
+ process.argv.push("--diagnostic-mode");
38
+ }
39
+
40
+ if (args.includes("--diagnostic-mode")) {
41
+ globalThis.__KIBI_MCP_DIAGNOSTIC_MODE = true;
42
+ process.env.KIBI_WORKSPACE = process.cwd();
43
+ process.env.KIBI_MCP_DIAGNOSTIC_MODE = "1";
44
+ }
45
+
46
+ const { startServer } = await import("../dist/server.js");
47
+
48
+ if (process.env.KIBI_MCP_DEBUG) {
22
49
  const originalStdoutWrite = process.stdout.write.bind(process.stdout);
23
50
  const originalStderrWrite = process.stderr.write.bind(process.stderr);
24
51
 
@@ -36,24 +63,25 @@ if (process.env.KIBI_MCP_DEBUG) {
36
63
  originalStderrWrite(`[KIBI-MCP-IN] ${str}\n`);
37
64
  }
38
65
  });
39
- }
66
+ }
40
67
 
41
- process.on('unhandledRejection', (reason, promise) => {
68
+ process.on('unhandledRejection', (reason, promise) => {
42
69
  console.error('[KIBI-MCP] Unhandled rejection at promise:', promise);
43
70
  console.error('[KIBI-MCP] Reason:', reason);
44
71
  if (reason instanceof Error) {
45
72
  console.error('[KIBI-MCP] Stack:', reason.stack);
46
73
  }
47
74
  process.exit(1);
48
- });
75
+ });
49
76
 
50
- process.on('uncaughtException', (error) => {
77
+ process.on('uncaughtException', (error) => {
51
78
  console.error('[KIBI-MCP] Uncaught exception:', error.message);
52
79
  console.error('[KIBI-MCP] Stack:', error.stack);
53
80
  process.exit(1);
54
- });
81
+ });
55
82
 
56
- startServer().catch((error) => {
57
- console.error("Fatal error:", error);
58
- process.exit(1);
59
- });
83
+ startServer().catch((error) => {
84
+ console.error("Fatal error:", error);
85
+ process.exit(1);
86
+ });
87
+ }
@@ -33,19 +33,24 @@ let diagnosticUsageLogPath = null;
33
33
  * Initialize diagnostic mode: set up usage.log path.
34
34
  * Called once during server startup.
35
35
  */
36
- export function initializeDiagnosticMode() {
37
- if (DIAGNOSTIC_MODE_ENABLED) {
38
- const workspaceRoot = resolveWorkspaceRoot();
39
- diagnosticUsageLogPath = path.join(workspaceRoot, ".kb", "usage.log");
40
- process.env.KIBI_MCP_DIAGNOSTIC_MODE = "1";
36
+ // implements REQ-008
37
+ export function initializeDiagnosticMode(enabled = DIAGNOSTIC_MODE_ENABLED) {
38
+ diagnosticUsageLogPath = null;
39
+ if (!enabled) {
40
+ Reflect.deleteProperty(process.env, "KIBI_MCP_DIAGNOSTIC_MODE");
41
+ return;
41
42
  }
43
+ const workspaceRoot = resolveWorkspaceRoot();
44
+ diagnosticUsageLogPath = path.join(workspaceRoot, ".kb", "usage.log");
45
+ process.env.KIBI_MCP_DIAGNOSTIC_MODE = "1";
42
46
  }
43
47
  /**
44
48
  * Append a JSON line to the usage.log file.
45
49
  * No-op if diagnostic mode is not enabled.
46
50
  */
51
+ // implements REQ-008
47
52
  export function appendUsageLogLine(entry) {
48
- if (!DIAGNOSTIC_MODE_ENABLED || !diagnosticUsageLogPath) {
53
+ if (!diagnosticUsageLogPath) {
49
54
  return;
50
55
  }
51
56
  const logDir = path.dirname(diagnosticUsageLogPath);
package/dist/env.js CHANGED
@@ -18,8 +18,38 @@
18
18
  import fs from "node:fs";
19
19
  import { resolveEnvFilePath, resolveWorkspaceRoot } from "./workspace.js";
20
20
  const DEFAULT_ENV_FILE = ".env";
21
+ function getEnvValue(key) {
22
+ const value = process.env[key];
23
+ return typeof value === "string" ? value : undefined;
24
+ }
25
+ function getTrimmedEnvValue(key) {
26
+ const value = getEnvValue(key)?.trim();
27
+ return value ? value : undefined;
28
+ }
29
+ export function getEnvFileName() {
30
+ // implements REQ-002
31
+ return getTrimmedEnvValue("KIBI_ENV_FILE") ?? DEFAULT_ENV_FILE;
32
+ }
33
+ export function isMcpDebugEnabled() {
34
+ // implements REQ-002
35
+ return Boolean(getEnvValue("KIBI_MCP_DEBUG"));
36
+ }
37
+ export function getBranchOverride() {
38
+ // implements REQ-002
39
+ return getTrimmedEnvValue("KIBI_BRANCH");
40
+ }
41
+ export function getKbPlPathOverride() {
42
+ // implements REQ-002
43
+ return getEnvValue("KIBI_KB_PL_PATH");
44
+ }
45
+ export function getCoreModulePathOverride(fileName) {
46
+ // implements REQ-002
47
+ const envKey = `KIBI_${fileName.replace(/\W/g, "_").toUpperCase()}_PATH`;
48
+ return getEnvValue(envKey);
49
+ }
21
50
  export function loadDefaultEnvFile() {
22
- const envFileName = process.env.KIBI_ENV_FILE ?? DEFAULT_ENV_FILE;
51
+ // implements REQ-002
52
+ const envFileName = getEnvFileName();
23
53
  const workspaceRoot = resolveWorkspaceRoot();
24
54
  return loadEnvFile({ envFileName, workspaceRoot });
25
55
  }
@@ -16,6 +16,9 @@
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
+ // INTENTIONAL: TOOLS is imported as a Zod-inferred schema type; ToolConfig is the runtime
20
+ // interface with looser Record<string, unknown> inputSchema. The cast is safe because the
21
+ // tool definitions are statically authored and validated at startup.
19
22
  const ACTIVE_TOOLS = TOOLS;
20
23
  function renderToolsDoc() {
21
24
  const lines = [
@@ -20,7 +20,20 @@ import { createRequire } from "node:module";
20
20
  import process from "node:process";
21
21
  import { PrologProcess } from "kibi-cli/prolog";
22
22
  import { copyCleanSnapshot, getBranchDiagnostic, isValidBranchName, resolveActiveBranch, } from "kibi-cli/public/branch-resolver";
23
+ import { getBranchOverride, isMcpDebugEnabled } from "../env.js";
23
24
  import { resolveKbPath, resolveWorkspaceRoot } from "../workspace.js";
25
+ const defaultSessionDeps = {
26
+ PrologProcess,
27
+ copyCleanSnapshot,
28
+ createRequire,
29
+ fs,
30
+ getBranchDiagnostic,
31
+ isValidBranchName,
32
+ resolveActiveBranch,
33
+ resolveKbPath,
34
+ resolveWorkspaceRoot,
35
+ };
36
+ let sessionDeps = { ...defaultSessionDeps };
24
37
  export let prologProcess = null;
25
38
  let isInitialized = false;
26
39
  export let activeBranchName = "develop";
@@ -28,32 +41,55 @@ let ensurePrologTail = Promise.resolve();
28
41
  export let isShuttingDown = false;
29
42
  let shutdownTimeout = null;
30
43
  export const inFlightRequests = new Map();
44
+ // implements REQ-008
45
+ export function resetSessionStateForTests() {
46
+ prologProcess = null;
47
+ isInitialized = false;
48
+ activeBranchName = "develop";
49
+ ensurePrologTail = Promise.resolve();
50
+ isShuttingDown = false;
51
+ inFlightRequests.clear();
52
+ if (shutdownTimeout) {
53
+ clearTimeout(shutdownTimeout);
54
+ shutdownTimeout = null;
55
+ }
56
+ }
57
+ export function _setSessionDepsForTests(
58
+ // implements REQ-008
59
+ overrides) {
60
+ sessionDeps = { ...sessionDeps, ...overrides };
61
+ }
62
+ export function _resetSessionDepsForTests() {
63
+ // implements REQ-008
64
+ sessionDeps = { ...defaultSessionDeps };
65
+ }
31
66
  function debugLog(...args) {
32
- if (process.env.KIBI_MCP_DEBUG) {
67
+ if (isMcpDebugEnabled()) {
33
68
  console.error(...args);
34
69
  }
35
70
  }
36
71
  export function ensureBranchKbExists(workspaceRoot, branch) {
37
- if (!isValidBranchName(branch)) {
72
+ // implements REQ-008
73
+ if (!sessionDeps.isValidBranchName(branch)) {
38
74
  throw new Error(`Invalid branch name: ${branch}`);
39
75
  }
40
- const branchPath = resolveKbPath(workspaceRoot, branch);
41
- if (fs.existsSync(branchPath)) {
76
+ const branchPath = sessionDeps.resolveKbPath(workspaceRoot, branch);
77
+ if (sessionDeps.fs.existsSync(branchPath)) {
42
78
  return;
43
79
  }
44
80
  // Try to copy from the previously active branch if available
45
81
  const previousBranch = activeBranchName;
46
- const previousBranchPath = resolveKbPath(workspaceRoot, previousBranch);
82
+ const previousBranchPath = sessionDeps.resolveKbPath(workspaceRoot, previousBranch);
47
83
  if (previousBranch !== branch &&
48
84
  previousBranch !== "develop" &&
49
- fs.existsSync(previousBranchPath)) {
85
+ sessionDeps.fs.existsSync(previousBranchPath)) {
50
86
  // Copy from previous branch for continuity
51
- copyCleanSnapshot(previousBranchPath, branchPath);
87
+ sessionDeps.copyCleanSnapshot(previousBranchPath, branchPath);
52
88
  debugLog(`[KIBI-MCP] Created branch KB for '${branch}' from '${previousBranch}'`);
53
89
  return;
54
90
  }
55
91
  // No previous branch available - create empty KB
56
- fs.mkdirSync(branchPath, { recursive: true });
92
+ sessionDeps.fs.mkdirSync(branchPath, { recursive: true });
57
93
  debugLog(`[KIBI-MCP] Created empty branch KB for '${branch}'`);
58
94
  }
59
95
  export async function initiateGracefulShutdown(exitCode = 0) {
@@ -103,22 +139,22 @@ export async function initiateGracefulShutdown(exitCode = 0) {
103
139
  }
104
140
  // implements REQ-008
105
141
  async function ensurePrologUnsafe() {
106
- const workspaceRoot = resolveWorkspaceRoot();
142
+ const workspaceRoot = sessionDeps.resolveWorkspaceRoot();
107
143
  // Determine target branch: respect KIBI_BRANCH override or resolve from git
108
- const envBranch = process.env.KIBI_BRANCH?.trim();
144
+ const envBranch = getBranchOverride();
109
145
  let targetBranch;
110
146
  if (envBranch) {
111
147
  // KIBI_BRANCH override is set - use it without re-resolving from git
112
- if (!isValidBranchName(envBranch)) {
148
+ if (!sessionDeps.isValidBranchName(envBranch)) {
113
149
  throw new Error(`Invalid branch name from KIBI_BRANCH: '${envBranch}'`);
114
150
  }
115
151
  targetBranch = envBranch;
116
152
  }
117
153
  else {
118
154
  // No override - resolve active branch from git (may change between requests)
119
- const branchResult = resolveActiveBranch(workspaceRoot);
155
+ const branchResult = sessionDeps.resolveActiveBranch(workspaceRoot);
120
156
  if ("error" in branchResult) {
121
- const diagnostic = getBranchDiagnostic(undefined, branchResult.error);
157
+ const diagnostic = sessionDeps.getBranchDiagnostic(undefined, branchResult.error);
122
158
  console.error(`[KIBI-MCP] ${diagnostic}`);
123
159
  throw new Error(`Failed to resolve active branch: ${branchResult.error}`);
124
160
  }
@@ -144,7 +180,7 @@ async function ensurePrologUnsafe() {
144
180
  }
145
181
  // Ensure new branch KB exists
146
182
  ensureBranchKbExists(workspaceRoot, targetBranch);
147
- const newKbPath = resolveKbPath(workspaceRoot, targetBranch);
183
+ const newKbPath = sessionDeps.resolveKbPath(workspaceRoot, targetBranch);
148
184
  // Attach to new branch KB
149
185
  const attachResult = await prologProcess.query(`kb_attach('${newKbPath}')`);
150
186
  if (!attachResult.success) {
@@ -157,13 +193,13 @@ async function ensurePrologUnsafe() {
157
193
  }
158
194
  // First initialization
159
195
  debugLog("[KIBI-MCP] Initializing Prolog process...");
160
- prologProcess = new PrologProcess({ timeout: 120000 });
196
+ prologProcess = new sessionDeps.PrologProcess({ timeout: 120000 });
161
197
  await prologProcess.start();
162
198
  // Startup debug: resolve which kibi-cli is being used and its version (best-effort).
163
199
  // Gate all output under KIBI_MCP_DEBUG and write only to stderr via debugLog.
164
- if (process.env.KIBI_MCP_DEBUG) {
200
+ if (isMcpDebugEnabled()) {
165
201
  try {
166
- const req = createRequire(import.meta.url);
202
+ const req = sessionDeps.createRequire(import.meta.url);
167
203
  try {
168
204
  const resolved = req.resolve("kibi-cli/prolog");
169
205
  debugLog(`[KIBI-MCP] require.resolve('kibi-cli/prolog') -> ${resolved}`);
@@ -193,11 +229,11 @@ async function ensurePrologUnsafe() {
193
229
  }
194
230
  }
195
231
  debugLog("[KIBI-MCP] Branch selection:");
196
- debugLog(`[KIBI-MCP] KIBI_BRANCH env: ${process.env.KIBI_BRANCH || "not set"}`);
232
+ debugLog(`[KIBI-MCP] KIBI_BRANCH env: ${envBranch || "not set"}`);
197
233
  debugLog(`[KIBI-MCP] Resolved branch: ${targetBranch}`);
198
234
  activeBranchName = targetBranch;
199
235
  ensureBranchKbExists(workspaceRoot, targetBranch);
200
- const kbPath = resolveKbPath(workspaceRoot, targetBranch);
236
+ const kbPath = sessionDeps.resolveKbPath(workspaceRoot, targetBranch);
201
237
  const attachResult = await prologProcess.query(`kb_attach('${kbPath}')`);
202
238
  if (!attachResult.success) {
203
239
  throw new Error(`Failed to attach KB: ${attachResult.error || "Unknown error"}`);
@@ -1,23 +1,6 @@
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
1
  import { z } from "zod";
20
2
  import { DIAGNOSTIC_MODE_ENABLED, appendUsageLogLine, deriveDiagnosticFields, extractToolCallPayload, } from "../diagnostics.js";
3
+ import { isMcpDebugEnabled } from "../env.js";
21
4
  import { TOOLS } from "../tools-config.js";
22
5
  import { handleKbCheck } from "../tools/check.js";
23
6
  import { handleKbCoverage } from "../tools/coverage.js";
@@ -28,13 +11,60 @@ import { handleKbQuery } from "../tools/query.js";
28
11
  import { handleKbSearch } from "../tools/search.js";
29
12
  import { handleKbStatus } from "../tools/status.js";
30
13
  import { handleKbUpsert } from "../tools/upsert.js";
31
- import { activeBranchName, ensureProlog, inFlightRequests, isShuttingDown, prologProcess, } from "./session.js";
32
- const ACTIVE_TOOLS = TOOLS;
14
+ const defaultToolsServerDeps = {
15
+ getSessionModule: () => import("./session.js"),
16
+ };
17
+ // implements REQ-008
18
+ export function _setToolsServerDepsForTests(deps, resetPromise = false) {
19
+ defaultToolsServerDeps.getSessionModule =
20
+ deps.getSessionModule ?? defaultToolsServerDeps.getSessionModule;
21
+ if (resetPromise) {
22
+ sessionModulePromise = null;
23
+ }
24
+ }
25
+ // implements REQ-012
26
+ export function _resetSessionModulePromise() {
27
+ sessionModulePromise = null;
28
+ }
29
+ let sessionModulePromise = null;
30
+ /* v8 ignore next (3 lines) — lazy async module loader; body only executes once per process
31
+ * when DEFAULT_TOOLS_RUNTIME.activeBranchName/ensureProlog/etc. are first called.
32
+ * Cannot be re-triggered without process restart (sessionModulePromise is module-level). */
33
+ async function getSessionModule() {
34
+ sessionModulePromise ??= defaultToolsServerDeps.getSessionModule();
35
+ return sessionModulePromise;
36
+ }
37
+ const DEFAULT_TOOLS_RUNTIME = {
38
+ diagnosticModeEnabled: () => DIAGNOSTIC_MODE_ENABLED,
39
+ appendUsageLogLine,
40
+ deriveDiagnosticFields,
41
+ extractToolCallPayload,
42
+ // INTENTIONAL: TOOLS is imported as a Zod-inferred schema type; ToolConfig is the
43
+ // runtime interface with looser Record<string, unknown> inputSchema. The cast is safe
44
+ // because the tool definitions are statically authored and validated at startup.
45
+ tools: TOOLS,
46
+ activeBranchName: async () => (await getSessionModule()).activeBranchName,
47
+ ensureProlog: async () => (await getSessionModule()).ensureProlog(),
48
+ inFlightRequests: async () => (await getSessionModule()).inFlightRequests,
49
+ isShuttingDown: async () => (await getSessionModule()).isShuttingDown,
50
+ prologProcess: async () => (await getSessionModule()).prologProcess,
51
+ handleKbCheck,
52
+ handleKbCoverage,
53
+ handleKbDelete,
54
+ handleKbFindGaps,
55
+ handleKbGraph,
56
+ handleKbQuery,
57
+ handleKbSearch,
58
+ handleKbStatus,
59
+ handleKbUpsert,
60
+ };
61
+ // implements REQ-008
33
62
  function debugLog(...args) {
34
- if (process.env.KIBI_MCP_DEBUG) {
63
+ if (isMcpDebugEnabled()) {
35
64
  console.error(...args);
36
65
  }
37
66
  }
67
+ // implements REQ-002
38
68
  export function jsonSchemaToZod(schema) {
39
69
  if (!schema || typeof schema !== "object") {
40
70
  return z.any();
@@ -52,6 +82,9 @@ export function jsonSchemaToZod(schema) {
52
82
  const literalSchemas = literals.map((value) => z.literal(value));
53
83
  if (literalSchemas.length === 1) {
54
84
  const single = literalSchemas[0];
85
+ if (!single) {
86
+ return description ? z.any().describe(description) : z.any();
87
+ }
55
88
  return description ? single.describe(description) : single;
56
89
  }
57
90
  const union = z.union(literalSchemas);
@@ -71,10 +104,9 @@ export function jsonSchemaToZod(schema) {
71
104
  const propSchema = jsonSchemaToZod(value);
72
105
  shape[key] = required.has(key) ? propSchema : propSchema.optional();
73
106
  }
74
- let objectSchema = z.object(shape);
75
- if (obj.additionalProperties !== false) {
76
- objectSchema = objectSchema.passthrough();
77
- }
107
+ const objectSchema = obj.additionalProperties === false
108
+ ? z.object(shape)
109
+ : z.looseObject(shape);
78
110
  const description = typeof obj.description === "string" ? obj.description : undefined;
79
111
  return description ? objectSchema.describe(description) : objectSchema;
80
112
  }
@@ -135,12 +167,17 @@ export function jsonSchemaToZod(schema) {
135
167
  }
136
168
  }
137
169
  }
138
- function addTool(server, name, description, inputSchema, handler) {
170
+ // implements REQ-002
171
+ export function addTool(server, name, description, inputSchema, handler,
172
+ // INTENTIONAL: DEFAULT_TOOLS_RUNTIME is typed as ToolsRuntime<PrologProcess>; the
173
+ // generic TProlog parameter exists so tests can inject a mock type. The cast is safe
174
+ // because the runtime object satisfies the full ToolsRuntime contract at runtime.
175
+ runtime = DEFAULT_TOOLS_RUNTIME) {
139
176
  const wrappedHandler = async (args) => {
140
177
  const startedAt = new Date();
141
- // Extract telemetry in diagnostic mode
142
- const { businessArgs, telemetry } = DIAGNOSTIC_MODE_ENABLED
143
- ? extractToolCallPayload(args)
178
+ const diagnosticModeEnabled = runtime.diagnosticModeEnabled();
179
+ const { businessArgs, telemetry } = diagnosticModeEnabled
180
+ ? runtime.extractToolCallPayload(args)
144
181
  : { businessArgs: args, telemetry: null };
145
182
  try {
146
183
  // Validate that args is a valid object
@@ -148,7 +185,7 @@ function addTool(server, name, description, inputSchema, handler) {
148
185
  throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
149
186
  }
150
187
  // Check if shutting down before processing
151
- if (isShuttingDown) {
188
+ if (await runtime.isShuttingDown()) {
152
189
  throw new Error(`Tool ${name} rejected: server is shutting down`);
153
190
  }
154
191
  // Extract or generate requestId from args
@@ -157,20 +194,23 @@ function addTool(server, name, description, inputSchema, handler) {
157
194
  ? requestIdArg
158
195
  : `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
159
196
  // Log tool call for debugging (to stderr to avoid breaking stdio protocol)
160
- if (process.env.KIBI_MCP_DEBUG) {
197
+ if (isMcpDebugEnabled()) {
161
198
  console.error(`[KIBI-MCP] Tool called: ${name} (requestId: ${requestId}) with args:`, JSON.stringify(businessArgs));
162
199
  }
163
200
  // Track the handler promise in inFlightRequests Map
201
+ const trackedRequests = await runtime.inFlightRequests();
164
202
  const handlerPromise = handler(businessArgs);
165
- inFlightRequests.set(requestId, handlerPromise);
203
+ trackedRequests.set(requestId, handlerPromise);
166
204
  try {
167
205
  // Execute handler
168
206
  const result = await handlerPromise;
169
207
  // Log usage in diagnostic mode
170
- if (DIAGNOSTIC_MODE_ENABLED) {
208
+ if (diagnosticModeEnabled) {
171
209
  const finishedAt = new Date();
172
- const diagnosticFields = deriveDiagnosticFields(name, businessArgs, telemetry, result);
173
- appendUsageLogLine({
210
+ const diagnosticFields = runtime.deriveDiagnosticFields(name, businessArgs, telemetry, result);
211
+ const processHandle = await runtime.prologProcess();
212
+ const branchName = await runtime.activeBranchName();
213
+ runtime.appendUsageLogLine({
174
214
  timestamp: finishedAt.toISOString(),
175
215
  request_id: requestId,
176
216
  tool: name,
@@ -180,8 +220,8 @@ function addTool(server, name, description, inputSchema, handler) {
180
220
  started_at: startedAt.toISOString(),
181
221
  finished_at: finishedAt.toISOString(),
182
222
  duration_ms: finishedAt.getTime() - startedAt.getTime(),
183
- prolog_pid: prologProcess?.getPid() ?? null,
184
- active_branch: activeBranchName,
223
+ prolog_pid: processHandle?.getPid() ?? null,
224
+ active_branch: branchName,
185
225
  ...diagnosticFields,
186
226
  });
187
227
  }
@@ -189,10 +229,12 @@ function addTool(server, name, description, inputSchema, handler) {
189
229
  }
190
230
  catch (error) {
191
231
  // Log error in diagnostic mode
192
- if (DIAGNOSTIC_MODE_ENABLED) {
232
+ if (diagnosticModeEnabled) {
193
233
  const finishedAt = new Date();
194
234
  const err = error instanceof Error ? error : new Error(String(error));
195
- appendUsageLogLine({
235
+ const processHandle = await runtime.prologProcess();
236
+ const branchName = await runtime.activeBranchName();
237
+ runtime.appendUsageLogLine({
196
238
  timestamp: finishedAt.toISOString(),
197
239
  request_id: requestId,
198
240
  tool: name,
@@ -202,8 +244,8 @@ function addTool(server, name, description, inputSchema, handler) {
202
244
  started_at: startedAt.toISOString(),
203
245
  finished_at: finishedAt.toISOString(),
204
246
  duration_ms: finishedAt.getTime() - startedAt.getTime(),
205
- prolog_pid: prologProcess?.getPid() ?? null,
206
- active_branch: activeBranchName,
247
+ prolog_pid: processHandle?.getPid() ?? null,
248
+ active_branch: branchName,
207
249
  error_message: err.message,
208
250
  });
209
251
  }
@@ -211,7 +253,7 @@ function addTool(server, name, description, inputSchema, handler) {
211
253
  }
212
254
  finally {
213
255
  // Always clean up from Map when done (success or failure)
214
- inFlightRequests.delete(requestId);
256
+ trackedRequests.delete(requestId);
215
257
  }
216
258
  }
217
259
  catch (error) {
@@ -226,47 +268,53 @@ function addTool(server, name, description, inputSchema, handler) {
226
268
  server.registerTool(name, { description, inputSchema: jsonSchemaToZod(inputSchema) }, wrappedHandler);
227
269
  }
228
270
  // implements REQ-002, REQ-013
229
- export function registerAllTools(server) {
271
+ export function registerAllTools(server,
272
+ // INTENTIONAL: same generic bridge cast as addTool — see comment there.
273
+ runtime = DEFAULT_TOOLS_RUNTIME) {
230
274
  const toolDef = (name) => {
231
- const t = ACTIVE_TOOLS.find((t) => t.name === name);
275
+ const t = runtime.tools.find((tool) => tool.name === name);
232
276
  if (!t)
233
277
  throw new Error(`Unknown tool: ${name}`);
234
278
  return t;
235
279
  };
280
+ // INTENTIONAL ARGUMENT CASTS: The `args as (unknown as)? XyzArgs` casts below
281
+ // bridge the generic ToolHandler (which receives Record<string, unknown>) to the
282
+ // specific handler argument types. Argument shapes are validated by Zod schemas
283
+ // (via jsonSchemaToZod) before the handler is invoked, so the casts are safe at runtime.
236
284
  addTool(server, "kb_query", toolDef("kb_query").description, toolDef("kb_query").inputSchema, async (args) => {
237
- const prolog = await ensureProlog();
238
- return handleKbQuery(prolog, args);
239
- });
285
+ const prolog = await runtime.ensureProlog();
286
+ return runtime.handleKbQuery(prolog, args);
287
+ }, runtime);
240
288
  addTool(server, "kb_search", toolDef("kb_search").description, toolDef("kb_search").inputSchema, async (args) => {
241
- const prolog = await ensureProlog();
242
- return handleKbSearch(prolog, args);
243
- });
289
+ const prolog = await runtime.ensureProlog();
290
+ return runtime.handleKbSearch(prolog, args);
291
+ }, runtime);
244
292
  addTool(server, "kb_status", toolDef("kb_status").description, toolDef("kb_status").inputSchema, async (args) => {
245
- const prolog = await ensureProlog();
246
- return handleKbStatus(prolog, args);
247
- });
293
+ const prolog = await runtime.ensureProlog();
294
+ return runtime.handleKbStatus(prolog, args);
295
+ }, runtime);
248
296
  addTool(server, "kb_find_gaps", toolDef("kb_find_gaps").description, toolDef("kb_find_gaps").inputSchema, async (args) => {
249
- const prolog = await ensureProlog();
250
- return handleKbFindGaps(prolog, args);
251
- });
297
+ const prolog = await runtime.ensureProlog();
298
+ return runtime.handleKbFindGaps(prolog, args);
299
+ }, runtime);
252
300
  addTool(server, "kb_coverage", toolDef("kb_coverage").description, toolDef("kb_coverage").inputSchema, async (args) => {
253
- const prolog = await ensureProlog();
254
- return handleKbCoverage(prolog, args);
255
- });
301
+ const prolog = await runtime.ensureProlog();
302
+ return runtime.handleKbCoverage(prolog, args);
303
+ }, runtime);
256
304
  addTool(server, "kb_graph", toolDef("kb_graph").description, toolDef("kb_graph").inputSchema, async (args) => {
257
- const prolog = await ensureProlog();
258
- return handleKbGraph(prolog, args);
259
- });
305
+ const prolog = await runtime.ensureProlog();
306
+ return runtime.handleKbGraph(prolog, args);
307
+ }, runtime);
260
308
  addTool(server, "kb_upsert", toolDef("kb_upsert").description, toolDef("kb_upsert").inputSchema, async (args) => {
261
- const prolog = await ensureProlog();
262
- return handleKbUpsert(prolog, args);
263
- });
309
+ const prolog = await runtime.ensureProlog();
310
+ return runtime.handleKbUpsert(prolog, args);
311
+ }, runtime);
264
312
  addTool(server, "kb_delete", toolDef("kb_delete").description, toolDef("kb_delete").inputSchema, async (args) => {
265
- const prolog = await ensureProlog();
266
- return handleKbDelete(prolog, args);
267
- });
313
+ const prolog = await runtime.ensureProlog();
314
+ return runtime.handleKbDelete(prolog, args);
315
+ }, runtime);
268
316
  addTool(server, "kb_check", toolDef("kb_check").description, toolDef("kb_check").inputSchema, async (args) => {
269
- const prolog = await ensureProlog();
270
- return handleKbCheck(prolog, args);
271
- });
317
+ const prolog = await runtime.ensureProlog();
318
+ return runtime.handleKbCheck(prolog, args);
319
+ }, runtime);
272
320
  }
@@ -16,9 +16,10 @@
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 { isMcpDebugEnabled } from "../env.js";
19
20
  import { initiateGracefulShutdown } from "./session.js";
20
21
  function debugLog(...args) {
21
- if (process.env.KIBI_MCP_DEBUG) {
22
+ if (isMcpDebugEnabled()) {
22
23
  console.error(...args);
23
24
  }
24
25
  }
@@ -15,7 +15,7 @@
15
15
  You should have received a copy of the GNU Affero General Public License
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
- import { existsSync, readFileSync } from "node:fs";
18
+ import { readFile } from "node:fs/promises";
19
19
  import * as path from "node:path";
20
20
  import { DEFAULT_CHECKS_CONFIG, RULE_NAMES, } from "kibi-cli/public/check-types";
21
21
  import { resolveWorkspaceRoot } from "../workspace.js";
@@ -25,8 +25,8 @@ function formatDiagnosticsForMcp(diagnostics) {
25
25
  category: d.category,
26
26
  severity: d.severity,
27
27
  message: d.message,
28
- file: d.file,
29
- suggestion: d.suggestion,
28
+ ...(d.file !== undefined ? { file: d.file } : {}),
29
+ ...(d.suggestion !== undefined ? { suggestion: d.suggestion } : {}),
30
30
  }));
31
31
  }
32
32
  function formatViolationText(violations) {
@@ -48,13 +48,10 @@ function formatViolationText(violations) {
48
48
  return `${violations.length} violations found\n${details.join("\n")}`;
49
49
  }
50
50
  // implements REQ-002
51
- function loadChecksConfig(workspaceRoot) {
51
+ async function loadChecksConfig(workspaceRoot) {
52
52
  const configPath = path.join(workspaceRoot, ".kb", "config.json");
53
- if (!existsSync(configPath)) {
54
- return DEFAULT_CHECKS_CONFIG;
55
- }
56
53
  try {
57
- const content = readFileSync(configPath, "utf8");
54
+ const content = await readFile(configPath, "utf8");
58
55
  const parsed = JSON.parse(content);
59
56
  const parsedRules = parsed.checks?.rules;
60
57
  const normalizedRules = {
@@ -104,10 +101,10 @@ function getEffectiveRules(configRules, requestedRules) {
104
101
  */
105
102
  // implements REQ-002
106
103
  export async function handleKbCheck(prolog, args) {
107
- const { rules } = args;
104
+ const { rules, workspaceRoot: workspaceOverride } = args;
108
105
  try {
109
- const workspaceRoot = resolveWorkspaceRoot();
110
- const checksConfig = loadChecksConfig(workspaceRoot);
106
+ const workspaceRoot = workspaceOverride ?? resolveWorkspaceRoot();
107
+ const checksConfig = await loadChecksConfig(workspaceRoot);
111
108
  const rulesAllowlist = getEffectiveRules(checksConfig.rules, rules);
112
109
  if (rulesAllowlist.size === 0) {
113
110
  return {
@@ -126,8 +123,8 @@ export async function handleKbCheck(prolog, args) {
126
123
  category: "SYNC_ERROR",
127
124
  severity: "error",
128
125
  message: v.description,
129
- file: v.source,
130
- suggestion: v.suggestion,
126
+ ...(v.source !== undefined ? { file: v.source } : {}),
127
+ ...(v.suggestion !== undefined ? { suggestion: v.suggestion } : {}),
131
128
  }));
132
129
  const summary = formatViolationText(aggregatedViolations);
133
130
  return {
@@ -189,8 +186,8 @@ async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
189
186
  rule: v.rule,
190
187
  entityId: v.entityId,
191
188
  description: v.description,
192
- suggestion: v.suggestion || undefined,
193
- source: v.source || undefined,
189
+ ...(v.suggestion ? { suggestion: v.suggestion } : {}),
190
+ ...(v.source ? { source: v.source } : {}),
194
191
  });
195
192
  }
196
193
  }
@@ -1,20 +1,21 @@
1
- import { existsSync } from "node:fs";
2
1
  import path from "node:path";
3
2
  import { PrologProcess, resolveKbPlPath } from "kibi-cli/prolog";
4
3
  import { escapeAtomContent } from "kibi-cli/prolog/codec";
4
+ import { getCoreModulePathOverride, getKbPlPathOverride } from "../env.js";
5
5
  // implements REQ-002, REQ-013
6
6
  export function resolveCorePlPath(fileName) {
7
- const envKey = `KIBI_${fileName.replace(/\W/g, "_").toUpperCase()}_PATH`;
8
- const override = process.env[envKey];
9
- if (override && existsSync(override)) {
7
+ const override = getCoreModulePathOverride(fileName);
8
+ if (override) {
10
9
  return override;
11
10
  }
12
- const kbPlPath = resolveKbPlPath();
13
- const sibling = path.join(path.dirname(kbPlPath), fileName);
14
- if (existsSync(sibling)) {
15
- return sibling;
11
+ // Fall back to the generic KB_PL override so test fixtures
12
+ // (which set only KIBI_KB_PL_PATH) can still resolve sibling modules.
13
+ const genericOverride = getKbPlPathOverride();
14
+ if (genericOverride) {
15
+ return path.join(path.dirname(genericOverride), fileName);
16
16
  }
17
- throw new Error(`Root-consistency error: resolveKbPlPath() resolved to '${kbPlPath}' but sibling '${fileName}' not found at '${sibling}'`);
17
+ const kbPlPath = resolveKbPlPath();
18
+ return path.join(path.dirname(kbPlPath), fileName);
18
19
  }
19
20
  // implements REQ-002, REQ-013
20
21
  export async function runJsonModuleQuery(prolog, fileName, goal, errorLabel) {
@@ -34,6 +35,11 @@ export async function runJsonModuleQuery(prolog, fileName, goal, errorLabel) {
34
35
  }
35
36
  return mockedParsed;
36
37
  }
38
+ // NOTE: useOneShotMode is an internal optimization flag on PrologProcess that
39
+ // forces single-query mode (start → query → terminate per call) instead of the
40
+ // default interactive session. It is not exposed in the public PrologProcess type
41
+ // because callers should not set it directly — only internal discovery helpers
42
+ // use it for lightweight one-shot queries that don't need session state.
37
43
  const oneShotCapable = prolog;
38
44
  prolog.invalidateCache();
39
45
  const result = oneShotCapable.useOneShotMode
@@ -18,7 +18,7 @@ export async function handleKbCoverage(prolog, args) {
18
18
  text: `Coverage summary: ${fullyCovered} fully covered out of ${total}.`,
19
19
  },
20
20
  ],
21
- structuredContent: payload,
21
+ ...(payload !== undefined ? { structuredContent: payload } : {}),
22
22
  };
23
23
  }
24
24
  catch (error) {
@@ -19,7 +19,7 @@ export async function handleKbFindGaps(prolog, args) {
19
19
  .join(", ")}`,
20
20
  },
21
21
  ],
22
- structuredContent: payload,
22
+ ...(payload !== undefined ? { structuredContent: payload } : {}),
23
23
  };
24
24
  }
25
25
  catch (error) {
@@ -17,7 +17,7 @@ export async function handleKbGraph(prolog, args) {
17
17
  : `Graph traversal returned ${nodes.length} nodes and ${(payload?.edges ?? []).length} edges from ${args.seedIds.join(", ")}.`,
18
18
  },
19
19
  ],
20
- structuredContent: payload,
20
+ ...(payload !== undefined ? { structuredContent: payload } : {}),
21
21
  };
22
22
  }
23
23
  catch (error) {
@@ -37,7 +37,11 @@ export function parsePairList(raw) {
37
37
  for (const row of rows) {
38
38
  const parts = splitTopLevel(row, ",").map((part) => stripQuotes(part.trim()));
39
39
  if (parts.length >= 2) {
40
- pairs.push([parts[0], parts[1]]);
40
+ const first = parts[0];
41
+ const second = parts[1];
42
+ if (first !== undefined && second !== undefined) {
43
+ pairs.push([first, second]);
44
+ }
41
45
  }
42
46
  }
43
47
  return pairs;
@@ -7,7 +7,13 @@ export { VALID_ENTITY_TYPES } from "./entity-query.js";
7
7
  export async function handleKbQuery(prolog, args) {
8
8
  const { type, id, tags, sourceFile, limit = 100, offset = 0 } = args;
9
9
  try {
10
- const results = await loadEntities(prolog, { type, id, tags, sourceFile });
10
+ const queryArgs = {
11
+ ...(type !== undefined ? { type } : {}),
12
+ ...(id !== undefined ? { id } : {}),
13
+ ...(tags !== undefined ? { tags } : {}),
14
+ ...(sourceFile !== undefined ? { sourceFile } : {}),
15
+ };
16
+ const results = await loadEntities(prolog, queryArgs);
11
17
  const paginated = paginateResults(results, limit, offset);
12
18
  // Build human-readable text with entity IDs and titles
13
19
  let text;
@@ -11,7 +11,9 @@ export async function handleKbSearch(prolog, args) {
11
11
  validateEntityType(type);
12
12
  try {
13
13
  const workspaceRoot = resolveWorkspaceRoot();
14
- const entities = await loadEntities(prolog, { type });
14
+ const entities = await loadEntities(prolog, {
15
+ ...(type !== undefined ? { type } : {}),
16
+ });
15
17
  const matches = await rankEntities(entities, trimmedQuery, workspaceRoot);
16
18
  const paginated = paginateResults(matches, limit, offset);
17
19
  const text = matches.length === 0
@@ -15,7 +15,7 @@
15
15
  You should have received a copy of the GNU Affero General Public License
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { access, readFile, writeFile } from "node:fs/promises";
19
19
  import path from "node:path";
20
20
  import { dump as dumpYAML, load as parseYAML } from "js-yaml";
21
21
  import { enrichSymbolCoordinates, } from "kibi-cli/extractors/symbols-coordinator";
@@ -47,9 +47,9 @@ const SOURCE_EXTENSIONS = new Set([
47
47
  export async function handleKbSymbolsRefresh(args) {
48
48
  // implements REQ-vscode-traceability
49
49
  const dryRun = args.dryRun === true;
50
- const workspaceRoot = resolveWorkspaceRoot();
51
- const manifestPath = resolveManifestPath(workspaceRoot);
52
- const rawContent = readFileSync(manifestPath, "utf8");
50
+ const workspaceRoot = args.workspaceRoot ?? resolveWorkspaceRoot();
51
+ const manifestPath = await resolveManifestPath(workspaceRoot);
52
+ const rawContent = await readFile(manifestPath, "utf8");
53
53
  const parsed = parseYAML(rawContent);
54
54
  if (!isRecord(parsed) || !Array.isArray(parsed.symbols)) {
55
55
  throw new Error(`Invalid symbols manifest at ${manifestPath}`);
@@ -63,13 +63,14 @@ export async function handleKbSymbolsRefresh(args) {
63
63
  title: typeof entry.title === "string" ? entry.title : "",
64
64
  }));
65
65
  const enriched = await enrichSymbolCoordinates(entriesForEnrichment, workspaceRoot);
66
- parsed.symbols = enriched;
66
+ const finalized = await Promise.all(enriched.map((entry, index) => fillMissingCoordinates(original[index] ?? {}, entry, workspaceRoot)));
67
+ parsed.symbols = finalized;
67
68
  let refreshed = 0;
68
69
  let failed = 0;
69
70
  let unchanged = 0;
70
71
  for (let i = 0; i < original.length; i++) {
71
72
  const before = original[i] ?? {};
72
- const after = enriched[i] ?? before;
73
+ const after = finalized[i] ?? before;
73
74
  const changed = GENERATED_COORD_FIELDS.some((field) => before[field] !== after[field]);
74
75
  if (changed) {
75
76
  refreshed++;
@@ -80,7 +81,7 @@ export async function handleKbSymbolsRefresh(args) {
80
81
  : typeof before.sourceFile === "string"
81
82
  ? before.sourceFile
82
83
  : undefined;
83
- const eligible = isEligible(source, workspaceRoot);
84
+ const eligible = await isEligible(source, workspaceRoot);
84
85
  if (eligible && !hasGeneratedCoordinates(after)) {
85
86
  failed++;
86
87
  }
@@ -95,7 +96,7 @@ export async function handleKbSymbolsRefresh(args) {
95
96
  });
96
97
  const nextContent = `${COMMENT_BLOCK}${dumped}`;
97
98
  if (!dryRun && rawContent !== nextContent) {
98
- writeFileSync(manifestPath, nextContent, "utf8");
99
+ await writeFile(manifestPath, nextContent, "utf8");
99
100
  }
100
101
  return {
101
102
  content: [
@@ -114,8 +115,8 @@ export async function handleKbSymbolsRefresh(args) {
114
115
  }
115
116
  export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = resolveWorkspaceRoot()) {
116
117
  // implements REQ-vscode-traceability
117
- const manifestPath = resolveManifestPath(workspaceRoot);
118
- const rawContent = readFileSync(manifestPath, "utf8");
118
+ const manifestPath = await resolveManifestPath(workspaceRoot);
119
+ const rawContent = await readFile(manifestPath, "utf8");
119
120
  const parsed = parseYAML(rawContent);
120
121
  if (!isRecord(parsed) || !Array.isArray(parsed.symbols)) {
121
122
  return { refreshed: false, found: false };
@@ -138,9 +139,10 @@ export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = re
138
139
  : "",
139
140
  };
140
141
  const [enriched] = await enrichSymbolCoordinates([singleEntry], workspaceRoot);
141
- symbols[index] = enriched ?? original;
142
+ const finalized = await fillMissingCoordinates(original, enriched ?? singleEntry, workspaceRoot);
143
+ symbols[index] = finalized;
142
144
  parsed.symbols = symbols;
143
- const refreshed = GENERATED_COORD_FIELDS.some((field) => original[field] !== symbols[index][field]);
145
+ const refreshed = GENERATED_COORD_FIELDS.some((field) => original[field] !== finalized[field]);
144
146
  const dumped = dumpYAML(parsed, {
145
147
  lineWidth: -1,
146
148
  noRefs: true,
@@ -148,38 +150,41 @@ export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = re
148
150
  });
149
151
  const nextContent = `${COMMENT_BLOCK}${dumped}`;
150
152
  if (rawContent !== nextContent) {
151
- writeFileSync(manifestPath, nextContent, "utf8");
153
+ await writeFile(manifestPath, nextContent, "utf8");
152
154
  }
153
155
  return { refreshed, found: true };
154
156
  }
155
- export function resolveManifestPath(workspaceRoot) {
157
+ export async function resolveManifestPath(workspaceRoot) {
156
158
  // implements REQ-002, REQ-013
157
159
  const configPath = path.join(workspaceRoot, ".kb", "config.json");
158
- if (existsSync(configPath)) {
159
- try {
160
- const config = JSON.parse(readFileSync(configPath, "utf8"));
161
- // Prefer paths.symbols (new standard) over symbolsManifest (legacy)
162
- if (config.paths?.symbols) {
163
- return path.isAbsolute(config.paths.symbols)
164
- ? config.paths.symbols
165
- : path.resolve(workspaceRoot, config.paths.symbols);
166
- }
167
- // Backward compatibility: check legacy symbolsManifest field
168
- if (config.symbolsManifest) {
169
- return path.isAbsolute(config.symbolsManifest)
170
- ? config.symbolsManifest
171
- : path.resolve(workspaceRoot, config.symbolsManifest);
172
- }
160
+ try {
161
+ const config = JSON.parse(await readFile(configPath, "utf8"));
162
+ // Prefer paths.symbols (new standard) over symbolsManifest (legacy)
163
+ if (config.paths?.symbols) {
164
+ return path.isAbsolute(config.paths.symbols)
165
+ ? config.paths.symbols
166
+ : path.resolve(workspaceRoot, config.paths.symbols);
173
167
  }
174
- catch {
175
- // config file missing or malformed; fall through to defaults
168
+ // Backward compatibility: check legacy symbolsManifest field
169
+ if (config.symbolsManifest) {
170
+ return path.isAbsolute(config.symbolsManifest)
171
+ ? config.symbolsManifest
172
+ : path.resolve(workspaceRoot, config.symbolsManifest);
176
173
  }
177
174
  }
175
+ catch {
176
+ // config file missing or malformed; fall through to defaults
177
+ }
178
178
  const candidates = [
179
179
  path.join(workspaceRoot, "symbols.yaml"),
180
180
  path.join(workspaceRoot, "symbols.yml"),
181
181
  ];
182
- return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
182
+ for (const candidate of candidates) {
183
+ if (await fileExists(candidate)) {
184
+ return candidate;
185
+ }
186
+ }
187
+ return candidates[0] ?? path.join(workspaceRoot, "symbols.yaml");
183
188
  }
184
189
  function hasGeneratedCoordinates(entry) {
185
190
  return (typeof entry.sourceLine === "number" &&
@@ -189,16 +194,72 @@ function hasGeneratedCoordinates(entry) {
189
194
  typeof entry.coordinatesGeneratedAt === "string" &&
190
195
  entry.coordinatesGeneratedAt.length > 0);
191
196
  }
192
- function isEligible(sourceFile, workspaceRoot) {
197
+ async function isEligible(sourceFile, workspaceRoot) {
193
198
  if (!sourceFile)
194
199
  return false;
195
200
  const absolute = path.isAbsolute(sourceFile)
196
201
  ? sourceFile
197
202
  : path.resolve(workspaceRoot, sourceFile);
198
- if (!existsSync(absolute))
203
+ if (!(await fileExists(absolute)))
199
204
  return false;
200
205
  return SOURCE_EXTENSIONS.has(path.extname(absolute).toLowerCase());
201
206
  }
207
+ async function fileExists(filePath) {
208
+ try {
209
+ await access(filePath);
210
+ return true;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
216
+ async function fillMissingCoordinates(before, after, workspaceRoot) {
217
+ if (hasGeneratedCoordinates(after)) {
218
+ return after;
219
+ }
220
+ const sourceFile = typeof after.sourceFile === "string"
221
+ ? after.sourceFile
222
+ : typeof before.sourceFile === "string"
223
+ ? before.sourceFile
224
+ : undefined;
225
+ const title = typeof after.title === "string"
226
+ ? after.title
227
+ : typeof before.title === "string"
228
+ ? before.title
229
+ : undefined;
230
+ if (!sourceFile || !title) {
231
+ return after;
232
+ }
233
+ const absolutePath = path.isAbsolute(sourceFile)
234
+ ? sourceFile
235
+ : path.resolve(workspaceRoot, sourceFile);
236
+ try {
237
+ const content = await readFile(absolutePath, "utf8");
238
+ const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+ const pattern = new RegExp(`\\b${escapedTitle}\\b`);
240
+ const lines = content.split(/\r?\n/);
241
+ for (let index = 0; index < lines.length; index++) {
242
+ const line = lines[index];
243
+ if (!line)
244
+ continue;
245
+ const match = pattern.exec(line);
246
+ if (!match || match.index < 0)
247
+ continue;
248
+ return {
249
+ ...after,
250
+ sourceLine: index + 1,
251
+ sourceColumn: match.index,
252
+ sourceEndLine: index + 1,
253
+ sourceEndColumn: match.index + title.length,
254
+ coordinatesGeneratedAt: new Date().toISOString(),
255
+ };
256
+ }
257
+ }
258
+ catch {
259
+ return after;
260
+ }
261
+ return after;
262
+ }
202
263
  function isRecord(value) {
203
264
  return typeof value === "object" && value !== null && !Array.isArray(value);
204
265
  }
@@ -19,6 +19,7 @@ import Ajv from "ajv";
19
19
  import { escapeAtom, toPrologAtom, toPrologString, } from "kibi-cli/prolog/codec";
20
20
  import entitySchema from "kibi-cli/schemas/entity";
21
21
  import relationshipSchema from "kibi-cli/schemas/relationship";
22
+ import { isMcpDebugEnabled } from "../env.js";
22
23
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
23
24
  let refreshCoordinatesForSymbolIdImpl = refreshCoordinatesForSymbolId;
24
25
  const ajv = new Ajv({ strict: false });
@@ -160,7 +161,7 @@ export async function handleKbUpsert(prolog, args) {
160
161
  }
161
162
  catch (error) {
162
163
  const message = error instanceof Error ? error.message : String(error);
163
- if (process.env.KIBI_MCP_DEBUG) {
164
+ if (isMcpDebugEnabled()) {
164
165
  console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${id}: ${message}`);
165
166
  }
166
167
  }
@@ -361,6 +362,9 @@ function formatUpsertError(entityId, rawError) {
361
362
  if (contradictionMatch) {
362
363
  // Extract individual conflict details from the list
363
364
  const details = contradictionMatch[1];
365
+ if (!details) {
366
+ return `Contradiction detected for entity ${entityId}: This requirement conflicts with existing requirements. Add a supersedes relationship to the conflicting requirement, or deprecate the old requirement before creating the new one.`;
367
+ }
364
368
  // Parse out readable parts - each entry is like 'Reason'-'ReqId'
365
369
  const conflicts = [];
366
370
  const conflictRegex = /'([^']+)'-'([^']+)'/g;
@@ -368,7 +372,9 @@ function formatUpsertError(entityId, rawError) {
368
372
  while (execResult !== null) {
369
373
  const reason = execResult[1];
370
374
  const otherReq = execResult[2];
371
- conflicts.push(` - Conflicts with ${otherReq}: ${reason}`);
375
+ if (reason !== undefined && otherReq !== undefined) {
376
+ conflicts.push(` - Conflicts with ${otherReq}: ${reason}`);
377
+ }
372
378
  execResult = conflictRegex.exec(details);
373
379
  }
374
380
  if (conflicts.length > 0) {
@@ -316,6 +316,7 @@ const BASE_TOOLS = [
316
316
  "validates",
317
317
  "implements",
318
318
  "covered_by",
319
+ "executable_for",
319
320
  "constrained_by",
320
321
  "constrains",
321
322
  "requires_property",
@@ -325,7 +326,7 @@ const BASE_TOOLS = [
325
326
  "supersedes",
326
327
  "relates_to",
327
328
  ],
328
- description: "Relationship type enum. Use only supported values. Direction semantics follow KB model (e.g., implements symbol->req, verified_by req->test).",
329
+ description: "Relationship type enum. Use only supported values. Direction semantics follow KB model (e.g., implements symbol->req, verified_by req/scenario->test, executable_for symbol->test).",
329
330
  },
330
331
  from: {
331
332
  type: "string",
@@ -386,6 +387,9 @@ const BASE_TOOLS = [
386
387
  ];
387
388
  /**
388
389
  * Inject _diagnostic_telemetry schema into tool inputs when diagnostic mode is enabled.
390
+ * TODO: This function is compile-time guarded by DIAGNOSTIC_MODE_ENABLED and only
391
+ * executes when the server starts with the --diagnostic-mode flag. It cannot be
392
+ * covered without a CLI integration test.
389
393
  */
390
394
  function withDiagnosticTelemetrySchema(tools) {
391
395
  return tools.map((tool) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -9,8 +9,8 @@
9
9
  "fast-glob": "^3.2.12",
10
10
  "gray-matter": "^4.0.3",
11
11
  "js-yaml": "^4.1.0",
12
- "kibi-cli": "^0.5.1",
13
- "kibi-core": "^0.4.1",
12
+ "kibi-cli": "^0.6.1",
13
+ "kibi-core": "^0.5.1",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"