kibi-mcp 2.2.0 → 2.4.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.
@@ -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
  "",
@@ -111,6 +111,10 @@ export function registerConfiguredTools(server, runtime, registerTool) {
111
111
  return startJob("kb_check", () => runtime.handleKbCheck(prologFor(context), checkArgs));
112
112
  },
113
113
  });
114
+ register({
115
+ name: "kb_prepare_impact_review",
116
+ execute: async (context, args) => getSpec("kb_prepare_impact_review").execute(args, context),
117
+ });
114
118
  register({
115
119
  name: "kb_model_requirement",
116
120
  execute: async (context, args) => runtime.handleKbModelRequirement(prologFor(context), args, withSessionProlog(context)),
@@ -24,6 +24,7 @@ import { getJob, jobSnapshot } from "./jobs.js";
24
24
  import { jsonSchemaToZod } from "./json-schema-to-zod.js";
25
25
  import { registerConfiguredTools } from "./tool-registration.js";
26
26
  import { DEFAULT_TOOLS_RUNTIME, _resetSessionModulePromise, _setToolsServerDepsForTests, } from "./tools-runtime.js";
27
+ import { WORKSPACE_ROOT_ARGUMENT, getWorkspaceRouter, } from "./workspace-router.js";
27
28
  export { jsonSchemaToZod } from "./json-schema-to-zod.js";
28
29
  export { _resetSessionModulePromise, _setToolsServerDepsForTests };
29
30
  const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
@@ -115,23 +116,72 @@ async function withToolTimeout(toolName, operation, onTimeout) {
115
116
  }
116
117
  }
117
118
  }
119
+ /**
120
+ * A module that vanished after startup means the package was upgraded or
121
+ * reinstalled under a running server; only a restart loads the new install.
122
+ */
123
+ function staleInstallHint(error) {
124
+ const code = error.code;
125
+ const missingModule = code === "ERR_MODULE_NOT_FOUND" ||
126
+ code === "MODULE_NOT_FOUND" ||
127
+ /Cannot find (module|package)/.test(error.message);
128
+ return missingModule
129
+ ? " — 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."
130
+ : "";
131
+ }
132
+ /**
133
+ * The decision when no workspace router is installed (unit tests, embedded
134
+ * use): every call is local, and `workspaceRoot` is dropped so handlers never
135
+ * see a routing-only argument. kb_check keeps it: there it also names the
136
+ * tree to inspect for impact diagnostics.
137
+ */
138
+ function localDecision(name, args) {
139
+ if (!(WORKSPACE_ROOT_ARGUMENT in args) || name === "kb_check") {
140
+ return { kind: "local", args };
141
+ }
142
+ const { [WORKSPACE_ROOT_ARGUMENT]: _ignored, ...rest } = args;
143
+ return { kind: "local", args: rest };
144
+ }
145
+ /** A result's diagnostics name the workspace mismatch when routing fell back. */
146
+ function withWorkspaceNotice(envelope, notice) {
147
+ if (!notice)
148
+ return envelope;
149
+ return { ...envelope, diagnostics: [...envelope.diagnostics, notice] };
150
+ }
118
151
  // implements REQ-002
119
152
  export function addTool(server, name, description, inputSchema, handler,
120
153
  // INTENTIONAL: DEFAULT_TOOLS_RUNTIME is typed as ToolsRuntime<PrologProcess>; the
121
154
  // generic TProlog parameter exists so tests can inject a mock type. The cast is safe
122
155
  // because the runtime object satisfies the full ToolsRuntime contract at runtime.
123
156
  runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
124
- const wrappedHandler = async (args) => {
157
+ const wrappedHandler = async (rawArgs) => {
125
158
  const startedAt = new Date();
126
159
  const diagnosticModeEnabled = runtime.diagnosticModeEnabled();
127
- const { businessArgs, telemetry } = diagnosticModeEnabled
128
- ? runtime.extractToolCallPayload(args)
129
- : { businessArgs: args, telemetry: null };
160
+ let args = rawArgs;
161
+ let workspaceNotice;
162
+ let businessArgs = rawArgs;
163
+ let telemetry = null;
130
164
  try {
131
165
  // Validate that args is a valid object
132
166
  if (typeof args !== "object" || args === null) {
133
167
  throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
134
168
  }
169
+ // Answer from the workspace the caller is working in. A call routed to
170
+ // another workspace's kibi-mcp returns that server's result as is; the
171
+ // child logs its own usage and the parent records nothing for it.
172
+ // Without a router the decision is synchronous, so a plain call reaches
173
+ // its handler without an extra microtask.
174
+ const router = getWorkspaceRouter();
175
+ const routing = router
176
+ ? await router.route(name, args)
177
+ : localDecision(name, args);
178
+ if (routing.kind === "remote")
179
+ return routing.result;
180
+ args = routing.args;
181
+ workspaceNotice = routing.notice;
182
+ ({ businessArgs, telemetry } = diagnosticModeEnabled
183
+ ? runtime.extractToolCallPayload(args)
184
+ : { businessArgs: args, telemetry: null });
135
185
  // Check if shutting down before processing
136
186
  if (await runtime.isShuttingDown()) {
137
187
  throw new Error(`Tool ${name} rejected: server is shutting down`);
@@ -189,7 +239,7 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
189
239
  }
190
240
  });
191
241
  const data = operationData(result);
192
- const envelope = toKibiResult(operationSpec, data);
242
+ const envelope = withWorkspaceNotice(toKibiResult(operationSpec, data), workspaceNotice);
193
243
  // Log usage in diagnostic mode
194
244
  if (diagnosticModeEnabled) {
195
245
  await appendDiagnosticSuccessUsage({
@@ -292,7 +342,7 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
292
342
  if (err.stack) {
293
343
  debugLog(`[KIBI-MCP] Tool ${name} stack:`, err.stack);
294
344
  }
295
- throw new Error(`Tool ${name} failed: ${err.message}`, { cause: err });
345
+ throw new Error(`Tool ${name} failed: ${err.message}${staleInstallHint(err)}`, { cause: err });
296
346
  }
297
347
  };
298
348
  server.registerTool(name, {
@@ -0,0 +1,583 @@
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
+ // implements REQ-mcp-workspace-routing
19
+ import { spawnSync } from "node:child_process";
20
+ import fs from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import path from "node:path";
23
+ import process from "node:process";
24
+ import { fileURLToPath } from "node:url";
25
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
26
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
27
+ import { CallToolResultSchema, RootsListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
28
+ /**
29
+ * Workspace routing: answer every tool call from the workspace the caller is
30
+ * working in, not the one this server happened to start in.
31
+ *
32
+ * Hosts start an MCP server once per project and then let the agent work
33
+ * elsewhere, most often in a git worktree. A server attached at startup would
34
+ * answer from the original checkout's branch, and nothing in the result would
35
+ * say so. This router decides, per call, which workspace should answer:
36
+ *
37
+ * 1. an explicit `workspaceRoot` argument (every tool accepts it; host hooks
38
+ * fill it in from the agent's current directory where hooks exist, and any
39
+ * agent can pass its working directory);
40
+ * 2. otherwise the client's MCP roots, when the client declares them;
41
+ * 3. otherwise the workspace this server is attached to.
42
+ *
43
+ * Another workspace is served by a child kibi-mcp started there (the
44
+ * workspace's own project-local install when present, else this server's
45
+ * entry), connected as an MCP client, pooled, and retired when idle. Routing
46
+ * is limited to worktrees of the same repository, directories under the
47
+ * client's roots, and `KIBI_MCP_ROUTABLE_ROOTS`; anything else, and any
48
+ * failure to start the child, falls back to the attached workspace with a
49
+ * `workspace_mismatch` diagnostic in the result, so a wrong-branch answer is
50
+ * never silent. `KIBI_WORKSPACE` (or an alias) pins the server and disables
51
+ * routing; a routed child is itself pinned and never routes further.
52
+ */
53
+ export const WORKSPACE_ROOT_ARGUMENT = "workspaceRoot";
54
+ export const WORKSPACE_ROOT_SCHEMA = {
55
+ type: "string",
56
+ description: "Optional absolute path of the workspace this call is about when it differs from the one the server started in, for example the git worktree you are working in. The server answers from that workspace's knowledge base when it is a worktree of the same repository or lies under the client's MCP roots; otherwise it answers from its own workspace and adds a workspace_mismatch diagnostic. Host hooks fill this in automatically where available.",
57
+ };
58
+ /** Mirror of the runtime's workspace env keys; any of them pins the server. */
59
+ const WORKSPACE_PIN_KEYS = [
60
+ "KIBI_WORKSPACE",
61
+ "KIBI_PROJECT_ROOT",
62
+ "KIBI_ROOT",
63
+ ];
64
+ const ROUTED_CHILD_ENV = "KIBI_MCP_ROUTED";
65
+ const ROUTING_SWITCH_ENV = "KIBI_MCP_ROUTING";
66
+ const ROUTABLE_ROOTS_ENV = "KIBI_MCP_ROUTABLE_ROOTS";
67
+ const KIBI_MCP_PACKAGE = "kibi-mcp";
68
+ const DEFAULT_MAX_CHILDREN = 4;
69
+ const DEFAULT_IDLE_RETIRE_MS = 10 * 60 * 1000;
70
+ const DEFAULT_ROOTS_TIMEOUT_MS = 2000;
71
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30000;
72
+ function isRecord(value) {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+ /**
76
+ * The Kibi workspace that owns `directory`: the nearest ancestor holding
77
+ * `.kb/manifest.json`, without crossing a `.git` boundary so an unrelated
78
+ * enclosing repository never claims the directory.
79
+ */
80
+ export function kibiWorkspaceFor(directory) {
81
+ let current = path.resolve(directory);
82
+ for (;;) {
83
+ if (fs.existsSync(path.join(current, ".kb", "manifest.json"))) {
84
+ return current;
85
+ }
86
+ if (fs.existsSync(path.join(current, ".git")))
87
+ return null;
88
+ const parent = path.dirname(current);
89
+ if (parent === current)
90
+ return null;
91
+ current = parent;
92
+ }
93
+ }
94
+ /** The workspace named by MCP roots: the first file root in a Kibi workspace. */
95
+ export function workspaceFromRoots(roots, resolveWorkspace = kibiWorkspaceFor) {
96
+ for (const root of roots) {
97
+ const workspace = resolveWorkspace(root);
98
+ if (workspace)
99
+ return workspace;
100
+ }
101
+ return null;
102
+ }
103
+ function realpathOrSelf(target) {
104
+ try {
105
+ return fs.realpathSync.native(target);
106
+ }
107
+ catch {
108
+ return path.resolve(target);
109
+ }
110
+ }
111
+ function isWithin(parent, candidate) {
112
+ const relative = path.relative(realpathOrSelf(parent), realpathOrSelf(candidate));
113
+ return (relative === "" ||
114
+ (!relative.startsWith("..") && !path.isAbsolute(relative)));
115
+ }
116
+ /** The repository's shared git directory, so worktrees compare equal. */
117
+ export function gitCommonDirectory(directory) {
118
+ const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
119
+ cwd: directory,
120
+ encoding: "utf8",
121
+ timeout: 5000,
122
+ });
123
+ if (result.status !== 0)
124
+ return null;
125
+ const common = result.stdout.trim();
126
+ if (!common)
127
+ return null;
128
+ return realpathOrSelf(path.resolve(directory, common));
129
+ }
130
+ /**
131
+ * Default routing boundary: worktrees of the attached repository, anything
132
+ * under the client's roots, and operator-listed roots.
133
+ */
134
+ export function defaultIsRoutable(attachedRoot, env) {
135
+ let attachedCommon;
136
+ const allowlist = (env[ROUTABLE_ROOTS_ENV] ?? "")
137
+ .split(path.delimiter)
138
+ .map((entry) => entry.trim())
139
+ .filter((entry) => entry.length > 0);
140
+ return (workspaceRoot, roots) => {
141
+ if (roots.some((root) => isWithin(root, workspaceRoot)))
142
+ return true;
143
+ if (allowlist.some((root) => isWithin(root, workspaceRoot)))
144
+ return true;
145
+ if (attachedCommon === undefined) {
146
+ attachedCommon = gitCommonDirectory(attachedRoot);
147
+ }
148
+ if (!attachedCommon)
149
+ return false;
150
+ return gitCommonDirectory(workspaceRoot) === attachedCommon;
151
+ };
152
+ }
153
+ function readDeclaredBin(packageJsonPath) {
154
+ try {
155
+ const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
156
+ if (manifest.name !== KIBI_MCP_PACKAGE)
157
+ return null;
158
+ const bin = typeof manifest.bin === "string"
159
+ ? manifest.bin
160
+ : isRecord(manifest.bin) &&
161
+ typeof manifest.bin[KIBI_MCP_PACKAGE] === "string"
162
+ ? manifest.bin[KIBI_MCP_PACKAGE]
163
+ : null;
164
+ if (!bin)
165
+ return null;
166
+ const entry = path.resolve(path.dirname(packageJsonPath), bin);
167
+ return fs.existsSync(entry) ? entry : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /**
174
+ * The kibi-mcp to run for a workspace: its own project-local install when it
175
+ * has one (so the child matches that branch's code), else this server's
176
+ * entry. Resolution goes through the package's public entry, since its
177
+ * exports map usually blocks `kibi-mcp/package.json`.
178
+ */
179
+ export function defaultResolveChildCommand(workspaceRoot) {
180
+ const marker = path.join(workspaceRoot, "package.json");
181
+ if (fs.existsSync(marker)) {
182
+ try {
183
+ const entry = createRequire(marker).resolve(KIBI_MCP_PACKAGE);
184
+ let current = path.dirname(entry);
185
+ for (;;) {
186
+ const bin = readDeclaredBin(path.join(current, "package.json"));
187
+ if (bin)
188
+ return { command: process.execPath, args: [bin] };
189
+ const parent = path.dirname(current);
190
+ if (parent === current)
191
+ break;
192
+ current = parent;
193
+ }
194
+ }
195
+ catch {
196
+ // No project-local install; fall through to this server's own entry.
197
+ }
198
+ }
199
+ const self = process.argv[1];
200
+ if (self && fs.existsSync(self)) {
201
+ return { command: process.execPath, args: [path.resolve(self)] };
202
+ }
203
+ try {
204
+ const entry = fileURLToPath(new URL("../../bin/kibi-mcp", import.meta.url));
205
+ if (fs.existsSync(entry))
206
+ return { command: process.execPath, args: [entry] };
207
+ }
208
+ catch {
209
+ // Not resolvable from this module location.
210
+ }
211
+ return null;
212
+ }
213
+ function isPinned(env) {
214
+ return WORKSPACE_PIN_KEYS.some((key) => (env[key] ?? "").trim().length > 0);
215
+ }
216
+ function withTimeout(promise, timeoutMs, what) {
217
+ return new Promise((resolve, reject) => {
218
+ const timer = setTimeout(() => reject(new Error(`${what} timed out after ${timeoutMs}ms`)), timeoutMs);
219
+ promise.then((value) => {
220
+ clearTimeout(timer);
221
+ resolve(value);
222
+ }, (error) => {
223
+ clearTimeout(timer);
224
+ reject(error);
225
+ });
226
+ });
227
+ }
228
+ export class WorkspaceRouter {
229
+ options;
230
+ env;
231
+ attachedRoot;
232
+ resolveWorkspace;
233
+ isRoutable;
234
+ resolveChildCommand;
235
+ log;
236
+ now;
237
+ children = new Map();
238
+ starting = new Map();
239
+ /** Background jobs started in a child, so polls reach the same process. */
240
+ jobOwners = new Map();
241
+ rootsCache = null;
242
+ rootsHandlerInstalled = false;
243
+ shutDown = false;
244
+ constructor(options) {
245
+ this.options = options;
246
+ this.env = options.env ?? process.env;
247
+ this.attachedRoot = realpathOrSelf(options.workspaceRoot);
248
+ this.resolveWorkspace = options.resolveWorkspace ?? kibiWorkspaceFor;
249
+ this.isRoutable =
250
+ options.isRoutable ?? defaultIsRoutable(this.attachedRoot, this.env);
251
+ this.resolveChildCommand =
252
+ options.resolveChildCommand ?? defaultResolveChildCommand;
253
+ this.log =
254
+ options.log ?? ((text) => process.stderr.write(`[kibi-mcp] ${text}\n`));
255
+ this.now = options.now ?? Date.now;
256
+ }
257
+ /** Routing is off for pinned servers, routed children, and by switch. */
258
+ get enabled() {
259
+ if (this.shutDown)
260
+ return false;
261
+ if ((this.env[ROUTING_SWITCH_ENV] ?? "").trim() === "0")
262
+ return false;
263
+ if ((this.env[ROUTED_CHILD_ENV] ?? "").trim() === "1")
264
+ return false;
265
+ return !isPinned(this.env);
266
+ }
267
+ /** Decide where `toolName(args)` is answered; strips `workspaceRoot`. */
268
+ async route(toolName, args) {
269
+ // Callers that never mention a workspace get their argument object back
270
+ // untouched, so handlers see exactly what the client sent.
271
+ if (!(WORKSPACE_ROOT_ARGUMENT in args)) {
272
+ if (!this.enabled)
273
+ return { kind: "local", args };
274
+ if (toolName === "kb_job_status")
275
+ return this.routeJobPoll(args);
276
+ const roots = await this.clientRoots();
277
+ const desired = workspaceFromRoots(roots, this.resolveWorkspace);
278
+ if (!desired)
279
+ return { kind: "local", args };
280
+ return this.routeTo(desired, roots[0] ?? desired, toolName, args);
281
+ }
282
+ const { [WORKSPACE_ROOT_ARGUMENT]: requestedRaw, ...rest } = args;
283
+ const requested = typeof requestedRaw === "string" && requestedRaw.trim().length > 0
284
+ ? requestedRaw.trim()
285
+ : undefined;
286
+ if (!this.enabled) {
287
+ if (requested === undefined)
288
+ return { kind: "local", args: rest };
289
+ if (toolName === "kb_check")
290
+ return { kind: "local", args };
291
+ const resolved = this.resolveWorkspace(requested);
292
+ if (resolved && realpathOrSelf(resolved) === this.attachedRoot) {
293
+ return { kind: "local", args: rest };
294
+ }
295
+ return {
296
+ kind: "local",
297
+ args: rest,
298
+ notice: this.notice(requested, resolved, "pinned"),
299
+ };
300
+ }
301
+ if (toolName === "kb_job_status")
302
+ return this.routeJobPoll(rest);
303
+ if (requested !== undefined) {
304
+ const resolved = this.resolveWorkspace(requested);
305
+ if (!resolved) {
306
+ // kb_check keeps its older meaning for a plain directory: impact
307
+ // diagnostics for that tree, answered by the attached knowledge base.
308
+ if (toolName === "kb_check")
309
+ return { kind: "local", args };
310
+ return {
311
+ kind: "local",
312
+ args: rest,
313
+ notice: this.notice(requested, null, "not_a_kibi_workspace"),
314
+ };
315
+ }
316
+ return this.routeTo(resolved, requested, toolName, rest);
317
+ }
318
+ // An empty workspaceRoot means "no preference": fall back to roots.
319
+ const roots = await this.clientRoots();
320
+ const desired = workspaceFromRoots(roots, this.resolveWorkspace);
321
+ if (!desired)
322
+ return { kind: "local", args: rest };
323
+ return this.routeTo(desired, roots[0] ?? desired, toolName, rest);
324
+ }
325
+ /** Polls for a job started in a child go to that child. */
326
+ async routeJobPoll(args) {
327
+ const owner = typeof args.jobId === "string"
328
+ ? this.jobOwners.get(args.jobId)
329
+ : undefined;
330
+ const child = owner ? this.children.get(owner) : undefined;
331
+ if (child && !child.closed) {
332
+ return this.callChild(child, "kb_job_status", args);
333
+ }
334
+ return { kind: "local", args };
335
+ }
336
+ async shutdown() {
337
+ this.shutDown = true;
338
+ await Promise.all([...this.children.values()].map((child) => this.retire(child)));
339
+ }
340
+ notice(requested, resolved, reason, error) {
341
+ const why = {
342
+ pinned: "this server is pinned to its workspace by KIBI_WORKSPACE (or an alias) and does not route calls",
343
+ not_a_kibi_workspace: "no Kibi workspace (.kb/manifest.json) owns that directory",
344
+ not_routable: "that workspace is neither a worktree of this server's repository nor under the client's MCP roots or KIBI_MCP_ROUTABLE_ROOTS",
345
+ unavailable: `kibi-mcp could not be started there${error ? `: ${error}` : ""}`,
346
+ }[reason];
347
+ return {
348
+ code: "workspace_mismatch",
349
+ severity: "warning",
350
+ message: `Answered from ${this.attachedRoot}, not from the requested workspace ${requested}: ${why}. Run the Kibi CLI from that directory if you need its knowledge base.`,
351
+ detail: {
352
+ requested,
353
+ resolved,
354
+ answeredFrom: this.attachedRoot,
355
+ reason,
356
+ ...(error ? { error } : {}),
357
+ },
358
+ };
359
+ }
360
+ async routeTo(resolved, requested, toolName, args) {
361
+ const target = realpathOrSelf(resolved);
362
+ if (target === this.attachedRoot)
363
+ return { kind: "local", args };
364
+ const roots = this.rootsCache ?? [];
365
+ if (!this.isRoutable(target, roots)) {
366
+ return {
367
+ kind: "local",
368
+ args,
369
+ notice: this.notice(requested, resolved, "not_routable"),
370
+ };
371
+ }
372
+ let child;
373
+ try {
374
+ child = await this.childFor(target);
375
+ }
376
+ catch (error) {
377
+ child = null;
378
+ const message = error instanceof Error ? error.message : String(error);
379
+ return {
380
+ kind: "local",
381
+ args,
382
+ notice: this.notice(requested, resolved, "unavailable", message),
383
+ };
384
+ }
385
+ if (!child) {
386
+ return {
387
+ kind: "local",
388
+ args,
389
+ notice: this.notice(requested, resolved, "unavailable", "no kibi-mcp entry resolves for that workspace"),
390
+ };
391
+ }
392
+ return this.callChild(child, toolName, args);
393
+ }
394
+ async callChild(child, toolName, args) {
395
+ child.inFlight += 1;
396
+ this.touch(child);
397
+ try {
398
+ const result = (await child.client.callTool({ name: toolName, arguments: args }, CallToolResultSchema));
399
+ this.rememberJob(child, result);
400
+ return { kind: "remote", workspaceRoot: child.root, result };
401
+ }
402
+ finally {
403
+ child.inFlight -= 1;
404
+ this.touch(child);
405
+ }
406
+ }
407
+ rememberJob(child, result) {
408
+ const structured = result
409
+ .structuredContent;
410
+ const data = isRecord(structured) ? structured.data : undefined;
411
+ const jobId = isRecord(data) ? data.jobId : undefined;
412
+ if (typeof jobId === "string")
413
+ this.jobOwners.set(jobId, child.root);
414
+ }
415
+ /** The client's current roots, cached while the client reports changes. */
416
+ async clientRoots() {
417
+ const capabilities = this.options.server.server.getClientCapabilities();
418
+ if (!capabilities?.roots)
419
+ return [];
420
+ const listChanged = capabilities.roots.listChanged === true;
421
+ if (listChanged && !this.rootsHandlerInstalled) {
422
+ this.rootsHandlerInstalled = true;
423
+ this.options.server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
424
+ this.rootsCache = null;
425
+ });
426
+ }
427
+ if (listChanged && this.rootsCache)
428
+ return this.rootsCache;
429
+ try {
430
+ const listed = await withTimeout(this.options.server.server.listRoots(), this.options.rootsTimeoutMs ?? DEFAULT_ROOTS_TIMEOUT_MS, "roots/list");
431
+ const roots = listed.roots.flatMap((root) => {
432
+ try {
433
+ return [fileURLToPath(root.uri)];
434
+ }
435
+ catch {
436
+ return [];
437
+ }
438
+ });
439
+ this.rootsCache = roots;
440
+ return roots;
441
+ }
442
+ catch (error) {
443
+ this.log(`roots/list failed; answering from ${this.attachedRoot}: ${error instanceof Error ? error.message : String(error)}`);
444
+ return this.rootsCache ?? [];
445
+ }
446
+ }
447
+ async childFor(root) {
448
+ const existing = this.children.get(root);
449
+ if (existing && !existing.closed)
450
+ return existing;
451
+ const pending = this.starting.get(root);
452
+ if (pending)
453
+ return pending;
454
+ const start = this.startChild(root).finally(() => {
455
+ this.starting.delete(root);
456
+ });
457
+ this.starting.set(root, start);
458
+ return start;
459
+ }
460
+ async startChild(root) {
461
+ const command = this.resolveChildCommand(root);
462
+ if (!command)
463
+ return null;
464
+ this.evictIfFull();
465
+ const env = {};
466
+ for (const [key, value] of Object.entries(this.env)) {
467
+ if (typeof value === "string")
468
+ env[key] = value;
469
+ }
470
+ env.KIBI_WORKSPACE = root;
471
+ env[ROUTED_CHILD_ENV] = "1";
472
+ const transport = new StdioClientTransport({
473
+ command: command.command,
474
+ args: [...command.args],
475
+ cwd: root,
476
+ env,
477
+ stderr: "pipe",
478
+ });
479
+ const client = new Client({
480
+ name: "kibi-mcp-workspace-router",
481
+ version: "1",
482
+ });
483
+ const child = {
484
+ root,
485
+ client,
486
+ transport,
487
+ lastUsed: this.now(),
488
+ inFlight: 0,
489
+ idleTimer: null,
490
+ closed: false,
491
+ };
492
+ transport.onclose = () => {
493
+ child.closed = true;
494
+ if (this.children.get(root) === child)
495
+ this.children.delete(root);
496
+ if (child.idleTimer)
497
+ clearTimeout(child.idleTimer);
498
+ };
499
+ await withTimeout(client.connect(transport), this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, `kibi-mcp for ${root} initialize`);
500
+ const label = `[kibi-mcp ${path.basename(root)}]`;
501
+ transport.stderr?.on("data", (chunk) => {
502
+ for (const line of chunk.toString("utf8").split("\n")) {
503
+ if (line.trim().length > 0)
504
+ process.stderr.write(`${label} ${line}\n`);
505
+ }
506
+ });
507
+ this.children.set(root, child);
508
+ this.touch(child);
509
+ this.log(`Serving ${root} through its own kibi-mcp`);
510
+ return child;
511
+ }
512
+ touch(child) {
513
+ child.lastUsed = this.now();
514
+ if (child.idleTimer)
515
+ clearTimeout(child.idleTimer);
516
+ const idleMs = this.options.idleRetireMs ?? DEFAULT_IDLE_RETIRE_MS;
517
+ child.idleTimer = setTimeout(() => {
518
+ if (child.inFlight === 0)
519
+ void this.retire(child);
520
+ }, idleMs);
521
+ child.idleTimer.unref?.();
522
+ }
523
+ evictIfFull() {
524
+ const max = this.options.maxChildren ?? DEFAULT_MAX_CHILDREN;
525
+ while (this.children.size >= max) {
526
+ let oldest = null;
527
+ for (const child of this.children.values()) {
528
+ if (child.inFlight > 0)
529
+ continue;
530
+ if (!oldest || child.lastUsed < oldest.lastUsed)
531
+ oldest = child;
532
+ }
533
+ if (!oldest)
534
+ return;
535
+ void this.retire(oldest);
536
+ this.children.delete(oldest.root);
537
+ }
538
+ }
539
+ async retire(child) {
540
+ if (child.closed)
541
+ return;
542
+ child.closed = true;
543
+ if (child.idleTimer)
544
+ clearTimeout(child.idleTimer);
545
+ if (this.children.get(child.root) === child)
546
+ this.children.delete(child.root);
547
+ for (const [jobId, owner] of this.jobOwners) {
548
+ if (owner === child.root)
549
+ this.jobOwners.delete(jobId);
550
+ }
551
+ try {
552
+ await child.client.close();
553
+ }
554
+ catch {
555
+ // The child is gone either way.
556
+ }
557
+ }
558
+ }
559
+ let activeRouter;
560
+ /** Install the router the shared tool handler consults; undefined disables it. */
561
+ export function setWorkspaceRouter(router) {
562
+ activeRouter = router;
563
+ }
564
+ export function getWorkspaceRouter() {
565
+ return activeRouter;
566
+ }
567
+ /** Add `workspaceRoot` to a tool input schema, keeping any existing entry's type. */
568
+ export function withWorkspaceRootSchema(tools) {
569
+ return tools.map((tool) => {
570
+ const schema = tool.inputSchema;
571
+ const properties = isRecord(schema.properties) ? schema.properties : {};
572
+ return {
573
+ ...tool,
574
+ inputSchema: {
575
+ ...schema,
576
+ properties: {
577
+ ...properties,
578
+ [WORKSPACE_ROOT_ARGUMENT]: WORKSPACE_ROOT_SCHEMA,
579
+ },
580
+ },
581
+ };
582
+ });
583
+ }
package/dist/server.js CHANGED
@@ -24,6 +24,8 @@ import { setupDocsAndPrompts } from "./server/docs.js";
24
24
  import { KIBI_ICONS } from "./server/icons.js";
25
25
  import { registerAllTools } from "./server/tools.js";
26
26
  import { connectTransport, setupTransportHandlers, } from "./server/transport.js";
27
+ import { WorkspaceRouter, setWorkspaceRouter, } from "./server/workspace-router.js";
28
+ import { resolveWorkspaceRoot } from "./workspace.js";
27
29
  // Read version from package.json to prevent drift
28
30
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
29
31
  const VERSION = packageJson.version ?? "0.1.0";
@@ -48,6 +50,8 @@ export async function startServer() {
48
50
  version: VERSION,
49
51
  icons: KIBI_ICONS,
50
52
  }, { instructions: SERVER_INSTRUCTIONS });
53
+ // Answer each call from the caller's workspace (worktrees, parallel agents).
54
+ setWorkspaceRouter(new WorkspaceRouter({ server, workspaceRoot: resolveWorkspaceRoot() }));
51
55
  // Setup documentation resources and prompts
52
56
  setupDocsAndPrompts(server);
53
57
  // Register all KB tools
@@ -7,7 +7,7 @@ function isOperationContext(value) {
7
7
  "workspaceRoot" in value &&
8
8
  "signal" in value);
9
9
  }
10
- // implements REQ-mcp-kibi-bootstrap-bootstrap-v1, REQ-kibi-operation-interface-parity
10
+ // implements REQ-KIBI-BOOTSTRAP-PLAN, REQ-kibi-operation-interface-parity
11
11
  // implements REQ-KIBI-BOOTSTRAP-PLAN
12
12
  export async function handleKbPlanBootstrap(input, context) {
13
13
  if (!isOperationContext(context))
@@ -74,7 +74,7 @@ async function handleKbSymbolsRefreshUnlocked(args, workspaceRoot) {
74
74
  id: typeof entry.id === "string" ? entry.id : "",
75
75
  title: typeof entry.title === "string" ? entry.title : "",
76
76
  }));
77
- const enriched = await enrichSymbolCoordinates(entriesForEnrichment, workspaceRoot);
77
+ const enriched = await enrichSymbolCoordinates(entriesForEnrichment, workspaceRoot, { allowPythonDecoratorCoordinates: true });
78
78
  const finalized = await Promise.all(enriched.map((entry, index) => fillMissingCoordinates(original[index] ?? {}, entry, workspaceRoot)));
79
79
  parsed.symbols = finalized;
80
80
  let refreshed = 0;
@@ -148,7 +148,7 @@ async function refreshCoordinatesForSymbolIdUnlocked(symbolId, workspaceRoot) {
148
148
  ? original.title
149
149
  : "",
150
150
  };
151
- const [enriched] = await enrichSymbolCoordinates([singleEntry], workspaceRoot);
151
+ const [enriched] = await enrichSymbolCoordinates([singleEntry], workspaceRoot, { allowPythonDecoratorCoordinates: true });
152
152
  const finalized = await fillMissingCoordinates(original, enriched ?? singleEntry, workspaceRoot);
153
153
  symbols[index] = finalized;
154
154
  parsed.symbols = symbols;
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { getSpec } from "kibi-runtime";
19
19
  import { DIAGNOSTIC_MODE_ENABLED, DIAGNOSTIC_TELEMETRY_SCHEMA, } from "./diagnostics.js";
20
+ import { withWorkspaceRootSchema } from "./server/workspace-router.js";
20
21
  const MCP_TOOL_ORDER = [
21
22
  "kb_query",
22
23
  "kb_search",
@@ -33,6 +34,7 @@ const MCP_TOOL_ORDER = [
33
34
  "kb_validate_upsert",
34
35
  "kb_delete",
35
36
  "kb_check",
37
+ "kb_prepare_impact_review",
36
38
  "kb_model_requirement",
37
39
  "kb_suggest_predicates",
38
40
  "kb_plan_bootstrap",
@@ -114,6 +116,13 @@ const TOOL_ANNOTATIONS = {
114
116
  kb_check: {
115
117
  title: "Validate Kibi knowledge base",
116
118
  },
119
+ kb_prepare_impact_review: {
120
+ title: "Prepare an impact review",
121
+ readOnlyHint: true,
122
+ destructiveHint: false,
123
+ idempotentHint: true,
124
+ openWorldHint: false,
125
+ },
117
126
  kb_model_requirement: {
118
127
  title: "Model a Kibi requirement",
119
128
  },
@@ -192,6 +201,6 @@ export function withDiagnosticTelemetrySchema(tools) {
192
201
  * Active tools list.
193
202
  * In diagnostic mode, all tools include the _diagnostic_telemetry parameter.
194
203
  */
195
- export const TOOLS = DIAGNOSTIC_MODE_ENABLED
204
+ export const TOOLS = withWorkspaceRootSchema(DIAGNOSTIC_MODE_ENABLED
196
205
  ? withDiagnosticTelemetrySchema(BASE_TOOLS)
197
- : [...BASE_TOOLS];
206
+ : [...BASE_TOOLS]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.4.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.0.2",
14
- "kibi-core": "^0.14.0",
13
+ "kibi-runtime": "^2.2.0",
14
+ "kibi-core": "^0.14.1",
15
15
  "mcpcat": "^0.1.12",
16
16
  "zod": "^4.3.6"
17
17
  },