kibi-mcp 3.1.2 → 3.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.
@@ -64,6 +64,7 @@ export function createMcpRuntime(session) {
64
64
  ...(fs ? { sourceFirst: true } : {}),
65
65
  ...(git ? { git } : {}),
66
66
  ...(net ? { net } : {}),
67
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
67
68
  };
68
69
  const resolved = resolveMcpAttachment(context.workspaceRoot, spec);
69
70
  const attachment = resolved instanceof Promise ? await resolved : resolved;
@@ -13,23 +13,92 @@ export function singleLiteralOrAny(literalSchemas, description) {
13
13
  }
14
14
  return description ? single.describe(description) : single;
15
15
  }
16
- function hasRequiredProperties(value, schema) {
17
- if (schema === null || typeof schema !== "object")
16
+ function matchesJsonType(value, type) {
17
+ switch (type) {
18
+ case "object":
19
+ return (value !== null && typeof value === "object" && !Array.isArray(value));
20
+ case "array":
21
+ return Array.isArray(value);
22
+ case "string":
23
+ return typeof value === "string";
24
+ case "number":
25
+ return typeof value === "number" && Number.isFinite(value);
26
+ case "integer":
27
+ return typeof value === "number" && Number.isInteger(value);
28
+ case "boolean":
29
+ return typeof value === "boolean";
30
+ case "null":
31
+ return value === null;
32
+ default:
33
+ return true;
34
+ }
35
+ }
36
+ function sameJsonValue(left, right) {
37
+ return JSON.stringify(left) === JSON.stringify(right);
38
+ }
39
+ // Evaluates a JSON Schema `if` condition the way a JSON Schema validator
40
+ // would for the keywords Kibi's operation schemas use in conditionals:
41
+ // boolean schemas, type, const, enum, required, properties (applied only to
42
+ // present keys), not, anyOf, allOf and oneOf. Keywords outside this set do not
43
+ // constrain the condition. The CLI validates the same schemas with ajv, so
44
+ // this must stay faithful: a mismatch makes MCP reject payloads the CLI
45
+ // accepts (or the reverse).
46
+ // implements REQ-002, REQ-mcp-conditional-input-validation
47
+ export function matchesJsonSchemaCondition(value, schema) {
48
+ if (schema === true || schema === undefined)
49
+ return true;
50
+ if (schema === false)
18
51
  return false;
52
+ if (schema === null || typeof schema !== "object")
53
+ return true;
19
54
  const condition = schema;
20
- const required = Array.isArray(condition.required)
21
- ? condition.required.filter((key) => typeof key === "string" && key.length > 0)
22
- : [];
23
- if (required.length > 0 &&
24
- !required.every((key) => Object.hasOwn(value, key))) {
55
+ const types = Array.isArray(condition.type)
56
+ ? condition.type.filter((entry) => typeof entry === "string")
57
+ : typeof condition.type === "string"
58
+ ? [condition.type]
59
+ : [];
60
+ if (types.length > 0 && !types.some((type) => matchesJsonType(value, type))) {
61
+ return false;
62
+ }
63
+ if (Object.hasOwn(condition, "const") &&
64
+ !sameJsonValue(value, condition.const)) {
65
+ return false;
66
+ }
67
+ if (Array.isArray(condition.enum) &&
68
+ !condition.enum.some((entry) => sameJsonValue(value, entry))) {
69
+ return false;
70
+ }
71
+ const isObject = value !== null && typeof value === "object" && !Array.isArray(value);
72
+ if (isObject) {
73
+ const record = value;
74
+ const required = Array.isArray(condition.required)
75
+ ? condition.required.filter((key) => typeof key === "string" && key.length > 0)
76
+ : [];
77
+ if (!required.every((key) => Object.hasOwn(record, key)))
78
+ return false;
79
+ if (condition.properties && typeof condition.properties === "object") {
80
+ for (const [key, propertySchema] of Object.entries(condition.properties)) {
81
+ if (Object.hasOwn(record, key) &&
82
+ !matchesJsonSchemaCondition(record[key], propertySchema)) {
83
+ return false;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ if ("not" in condition && matchesJsonSchemaCondition(value, condition.not)) {
25
89
  return false;
26
90
  }
27
91
  if (Array.isArray(condition.anyOf) &&
28
- !condition.anyOf.some((entry) => hasRequiredProperties(value, entry))) {
92
+ !condition.anyOf.some((entry) => matchesJsonSchemaCondition(value, entry))) {
29
93
  return false;
30
94
  }
31
95
  if (Array.isArray(condition.allOf) &&
32
- !condition.allOf.every((entry) => hasRequiredProperties(value, entry))) {
96
+ !condition.allOf.every((entry) => matchesJsonSchemaCondition(value, entry))) {
97
+ return false;
98
+ }
99
+ if (Array.isArray(condition.oneOf) &&
100
+ condition.oneOf.filter((entry) => matchesJsonSchemaCondition(value, entry))
101
+ .length !== 1) {
33
102
  return false;
34
103
  }
35
104
  return true;
@@ -40,7 +109,7 @@ function conditionalRequiredKeys(value, condition) {
40
109
  const rule = condition;
41
110
  if (!("if" in rule) || !("then" in rule))
42
111
  return [];
43
- if (!hasRequiredProperties(value, rule.if))
112
+ if (!matchesJsonSchemaCondition(value, rule.if))
44
113
  return [];
45
114
  if (rule.then === null || typeof rule.then !== "object")
46
115
  return [];
@@ -150,9 +150,33 @@ export function registerConfiguredTools(server, runtime, registerTool) {
150
150
  });
151
151
  register({
152
152
  name: "kb_apply_plan",
153
- execute: async (context, args) => runtime.handleKbApplyPlan
154
- ? runtime.handleKbApplyPlan(args, context)
155
- : executeApplyPlan(args, context),
153
+ execute: async (context, args) => {
154
+ const { async: asyncMode, ...applyArgs } = args;
155
+ const apply = (applyContext) => runtime.handleKbApplyPlan
156
+ ? runtime.handleKbApplyPlan(applyArgs, applyContext)
157
+ : executeApplyPlan(applyArgs, applyContext);
158
+ // Without a registered kb_job_status nothing could poll the receipt,
159
+ // so async falls back to a synchronous apply (which still reports
160
+ // progress when the request carries a progressToken).
161
+ if (asyncMode !== true || !enabledOptionalTools().has("kb_job_status")) {
162
+ return apply(context);
163
+ }
164
+ // A large bootstrap plan can outlast any client request timeout.
165
+ // Detach it into a job and return the kibi.job.v1 receipt; the agent
166
+ // polls kb_job_status. The request is over once the receipt returns,
167
+ // so the job reports no progress and refreshes the branch stamp itself.
168
+ const { onProgress: _requestProgress, ...jobContext } = context;
169
+ return startJob("kb_apply_plan", async () => {
170
+ const result = await apply(jobContext);
171
+ await runtime.operationRuntime.afterSuccess({
172
+ name: "kb_apply_plan",
173
+ effects: getSpec("kb_apply_plan").effects,
174
+ requiresProlog: true,
175
+ execute: async () => undefined,
176
+ }, jobContext);
177
+ return result;
178
+ });
179
+ },
156
180
  });
157
181
  register({
158
182
  name: "kb_ingest_proof",
@@ -85,12 +85,14 @@ function isToolTimeoutError(value) {
85
85
  value.code === "KIBI_TOOL_TIMEOUT");
86
86
  }
87
87
  // implements REQ-002
88
- async function withToolTimeout(toolName, operation, onTimeout) {
88
+ async function withToolTimeout(toolName, operation, onTimeout, activity) {
89
89
  const timeoutMs = getToolTimeoutMs();
90
90
  let timeout;
91
+ let fired = false;
91
92
  try {
92
93
  const timeoutPromise = new Promise((_, reject) => {
93
- timeout = setTimeout(() => {
94
+ const fire = () => {
95
+ fired = true;
94
96
  const error = createToolTimeoutError(toolName, timeoutMs);
95
97
  // Do not report the timeout until cancellation/reset has completed
96
98
  // and the original request has reached a terminal state. This is
@@ -105,7 +107,17 @@ async function withToolTimeout(toolName, operation, onTimeout) {
105
107
  ]);
106
108
  })
107
109
  .finally(() => reject(error));
108
- }, timeoutMs);
110
+ };
111
+ timeout = setTimeout(fire, timeoutMs);
112
+ if (activity) {
113
+ activity.touch = () => {
114
+ if (fired)
115
+ return;
116
+ if (timeout)
117
+ clearTimeout(timeout);
118
+ timeout = setTimeout(fire, timeoutMs);
119
+ };
120
+ }
109
121
  });
110
122
  // When abort settles `operation` before this timeout promise rejects,
111
123
  // the late reject must not become an unhandled rejection.
@@ -118,6 +130,35 @@ async function withToolTimeout(toolName, operation, onTimeout) {
118
130
  }
119
131
  }
120
132
  }
133
+ /**
134
+ * Forward operation progress as MCP notifications/progress when the request
135
+ * carries a progressToken, and push the server-side tool timeout back on each
136
+ * report. Progress values must increase, so repeats are nudged forward.
137
+ */
138
+ // implements REQ-bootstrap-apply-long-running
139
+ export function progressReporter(extra, activity) {
140
+ const token = extra?._meta?.progressToken;
141
+ const send = extra?.sendNotification;
142
+ if (token === undefined || typeof send !== "function")
143
+ return undefined;
144
+ let last = -1;
145
+ return ({ progress, total, message }) => {
146
+ activity.touch();
147
+ const value = progress > last ? progress : last + 0.001;
148
+ last = value;
149
+ void send({
150
+ method: "notifications/progress",
151
+ params: {
152
+ progressToken: token,
153
+ progress: value,
154
+ ...(total === undefined ? {} : { total }),
155
+ ...(message === undefined ? {} : { message }),
156
+ },
157
+ }).catch((error) => {
158
+ debugLog("[KIBI-MCP] progress notification failed:", error instanceof Error ? error.message : String(error));
159
+ });
160
+ };
161
+ }
121
162
  /**
122
163
  * A module that vanished after startup means the package was upgraded or
123
164
  * reinstalled under a running server; only a restart loads the new install.
@@ -177,7 +218,7 @@ export function addTool(server, name, description, inputSchema, handler,
177
218
  // generic TProlog parameter exists so tests can inject a mock type. The cast is safe
178
219
  // because the runtime object satisfies the full ToolsRuntime contract at runtime.
179
220
  runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
180
- const wrappedHandler = async (rawArgs) => {
221
+ const wrappedHandler = async (rawArgs, extra) => {
181
222
  const startedAt = new Date();
182
223
  const diagnosticModeEnabled = runtime.diagnosticModeEnabled();
183
224
  let args = rawArgs;
@@ -228,7 +269,12 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
228
269
  execute: async (input, _context) => handler(input),
229
270
  };
230
271
  const observed = observeDetachedReads(runtime.operationRuntime);
231
- const handlerPromise = executeOperation(observed.runtime, operationSpec, businessArgs, { signal: controller.signal });
272
+ const activity = { touch: () => undefined };
273
+ const onProgress = progressReporter(extra, activity);
274
+ const handlerPromise = executeOperation(observed.runtime, operationSpec, businessArgs, {
275
+ signal: controller.signal,
276
+ ...(onProgress ? { onProgress } : {}),
277
+ });
232
278
  trackedRequests.set(requestId, handlerPromise);
233
279
  let resetAttempted = false;
234
280
  let resetSucceeded = false;
@@ -261,7 +307,7 @@ runtime = DEFAULT_TOOLS_RUNTIME, spec, annotations, outputSchema) {
261
307
  ? resetFailure.message
262
308
  : String(resetFailure);
263
309
  }
264
- });
310
+ }, activity);
265
311
  const data = operationData(result);
266
312
  const envelope = withWorkspaceNotice(withWorkspaceNotice(toKibiResult(operationSpec, data), workspaceNotice), observed.notice());
267
313
  // Log usage in diagnostic mode
@@ -414,12 +460,12 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
414
460
  }
415
461
  /**
416
462
  * kb_job_status polls background jobs started by long-running operations
417
- * (currently `kb_check` with `async: true`). This is an MCP-server-native
463
+ * (`kb_check` and `kb_apply_plan` with `async: true`). This is an MCP-server-native
418
464
  * companion to the job receipts, not part of the canonical CLI operation
419
465
  * catalog: jobs live in the server process and have no CLI counterpart.
420
466
  */
421
467
  function registerJobStatusTool(server) {
422
- addTool(server, "kb_job_status", "Poll a background job started with async:true (e.g. kb_check async jobs). Returns the kibi.job.v1 state: running, succeeded (with the full result), or failed (with the error).", {
468
+ addTool(server, "kb_job_status", "Poll a background job started with async:true (kb_check or kb_apply_plan async jobs). Returns the kibi.job.v1 state: running, succeeded (with the full result), or failed (with the error).", {
423
469
  type: "object",
424
470
  properties: {
425
471
  jobId: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "3.1.2",
3
+ "version": "3.4.0",
4
4
  "mcpName": "io.github.Looted/kibi-mcp",
5
5
  "dependencies": {
6
6
  "@modelcontextprotocol/sdk": "^1.32.1",
@@ -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.4.1",
14
- "kibi-core": "^0.15.2",
13
+ "kibi-runtime": "^2.7.0",
14
+ "kibi-core": "^0.17.0",
15
15
  "zod": "^4.3.6"
16
16
  },
17
17
  "type": "module",