kibi-mcp 2.0.0 → 2.1.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.
@@ -10,13 +10,32 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { appendPayloadCountField, normalizeResultPayload } from "kibi-runtime";
13
15
  import { resolveWorkspaceRoot } from "./workspace.js";
14
16
  export { buildDiagnosticToolCall, classifyDiagnosticError, deriveDiagnosticHints, deriveDiagnosticRetryKey, extractToolCallPayload, redactDiagnosticArgs, } from "./diagnostics-helpers.js";
15
17
  const DIAGNOSTIC_MODE_FLAG = "--diagnostic-mode";
16
- export const DIAGNOSTIC_MODE_ENABLED = process.argv.includes(DIAGNOSTIC_MODE_FLAG);
18
+ const DIAGNOSTIC_MODE_ENV_KEY = "KIBI_DIAGNOSTIC_MODE";
19
+ /**
20
+ * Usage telemetry is opt-in and stays off unless the operator asks for it.
21
+ *
22
+ * The launch flag covers hosts whose MCP command line the operator writes by
23
+ * hand. `KIBI_DIAGNOSTIC_MODE=1` covers hosts where a plugin owns the command
24
+ * line, so opting in never requires editing a shipped launcher. Neither
25
+ * signal is set by default, and plugin installation alone never enables it.
26
+ */
27
+ export function diagnosticModeRequested(argv = process.argv, env = process.env) {
28
+ if (argv.includes(DIAGNOSTIC_MODE_FLAG))
29
+ return true;
30
+ const optIn = env[DIAGNOSTIC_MODE_ENV_KEY]?.trim().toLowerCase();
31
+ return optIn === "1" || optIn === "true";
32
+ }
33
+ export const DIAGNOSTIC_MODE_ENABLED = diagnosticModeRequested();
17
34
  let diagnosticUsageLogPath = null;
35
+ let diagnosticOriginFields = {};
18
36
  export function initializeDiagnosticMode(enabled = DIAGNOSTIC_MODE_ENABLED) {
19
37
  diagnosticUsageLogPath = null;
38
+ diagnosticOriginFields = {};
20
39
  if (!enabled) {
21
40
  process.env.KIBI_MCP_DIAGNOSTIC_MODE = "0";
22
41
  return;
@@ -30,13 +49,50 @@ export function initializeDiagnosticMode(enabled = DIAGNOSTIC_MODE_ENABLED) {
30
49
  diagnosticUsageLogPath =
31
50
  process.env.KIBI_MCP_DIAGNOSTIC_USAGE_LOG_PATH ??
32
51
  path.join(workspaceRoot, ".kb", "usage.log");
52
+ diagnosticOriginFields = resolveOriginFields(workspaceRoot);
33
53
  process.env.KIBI_MCP_DIAGNOSTIC_MODE = "1";
34
54
  }
55
+ /**
56
+ * Identify which host, package build, and checkout produced a usage row.
57
+ *
58
+ * Without these a multi-worktree or multi-host setup cannot tell whether a
59
+ * behavioral difference came from the agent, the editor integration, or the
60
+ * installed Kibi version, because nothing else in a row names its origin.
61
+ */
62
+ function resolveOriginFields(workspaceRoot) {
63
+ return {
64
+ interface: "mcp",
65
+ host: process.env.KIBI_MCP_HOST?.trim() || "unknown",
66
+ package_version: readMcpPackageVersion(),
67
+ workspace_root: workspaceRoot,
68
+ };
69
+ }
70
+ function readMcpPackageVersion() {
71
+ let current = path.dirname(fileURLToPath(import.meta.url));
72
+ while (true) {
73
+ const candidate = path.join(current, "package.json");
74
+ if (fs.existsSync(candidate)) {
75
+ try {
76
+ const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
77
+ const version = isRecord(parsed) ? parsed.version : undefined;
78
+ return typeof version === "string" ? version : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ const parent = path.dirname(current);
85
+ if (parent === current)
86
+ return null;
87
+ current = parent;
88
+ }
89
+ }
35
90
  export function appendUsageLogLine(entry) {
36
91
  if (!diagnosticUsageLogPath)
37
92
  return;
38
93
  fs.mkdirSync(path.dirname(diagnosticUsageLogPath), { recursive: true });
39
- fs.appendFileSync(diagnosticUsageLogPath, `${JSON.stringify(entry)}\n`, {
94
+ const row = { ...diagnosticOriginFields, ...entry };
95
+ fs.appendFileSync(diagnosticUsageLogPath, `${JSON.stringify(row)}\n`, {
40
96
  encoding: "utf8",
41
97
  });
42
98
  }
@@ -77,12 +133,6 @@ export const DIAGNOSTIC_TELEMETRY_SCHEMA = {
77
133
  function isRecord(value) {
78
134
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
79
135
  }
80
- function structuredContentFrom(result) {
81
- if (!isRecord(result))
82
- return undefined;
83
- const structuredContent = result.structuredContent;
84
- return isRecord(structuredContent) ? structuredContent : undefined;
85
- }
86
136
  function stringArray(value) {
87
137
  if (!Array.isArray(value))
88
138
  return [];
@@ -276,11 +326,8 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
276
326
  fields.actor_id = telemetry.actor_id;
277
327
  }
278
328
  }
279
- const envelope = structuredContentFrom(result);
280
- const structuredContent = envelope?.kibiProtocol === 1 && isRecord(envelope.data)
281
- ? envelope.data
282
- : envelope;
283
- if (envelope?.kibiProtocol === 1) {
329
+ const { envelope, data: structuredContent } = normalizeResultPayload(result);
330
+ if (envelope) {
284
331
  fields.protocol_version = envelope.kibiProtocol;
285
332
  fields.result_version = envelope.resultVersion ?? null;
286
333
  fields.result_status = envelope.status ?? null;
@@ -296,18 +343,14 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
296
343
  fields.unsafe_original_retry = telemetry?.unsafe_original_retry === true;
297
344
  }
298
345
  if (toolName === "kb_query" || toolName === "kb_search") {
299
- const resultCount = Number(structuredContent?.count ?? 0);
300
- fields.result_count = resultCount;
301
- fields.zero_results = resultCount === 0;
302
- fields.result_summary =
303
- resultCount === 0 ? "0 results" : `${resultCount} results`;
346
+ appendPayloadCountField(fields, "result_count", "results", structuredContent);
347
+ if (typeof fields.result_count === "number") {
348
+ fields.zero_results = fields.result_count === 0;
349
+ }
304
350
  }
305
351
  if (toolName === "kb_check") {
306
- const violationCount = Number(structuredContent?.count ?? 0);
307
- fields.violation_count = violationCount;
352
+ appendPayloadCountField(fields, "violation_count", "violations", structuredContent);
308
353
  fields.requested_rules = Array.isArray(args.rules) ? args.rules : [];
309
- fields.result_summary =
310
- violationCount === 0 ? "0 violations" : `${violationCount} violations`;
311
354
  }
312
355
  if (toolName === "kb_coverage" && structuredContent) {
313
356
  appendCoverageFields(fields, args, structuredContent);
package/dist/env.js CHANGED
@@ -16,8 +16,7 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import fs from "node:fs";
19
- import { resolveEnvFilePath, resolveWorkspaceRoot } from "./workspace.js";
20
- const DEFAULT_ENV_FILE = ".env";
19
+ import { KIBI_PROJECT_ENV_FILE, bootstrapKibiEnvironment, inspectSecretSource, parseEnvContent, resolveEnvFilePath, resolveKibiProjectEnvPath, resolveKibiWorkspaceRoot, } from "kibi-runtime";
21
20
  function getEnvValue(key) {
22
21
  const value = process.env[key];
23
22
  return typeof value === "string" ? value : undefined;
@@ -26,9 +25,10 @@ function getTrimmedEnvValue(key) {
26
25
  const value = getEnvValue(key)?.trim();
27
26
  return value ? value : undefined;
28
27
  }
28
+ /** Preferred project env file name (`.env.kibi` or `KIBI_ENV_FILE` override). */
29
29
  export function getEnvFileName() {
30
30
  // implements REQ-002
31
- return getTrimmedEnvValue("KIBI_ENV_FILE") ?? DEFAULT_ENV_FILE;
31
+ return getTrimmedEnvValue("KIBI_ENV_FILE") ?? KIBI_PROJECT_ENV_FILE;
32
32
  }
33
33
  export function isMcpDebugEnabled() {
34
34
  // implements REQ-002
@@ -51,12 +51,26 @@ export function getCoreModulePathOverride(fileName) {
51
51
  const envKey = `KIBI_${fileName.replace(/\W/g, "_").toUpperCase()}_PATH`;
52
52
  return getEnvValue(envKey);
53
53
  }
54
+ /**
55
+ * Harness-independent bootstrap: process → project `.env.kibi`/`KIBI_ENV_FILE`
56
+ * → user `~/.config/kibi/env`, plus legacy `.env` gap-fill.
57
+ * Prefer calling from `startServer()` only.
58
+ */
54
59
  export function loadDefaultEnvFile() {
55
60
  // implements REQ-002
56
- const envFileName = getEnvFileName();
57
- const workspaceRoot = resolveWorkspaceRoot();
58
- return loadEnvFile({ envFileName, workspaceRoot });
61
+ const result = bootstrapKibiEnvironment();
62
+ const keysLoaded = [
63
+ ...result.keysLoadedFromUser,
64
+ ...result.keysLoadedFromProject,
65
+ ...result.keysLoadedFromLegacy,
66
+ ];
67
+ return {
68
+ loaded: keysLoaded.length > 0,
69
+ envFilePath: result.projectEnvPath,
70
+ keysLoaded,
71
+ };
59
72
  }
73
+ /** @deprecated Prefer bootstrapKibiEnvironment; kept for targeted file tests. */
60
74
  export function loadEnvFile(options) {
61
75
  const { envFileName, workspaceRoot } = options;
62
76
  const envFilePath = resolveEnvFilePath(envFileName, workspaceRoot);
@@ -80,27 +94,4 @@ export function loadEnvFile(options) {
80
94
  return { loaded: false, envFilePath, keysLoaded };
81
95
  }
82
96
  }
83
- function parseEnvContent(content) {
84
- const lines = content.split(/\r?\n/);
85
- const entries = [];
86
- for (const rawLine of lines) {
87
- const line = rawLine.trim();
88
- if (!line || line.startsWith("#")) {
89
- continue;
90
- }
91
- const eqIndex = line.indexOf("=");
92
- if (eqIndex <= 0) {
93
- continue;
94
- }
95
- const key = line.substring(0, eqIndex).trim();
96
- let value = line.substring(eqIndex + 1).trim();
97
- if (value.startsWith('"') && value.endsWith('"')) {
98
- value = value.slice(1, -1);
99
- }
100
- else if (value.startsWith("'") && value.endsWith("'")) {
101
- value = value.slice(1, -1);
102
- }
103
- entries.push({ key, value });
104
- }
105
- return entries;
106
- }
97
+ export { resolveEnvFilePath, resolveKibiProjectEnvPath, resolveKibiWorkspaceRoot, inspectSecretSource, };
@@ -1,19 +1,38 @@
1
- import { resolveBranchAttachment } from "kibi-runtime";
1
+ import { CapabilityRegistryCache, ensureCapabilityRegistry, resolveBranchAttachment, } from "kibi-runtime";
2
2
  export function attachedContextWithProlog(withAttachment, prolog) {
3
3
  return { ...withAttachment, prolog };
4
4
  }
5
+ function resolvePluginsOption(plugins) {
6
+ if (plugins === undefined)
7
+ return undefined;
8
+ if (typeof plugins === "function")
9
+ return plugins;
10
+ return async () => plugins;
11
+ }
5
12
  // implements REQ-kibi-operation-interface-parity
6
13
  export function createMcpRuntime(session) {
7
14
  const sessionPrologs = new WeakMap();
15
+ // Per-runtime cache — not a process-global singleton.
16
+ const pluginCache = new CapabilityRegistryCache();
8
17
  return {
9
18
  open: async (spec, options = {}) => {
10
19
  const fs = options.fs ?? session.fs;
11
20
  const git = options.git ?? session.git;
12
21
  const net = options.net ?? session.net;
22
+ const pluginsOption = options.plugins ?? session.plugins;
23
+ const injectedPlugins = resolvePluginsOption(pluginsOption);
24
+ const workspaceRoot = options.workspaceRoot ?? session.workspaceRoot;
25
+ const ensurePlugins = async () => {
26
+ if (injectedPlugins)
27
+ return injectedPlugins();
28
+ return ensureCapabilityRegistry(workspaceRoot, { cache: pluginCache });
29
+ };
13
30
  const context = {
14
- workspaceRoot: options.workspaceRoot ?? session.workspaceRoot,
31
+ workspaceRoot,
15
32
  signal: options.signal ?? session.signal ?? new AbortController().signal,
16
33
  clock: options.clock ?? session.clock ?? (() => new Date()),
34
+ ensurePlugins,
35
+ ...(pluginsOption !== undefined ? { plugins: pluginsOption } : {}),
17
36
  ...(fs ? { fs } : {}),
18
37
  ...(fs ? { sourceFirst: true } : {}),
19
38
  ...(git ? { git } : {}),
@@ -199,7 +199,7 @@ function registerDocResources() {
199
199
  '2. `kb_coverage` with `{ "by": "req", "includePassing": false }` to review proof rows and `repairPlan`; require `repairPlan.scope.complete` before treating it as a project-wide inventory',
200
200
  "3. Apply only a `repairPlan` batch whose state is `ready`; batches are non-auto-applicable guidance, so query current endpoints, validate every write, and keep `kb_upsert` sequential",
201
201
  "4. Re-run `kb_coverage` after each batch instead of continuing from a stale plan",
202
- '5. `kb_graph` with `{ "seedIds": ["REQ-001"], "direction": "both", "depth": 2 }` to inspect neighboring entities',
202
+ '5. `kb_graph` with `{ "seedIds": ["REQ-cli-gc"], "direction": "both", "depth": 2 }` to inspect neighboring entities',
203
203
  "",
204
204
  "## Add a requirement and link it to a test",
205
205
  '1. `kb_query` with `{ "type": "test" }` to check for existing test IDs',
@@ -88,6 +88,20 @@ export function jsonSchemaToZod(schema) {
88
88
  : typeof obj.type === "string"
89
89
  ? [obj.type]
90
90
  : [];
91
+ // JSON Schema anyOf unions become real Zod unions so MCP output
92
+ // validation enforces the declared alternatives instead of silently
93
+ // degrading to z.any() — e.g. kb_check's synchronous payload versus its
94
+ // kibi.job.v1 async receipt. oneOf is intentionally left degrading: its
95
+ // required-guard branches carry no properties and would loosen input
96
+ // validation instead of tightening it.
97
+ const anyOfVariants = Array.isArray(obj.anyOf) ? obj.anyOf : [];
98
+ if (anyOfVariants.length > 0) {
99
+ const variants = anyOfVariants.map((variant) => jsonSchemaToZod(variant));
100
+ const union = z.union(variants);
101
+ return typeof obj.description === "string"
102
+ ? union.describe(obj.description)
103
+ : union;
104
+ }
91
105
  // JSON Schema nullable fields are represented as a type union in the
92
106
  // catalog. Build a real Zod union so MCP publishes and validates the same
93
107
  // nullability instead of silently degrading the field to z.any().
@@ -27,6 +27,9 @@ export function registerConfiguredTools(server, runtime, registerTool) {
27
27
  name,
28
28
  effects: publicSpec.effects,
29
29
  requiresProlog: publicSpec.requiresProlog,
30
+ ...(publicSpec.agentVisibleStructuredData === true
31
+ ? { agentVisibleStructuredData: true }
32
+ : {}),
30
33
  execute: (args, context) => execute(context, args),
31
34
  };
32
35
  registerTool(server, name, definition.description, definition.inputSchema, async () => undefined, runtime, spec, definition.annotations, definition.outputSchema);
@@ -81,7 +84,7 @@ export function registerConfiguredTools(server, runtime, registerTool) {
81
84
  });
82
85
  register({
83
86
  name: "kb_semantic_advisor",
84
- execute: async (_context, args) => runtime.handleKbSemanticAdvisor(args),
87
+ execute: async (context, args) => runtime.handleKbSemanticAdvisor(args, context),
85
88
  });
86
89
  register({
87
90
  name: "kb_upsert",
@@ -110,11 +113,11 @@ export function registerConfiguredTools(server, runtime, registerTool) {
110
113
  });
111
114
  register({
112
115
  name: "kb_model_requirement",
113
- execute: async (context, args) => runtime.handleKbModelRequirement(prologFor(context), args),
116
+ execute: async (context, args) => runtime.handleKbModelRequirement(prologFor(context), args, withSessionProlog(context)),
114
117
  });
115
118
  register({
116
119
  name: "kb_suggest_predicates",
117
- execute: async (context, args) => runtime.handleKbSuggestPredicates(prologFor(context), args),
120
+ execute: async (context, args) => runtime.handleKbSuggestPredicates(prologFor(context), args, withSessionProlog(context)),
118
121
  });
119
122
  register({
120
123
  name: "kb_plan_bootstrap",
@@ -49,10 +49,15 @@ function adaptProlog(prolog) {
49
49
  if (existing) {
50
50
  return existing;
51
51
  }
52
+ const engine = prolog;
53
+ const queryEntities = engine.queryEntities;
54
+ const searchEntities = engine.searchEntities;
55
+ const storageStatus = engine.storageStatus;
56
+ const queryStatusJson = engine.queryStatusJson;
52
57
  let lastResult = null;
53
58
  const port = {
54
- query: async (goal) => {
55
- lastResult = await prolog.query(goal);
59
+ query: async (goal, signal) => {
60
+ lastResult = await engine.query(goal, signal);
56
61
  return lastResult;
57
62
  },
58
63
  nextSolution: async () => {
@@ -60,35 +65,37 @@ function adaptProlog(prolog) {
60
65
  lastResult = null;
61
66
  return result;
62
67
  },
63
- save: () => prolog.query("kb_save"),
64
- ...(typeof prolog.queryEntities ===
65
- "function"
68
+ save: (signal) => engine.query("kb_save", signal),
69
+ ...(typeof queryEntities === "function"
66
70
  ? {
67
- queryEntities: (input) => prolog.queryEntities(input),
71
+ // The captured method uses .call(engine, ...) below to preserve the
72
+ // EngineClient receiver (unbound queryStatusJson breaks this.command).
73
+ queryEntities: (input, signal) => queryEntities.call(engine, input, signal),
68
74
  }
69
75
  : {}),
70
- ...(typeof prolog.searchEntities ===
71
- "function"
76
+ ...(typeof searchEntities === "function"
72
77
  ? {
73
- searchEntities: (input) => prolog.searchEntities(input),
78
+ searchEntities: (input, signal) => searchEntities.call(engine, input, signal),
74
79
  }
75
80
  : {}),
76
- ...(typeof prolog.storageStatus ===
77
- "function"
81
+ ...(typeof storageStatus === "function"
78
82
  ? {
79
- storageStatus: () => prolog.storageStatus(),
83
+ storageStatus: () => storageStatus.call(engine),
80
84
  }
81
85
  : {}),
82
- ...(typeof prolog.queryStatusJson ===
83
- "function"
86
+ ...(typeof queryStatusJson === "function"
84
87
  ? {
85
- queryStatusJson: () => prolog.queryStatusJson(),
88
+ queryStatusJson: (signal) => queryStatusJson.call(engine, signal),
86
89
  }
87
90
  : {}),
88
91
  };
89
92
  prologPorts.set(prolog, port);
90
93
  return port;
91
94
  }
95
+ /** Test seam: adapt a session engine so AbortSignal reaches EngineClient/PrologPort methods. */
96
+ export function _adaptPrologForTests(prolog) {
97
+ return adaptProlog(prolog);
98
+ }
92
99
  const operationRuntime = createMcpRuntime({
93
100
  workspaceRoot: resolveWorkspaceRoot(),
94
101
  activeBranchName: async () => (await getSessionModule()).getActiveBranchName(),
@@ -28,10 +28,32 @@ export { jsonSchemaToZod } from "./json-schema-to-zod.js";
28
28
  export { _resetSessionModulePromise, _setToolsServerDepsForTests };
29
29
  const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
30
30
  const TOOL_TIMEOUT_ENV = "KIBI_MCP_TOOL_TIMEOUT_MS";
31
- // Give cancellation and the Prolog reset a short bounded grace period, but do
32
- // not hold the MCP request open for the full five-second shutdown budget when
33
- // a handler never observes AbortSignal.
31
+ // Give cancellation a short bounded grace period, but do not hold the MCP
32
+ // request open for the full five-second shutdown budget when a handler never
33
+ // observes AbortSignal.
34
34
  const TOOL_TIMEOUT_GRACE_MS = 100;
35
+ // Mutations that ignore AbortSignal may leave the journal indeterminate. Only
36
+ // then tear down the shared session engine — never on ordinary read timeouts,
37
+ // which would reject sibling tools with "Kibi engine connection closed".
38
+ const TOOL_TIMEOUT_WEDGE_MS = 1_000;
39
+ function isMutationEffect(effects) {
40
+ return (effects?.some((effect) => effect === "kb-write" || effect === "workspace-write") ?? false);
41
+ }
42
+ /** Embed envelope data in content text for opted-in tools (hosts that hide structuredContent).
43
+ * Preserves non-text content parts; only augments/replaces text parts. */
44
+ function withAgentVisibleText(content, data) {
45
+ const textParts = content.filter((part) => part.type === "text" && typeof part.text === "string");
46
+ const nonTextParts = content.filter((part) => part.type !== "text");
47
+ const summary = textParts
48
+ .map((part) => part.text)
49
+ .join(" ")
50
+ .replace(/\s+/g, " ")
51
+ .trim();
52
+ const payload = JSON.stringify(data);
53
+ // Prefer JSON-first so first-line-only host summaries still include machine data.
54
+ const text = summary.length > 0 ? `${payload}\n${summary}` : payload;
55
+ return [{ type: "text", text }, ...nonTextParts];
56
+ }
35
57
  // implements REQ-008
36
58
  function debugLog(...args) {
37
59
  if (isMcpDebugEnabled()) {
@@ -64,27 +86,28 @@ async function withToolTimeout(toolName, operation, onTimeout) {
64
86
  const timeoutMs = getToolTimeoutMs();
65
87
  let timeout;
66
88
  try {
67
- return await Promise.race([
68
- operation,
69
- new Promise((_, reject) => {
70
- timeout = setTimeout(() => {
71
- const error = createToolTimeoutError(toolName, timeoutMs);
72
- // Do not report the timeout until cancellation/reset has completed
73
- // and the original request has reached a terminal state. This is
74
- // especially important for source-first mutations: a caller must
75
- // never retry while the authoritative journal is still deciding its
76
- // outcome.
77
- void onTimeout(error, timeoutMs)
78
- .then(async () => {
79
- await Promise.race([
80
- operation.then(() => undefined, () => undefined),
81
- new Promise((resolve) => setTimeout(resolve, TOOL_TIMEOUT_GRACE_MS)),
82
- ]);
83
- })
84
- .finally(() => reject(error));
85
- }, timeoutMs);
86
- }),
87
- ]);
89
+ const timeoutPromise = new Promise((_, reject) => {
90
+ timeout = setTimeout(() => {
91
+ const error = createToolTimeoutError(toolName, timeoutMs);
92
+ // Do not report the timeout until cancellation/reset has completed
93
+ // and the original request has reached a terminal state. This is
94
+ // especially important for source-first mutations: a caller must
95
+ // never retry while the authoritative journal is still deciding its
96
+ // outcome.
97
+ void onTimeout(error, timeoutMs)
98
+ .then(async () => {
99
+ await Promise.race([
100
+ operation.then(() => undefined, () => undefined),
101
+ new Promise((resolve) => setTimeout(resolve, TOOL_TIMEOUT_GRACE_MS)),
102
+ ]);
103
+ })
104
+ .finally(() => reject(error));
105
+ }, timeoutMs);
106
+ });
107
+ // When abort settles `operation` before this timeout promise rejects,
108
+ // the late reject must not become an unhandled rejection.
109
+ timeoutPromise.catch(() => undefined);
110
+ return await Promise.race([operation, timeoutPromise]);
88
111
  }
89
112
  finally {
90
113
  if (timeout) {
@@ -139,15 +162,30 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
139
162
  try {
140
163
  // Execute handler
141
164
  const result = await withToolTimeout(name, handlerPromise, async (error) => {
142
- resetAttempted = true;
143
165
  controller.abort(error);
166
+ // Read/discovery timeouts must cancel in-flight RPCs without
167
+ // terminating the shared session engine. A global resetProlog here
168
+ // rejects sibling tools with "Kibi engine connection closed".
169
+ if (!isMutationEffect(operationSpec.effects)) {
170
+ return;
171
+ }
172
+ const settled = await Promise.race([
173
+ handlerPromise.then(() => true, () => true),
174
+ new Promise((resolve) => setTimeout(() => resolve(false), TOOL_TIMEOUT_WEDGE_MS)),
175
+ ]);
176
+ if (settled) {
177
+ return;
178
+ }
179
+ resetAttempted = true;
144
180
  try {
145
181
  await runtime.resetProlog(`tool timeout: ${name}`);
146
182
  resetSucceeded = true;
147
183
  }
148
- catch (error) {
184
+ catch (resetFailure) {
149
185
  resetError =
150
- error instanceof Error ? error.message : String(error);
186
+ resetFailure instanceof Error
187
+ ? resetFailure.message
188
+ : String(resetFailure);
151
189
  }
152
190
  });
153
191
  const data = operationData(result);
@@ -169,8 +207,13 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
169
207
  typeof result === "object" &&
170
208
  !Array.isArray(result) &&
171
209
  "content" in result) {
210
+ const withContent = result;
211
+ const content = operationSpec.agentVisibleStructuredData === true
212
+ ? withAgentVisibleText(withContent.content, data)
213
+ : withContent.content;
172
214
  return {
173
215
  ...result,
216
+ content,
174
217
  structuredContent: envelope,
175
218
  };
176
219
  }
@@ -195,7 +238,7 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
195
238
  });
196
239
  }
197
240
  if (isToolTimeoutError(error) &&
198
- operationSpec.effects.some((effect) => effect === "kb-write" || effect === "workspace-write")) {
241
+ isMutationEffect(operationSpec.effects)) {
199
242
  const recoveryActions = [
200
243
  {
201
244
  operation: "kb_status",
@@ -313,7 +356,7 @@ function registerJobStatusTool(server) {
313
356
  content: [{ type: "text", text: JSON.stringify(snapshot) }],
314
357
  structuredContent: snapshot,
315
358
  };
316
- }, undefined, undefined, undefined, {
359
+ }, undefined, undefined, {
317
360
  title: "Poll a Kibi background job",
318
361
  readOnlyHint: true,
319
362
  destructiveHint: false,
@@ -22,7 +22,7 @@ export function hasImpactOptions(args) {
22
22
  args.includeWorkingTreeDiff ||
23
23
  (args.sourceFiles && args.sourceFiles.length > 0));
24
24
  }
25
- export function analyzeKbCheckImpact(workspaceRoot, args) {
25
+ export async function analyzeKbCheckImpact(workspaceRoot, args) {
26
26
  if (!hasImpactOptions(args)) {
27
27
  return undefined;
28
28
  }
@@ -1,12 +1,21 @@
1
1
  import { resolveWorkspaceRoot } from "../workspace.js";
2
2
  export function createDiscoveryContext(prolog, baseContext) {
3
3
  // implements REQ-kibi-operation-interface-parity
4
+ // Keep this adapter aligned with tools-runtime adaptProlog: indexed discovery
5
+ // and typed status must reach EngineClient command frames (not query-only
6
+ // fallbacks), and AbortSignal must forward for MCP tool cancellation.
7
+ const engine = prolog;
8
+ const queryEntities = engine.queryEntities;
9
+ const searchEntities = engine.searchEntities;
10
+ const storageStatus = engine.storageStatus;
11
+ const queryStatusJson = engine.queryStatusJson;
4
12
  let lastResult = null;
5
13
  const mode = prolog
6
14
  .useOneShotMode;
15
+ const signal = baseContext?.signal ?? new AbortController().signal;
7
16
  const port = {
8
- query: async (goal) => {
9
- lastResult = await prolog.query(goal);
17
+ query: async (goal, querySignal) => {
18
+ lastResult = await engine.query(goal, querySignal ?? signal);
10
19
  return lastResult;
11
20
  },
12
21
  oneShotMode: mode === undefined
@@ -17,18 +26,32 @@ export function createDiscoveryContext(prolog, baseContext) {
17
26
  lastResult = null;
18
27
  return result;
19
28
  },
20
- save: () => prolog.query("kb_save"),
21
- ...(typeof prolog.queryStatusJson ===
22
- "function"
29
+ save: (saveSignal) => engine.query("kb_save", saveSignal ?? signal),
30
+ ...(typeof queryEntities === "function"
23
31
  ? {
24
- queryStatusJson: () => prolog.queryStatusJson(),
32
+ queryEntities: (input, querySignal) => queryEntities.call(engine, input, querySignal ?? signal),
33
+ }
34
+ : {}),
35
+ ...(typeof searchEntities === "function"
36
+ ? {
37
+ searchEntities: (input, querySignal) => searchEntities.call(engine, input, querySignal ?? signal),
38
+ }
39
+ : {}),
40
+ ...(typeof storageStatus === "function"
41
+ ? {
42
+ storageStatus: () => storageStatus.call(engine),
43
+ }
44
+ : {}),
45
+ ...(typeof queryStatusJson === "function"
46
+ ? {
47
+ queryStatusJson: (statusSignal) => queryStatusJson.call(engine, statusSignal ?? signal),
25
48
  }
26
49
  : {}),
27
50
  };
28
51
  return {
29
52
  ...(baseContext ?? {}),
30
53
  workspaceRoot: baseContext?.workspaceRoot ?? resolveWorkspaceRoot(),
31
- signal: baseContext?.signal ?? new AbortController().signal,
54
+ signal,
32
55
  clock: baseContext?.clock ?? (() => new Date()),
33
56
  prolog: port,
34
57
  };
@@ -2,11 +2,11 @@ import { modelRequirementSpec } from "kibi-runtime";
2
2
  import { estimateNormativeSignalConfidence, extractRequirementClaim, getWorkspaceMigrationWarning, strictWriteSetToApplyPlan, writeSetPrimaryEntityId, } from "kibi-runtime";
3
3
  import { resolveWorkspaceRoot } from "../workspace.js";
4
4
  export { estimateNormativeSignalConfidence, extractRequirementClaim, getWorkspaceMigrationWarning, strictWriteSetToApplyPlan, writeSetPrimaryEntityId, };
5
- export async function handleKbModelRequirement(_prolog, args) {
6
- const context = {
5
+ export async function handleKbModelRequirement(_prolog, args, context) {
6
+ const resolved = context ?? {
7
7
  workspaceRoot: resolveWorkspaceRoot(),
8
8
  signal: new AbortController().signal,
9
9
  clock: () => new Date(),
10
10
  };
11
- return modelRequirementSpec.execute(args, context);
11
+ return modelRequirementSpec.execute(args, resolved);
12
12
  }
@@ -1,4 +1,4 @@
1
1
  import { executeSemanticAdvisor } from "kibi-runtime";
2
- export async function handleKbSemanticAdvisor(args) {
3
- return executeSemanticAdvisor(args);
2
+ export async function handleKbSemanticAdvisor(args, context) {
3
+ return executeSemanticAdvisor(args, context);
4
4
  }
@@ -1,13 +1,14 @@
1
1
  import { suggestPredicatesSpec } from "kibi-runtime";
2
2
  import { resolveWorkspaceRoot } from "../workspace.js";
3
3
  import { createDiscoveryContext } from "./discovery-adapter.js";
4
- export async function handleKbSuggestPredicates(prolog, args) {
5
- const context = prolog === null
6
- ? {
7
- workspaceRoot: resolveWorkspaceRoot(),
8
- signal: new AbortController().signal,
9
- clock: () => new Date(),
10
- }
11
- : createDiscoveryContext(prolog);
12
- return suggestPredicatesSpec.execute(args, context);
4
+ export async function handleKbSuggestPredicates(prolog, args, context) {
5
+ const resolved = context ??
6
+ (prolog === null
7
+ ? {
8
+ workspaceRoot: resolveWorkspaceRoot(),
9
+ signal: new AbortController().signal,
10
+ clock: () => new Date(),
11
+ }
12
+ : createDiscoveryContext(prolog));
13
+ return suggestPredicatesSpec.execute(args, resolved);
13
14
  }
package/dist/workspace.js CHANGED
@@ -13,32 +13,17 @@
13
13
  GNU Affero General Public License for more details.
14
14
 
15
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/>.
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
- import fs from "node:fs";
19
18
  import path from "node:path";
20
- import { branchStorePath } from "kibi-runtime";
21
- const WORKSPACE_ENV_KEYS = [
22
- "KIBI_WORKSPACE",
23
- "KIBI_PROJECT_ROOT",
24
- "KIBI_ROOT",
25
- ];
19
+ import { branchStorePath, resolveKibiWorkspaceRoot } from "kibi-runtime";
26
20
  const KB_PATH_ENV_KEYS = ["KIBI_KB_PATH", "KB_PATH"];
27
- // implements REQ-002, REQ-012
21
+ /**
22
+ * Canonical workspace root — delegates to shared resolveKibiWorkspaceRoot.
23
+ */
24
+ // implements REQ-002, REQ-012, REQ-kibi-env-bootstrap
28
25
  export function resolveWorkspaceRoot(startDir = process.cwd()) {
29
- const envRoot = readFirstEnv(WORKSPACE_ENV_KEYS);
30
- if (envRoot) {
31
- return path.resolve(envRoot);
32
- }
33
- const kbRoot = findUpwards(startDir, ".kb");
34
- if (kbRoot) {
35
- return kbRoot;
36
- }
37
- const gitRoot = findUpwards(startDir, ".git");
38
- if (gitRoot) {
39
- return gitRoot;
40
- }
41
- return path.resolve(startDir);
26
+ return resolveKibiWorkspaceRoot(startDir);
42
27
  }
43
28
  // implements REQ-002, REQ-012
44
29
  export function resolveKbPath(workspaceRoot, branch) {
@@ -72,17 +57,6 @@ export function nextAncestorDirectory(current) {
72
57
  const parent = path.dirname(current);
73
58
  return parent === current ? undefined : parent;
74
59
  }
75
- function findUpwards(startDir, marker) {
76
- let current = path.resolve(startDir);
77
- while (current !== undefined) {
78
- const candidate = path.join(current, marker);
79
- if (fs.existsSync(candidate)) {
80
- return current;
81
- }
82
- current = nextAncestorDirectory(current);
83
- }
84
- return null;
85
- }
86
60
  function isBranchPath(p) {
87
61
  const parent = path.basename(path.dirname(p));
88
62
  return parent === "branches";
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
+ "mcpName": "io.github.looted/kibi-mcp",
4
5
  "dependencies": {
5
6
  "@modelcontextprotocol/sdk": "^1.30.0",
6
7
  "ajv": "^8.18.0",
@@ -9,10 +10,9 @@
9
10
  "fast-glob": "^3.2.12",
10
11
  "gray-matter": "^4.0.3",
11
12
  "js-yaml": "^4.1.0",
12
- "kibi-runtime": "^2.0.0",
13
- "kibi-core": "^0.12.0",
13
+ "kibi-runtime": "^2.0.1",
14
+ "kibi-core": "^0.13.0",
14
15
  "mcpcat": "^0.1.12",
15
- "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"
17
17
  },
18
18
  "type": "module",
@@ -29,10 +29,7 @@
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": [
33
- "dist",
34
- "bin"
35
- ],
32
+ "files": ["dist", "bin"],
36
33
  "engines": {
37
34
  "node": ">=22"
38
35
  },