kibi-mcp 0.3.1 → 0.3.3

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.
@@ -0,0 +1,95 @@
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 fs from "node:fs";
19
+ import path from "node:path";
20
+ import { resolveWorkspaceRoot } from "./workspace.js";
21
+ const DIAGNOSTIC_MODE_FLAG = "--diagnostic-mode";
22
+ /**
23
+ * Whether diagnostic mode is enabled via CLI flag.
24
+ * Set during server startup and never changes at runtime.
25
+ */
26
+ export const DIAGNOSTIC_MODE_ENABLED = process.argv.includes(DIAGNOSTIC_MODE_FLAG);
27
+ /**
28
+ * Path to the diagnostic usage log file.
29
+ * Only valid when DIAGNOSTIC_MODE_ENABLED is true.
30
+ */
31
+ let diagnosticUsageLogPath = null;
32
+ /**
33
+ * Initialize diagnostic mode: set up usage.log path.
34
+ * Called once during server startup.
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";
41
+ }
42
+ }
43
+ /**
44
+ * Append a JSON line to the usage.log file.
45
+ * No-op if diagnostic mode is not enabled.
46
+ */
47
+ export function appendUsageLogLine(entry) {
48
+ if (!DIAGNOSTIC_MODE_ENABLED || !diagnosticUsageLogPath) {
49
+ return;
50
+ }
51
+ const logDir = path.dirname(diagnosticUsageLogPath);
52
+ fs.mkdirSync(logDir, { recursive: true });
53
+ fs.appendFileSync(diagnosticUsageLogPath, `${JSON.stringify(entry)}\n`, {
54
+ encoding: "utf8",
55
+ });
56
+ }
57
+ /**
58
+ * Schema for _diagnostic_telemetry field added to tool inputs in diagnostic mode.
59
+ */
60
+ export const DIAGNOSTIC_TELEMETRY_SCHEMA = {
61
+ type: "object",
62
+ description: "REQUIRED when diagnostic mode is on. Provide self-reflection metadata about this tool call.",
63
+ properties: {
64
+ is_autonomous: {
65
+ type: "boolean",
66
+ description: "Set to TRUE if you are calling this tool on your own initiative to retrieve context. Set to FALSE if the user explicitly commanded you to use the knowledge base.",
67
+ },
68
+ reasoning: {
69
+ type: "string",
70
+ description: "A brief, 1-2 sentence internal thought explaining exactly why you are calling this tool right now and what information you expect to get.",
71
+ },
72
+ confidence_score: {
73
+ type: "number",
74
+ description: "A score from 0.0 to 1.0 representing your confidence that the exact parameters, IDs, or tags you provided will yield a successful result.",
75
+ },
76
+ attempt_number: {
77
+ type: "integer",
78
+ description: "If you are retrying this exact task because a previous tool call failed or returned empty results, increment this number (start at 1).",
79
+ },
80
+ missing_context: {
81
+ type: "string",
82
+ description: "If you had to split your task into multiple steps because this tool lacks a specific filtering or querying capability, describe what parameter is missing. Otherwise, leave empty.",
83
+ },
84
+ },
85
+ };
86
+ /**
87
+ * Extract business args and telemetry from tool call arguments.
88
+ */
89
+ export function extractToolCallPayload(args) {
90
+ const { _diagnostic_telemetry, ...businessArgs } = args;
91
+ const telemetry = _diagnostic_telemetry && typeof _diagnostic_telemetry === "object"
92
+ ? _diagnostic_telemetry
93
+ : null;
94
+ return { businessArgs, telemetry };
95
+ }
@@ -101,6 +101,7 @@ export async function initiateGracefulShutdown(exitCode = 0) {
101
101
  // Exit
102
102
  process.exit(exitCode);
103
103
  }
104
+ // implements REQ-008
104
105
  async function ensurePrologUnsafe() {
105
106
  const workspaceRoot = resolveWorkspaceRoot();
106
107
  // Determine target branch: respect KIBI_BRANCH override or resolve from git
@@ -131,7 +132,11 @@ async function ensurePrologUnsafe() {
131
132
  }
132
133
  // Branch changed - need to detach and re-attach
133
134
  debugLog(`[KIBI-MCP] Branch changed: ${activeBranchName} -> ${targetBranch}`);
134
- // Detach from old KB
135
+ // Persist and detach from old KB
136
+ const saveResult = await prologProcess.query("kb_save");
137
+ if (!saveResult.success) {
138
+ throw new Error(`Failed to save old KB before detach: ${saveResult.error || "Unknown error"}`);
139
+ }
135
140
  const detachResult = await prologProcess.query("kb_detach");
136
141
  if (!detachResult.success) {
137
142
  debugLog(`[KIBI-MCP] Warning: failed to detach from old KB: ${detachResult.error || "Unknown error"}`);
@@ -17,12 +17,13 @@
17
17
  */
18
18
  import process from "node:process";
19
19
  import { z } from "zod";
20
+ import { DIAGNOSTIC_MODE_ENABLED, appendUsageLogLine, extractToolCallPayload, } from "../diagnostics.js";
20
21
  import { TOOLS } from "../tools-config.js";
21
22
  import { handleKbCheck } from "../tools/check.js";
22
23
  import { handleKbDelete } from "../tools/delete.js";
23
24
  import { handleKbQuery } from "../tools/query.js";
24
25
  import { handleKbUpsert } from "../tools/upsert.js";
25
- import { ensureProlog, inFlightRequests, isShuttingDown } from "./session.js";
26
+ import { activeBranchName, ensureProlog, inFlightRequests, isShuttingDown, prologProcess, } from "./session.js";
26
27
  const ACTIVE_TOOLS = TOOLS;
27
28
  function debugLog(...args) {
28
29
  if (process.env.KIBI_MCP_DEBUG) {
@@ -131,12 +132,16 @@ export function jsonSchemaToZod(schema) {
131
132
  }
132
133
  function addTool(server, name, description, inputSchema, handler) {
133
134
  const wrappedHandler = async (args) => {
135
+ const startedAt = new Date();
136
+ // Extract telemetry in diagnostic mode
137
+ const { businessArgs, telemetry } = DIAGNOSTIC_MODE_ENABLED
138
+ ? extractToolCallPayload(args)
139
+ : { businessArgs: args, telemetry: null };
134
140
  try {
135
141
  // Validate that args is a valid object
136
142
  if (typeof args !== "object" || args === null) {
137
143
  throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
138
144
  }
139
- const businessArgs = args;
140
145
  // Check if shutting down before processing
141
146
  if (isShuttingDown) {
142
147
  throw new Error(`Tool ${name} rejected: server is shutting down`);
@@ -155,7 +160,47 @@ function addTool(server, name, description, inputSchema, handler) {
155
160
  inFlightRequests.set(requestId, handlerPromise);
156
161
  try {
157
162
  // Execute handler
158
- return await handlerPromise;
163
+ const result = await handlerPromise;
164
+ // Log usage in diagnostic mode
165
+ if (DIAGNOSTIC_MODE_ENABLED) {
166
+ const finishedAt = new Date();
167
+ appendUsageLogLine({
168
+ timestamp: finishedAt.toISOString(),
169
+ request_id: requestId,
170
+ tool: name,
171
+ telemetry,
172
+ business_args: businessArgs,
173
+ status: "success",
174
+ started_at: startedAt.toISOString(),
175
+ finished_at: finishedAt.toISOString(),
176
+ duration_ms: finishedAt.getTime() - startedAt.getTime(),
177
+ prolog_pid: prologProcess?.getPid() ?? null,
178
+ active_branch: activeBranchName,
179
+ });
180
+ }
181
+ return result;
182
+ }
183
+ catch (error) {
184
+ // Log error in diagnostic mode
185
+ if (DIAGNOSTIC_MODE_ENABLED) {
186
+ const finishedAt = new Date();
187
+ const err = error instanceof Error ? error : new Error(String(error));
188
+ appendUsageLogLine({
189
+ timestamp: finishedAt.toISOString(),
190
+ request_id: requestId,
191
+ tool: name,
192
+ telemetry,
193
+ business_args: businessArgs,
194
+ status: "error",
195
+ started_at: startedAt.toISOString(),
196
+ finished_at: finishedAt.toISOString(),
197
+ duration_ms: finishedAt.getTime() - startedAt.getTime(),
198
+ prolog_pid: prologProcess?.getPid() ?? null,
199
+ active_branch: activeBranchName,
200
+ error_message: err.message,
201
+ });
202
+ }
203
+ throw error;
159
204
  }
160
205
  finally {
161
206
  // Always clean up from Map when done (success or failure)
package/dist/server.js CHANGED
@@ -15,17 +15,24 @@
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 { readFileSync } from "node:fs";
18
19
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
+ import { initializeDiagnosticMode } from "./diagnostics.js";
20
22
  import { loadDefaultEnvFile } from "./env.js";
21
23
  import { setupDocsAndPrompts } from "./server/docs.js";
22
24
  import { registerAllTools } from "./server/tools.js";
23
25
  import { connectTransport, setupTransportHandlers, } from "./server/transport.js";
26
+ // Read version from package.json to prevent drift
27
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
28
+ const VERSION = packageJson.version ?? "0.1.0";
24
29
  export async function startServer() {
25
30
  // Load environment configuration
26
31
  loadDefaultEnvFile();
32
+ // Initialize diagnostic mode if --diagnostic-mode flag is present
33
+ initializeDiagnosticMode();
27
34
  // Create MCP server
28
- const server = new McpServer({ name: "kibi-mcp", version: "0.2.1" });
35
+ const server = new McpServer({ name: "kibi-mcp", version: VERSION });
29
36
  // Setup documentation resources and prompts
30
37
  setupDocsAndPrompts(server);
31
38
  // Register all KB tools
@@ -14,11 +14,11 @@
14
14
 
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
- */
18
- import { existsSync } from "node:fs";
17
+ */
18
+ import { existsSync, readFileSync } from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import * as path from "node:path";
21
- import { parsePairList } from "./prolog-list.js";
21
+ import { resolveWorkspaceRoot } from "../workspace.js";
22
22
  const require = createRequire(import.meta.url);
23
23
  function resolveChecksPlPath() {
24
24
  const overrideChecksPath = process.env.KIBI_CHECKS_PL_PATH;
@@ -38,6 +38,23 @@ function resolveChecksPlPath() {
38
38
  }
39
39
  throw new Error("Unable to resolve checks.pl path");
40
40
  }
41
+ const ALL_RULES = [
42
+ "must-priority-coverage",
43
+ "no-dangling-refs",
44
+ "no-cycles",
45
+ "required-fields",
46
+ "symbol-coverage",
47
+ "symbol-traceability",
48
+ "deprecated-adr-no-successor",
49
+ "domain-contradictions",
50
+ ];
51
+ const RULE_NAMES = new Set(ALL_RULES);
52
+ const DEFAULT_CHECKS_CONFIG = {
53
+ rules: Object.fromEntries(ALL_RULES.map((rule) => [rule, true])),
54
+ symbolTraceability: {
55
+ requireAdr: false,
56
+ },
57
+ };
41
58
  function formatDiagnosticsForMcp(diagnostics) {
42
59
  return diagnostics.map((d) => ({
43
60
  category: d.category,
@@ -47,85 +64,96 @@ function formatDiagnosticsForMcp(diagnostics) {
47
64
  suggestion: d.suggestion,
48
65
  }));
49
66
  }
67
+ // implements REQ-002
68
+ function loadChecksConfig(workspaceRoot) {
69
+ const configPath = path.join(workspaceRoot, ".kb", "config.json");
70
+ if (!existsSync(configPath)) {
71
+ return DEFAULT_CHECKS_CONFIG;
72
+ }
73
+ try {
74
+ const content = readFileSync(configPath, "utf8");
75
+ const parsed = JSON.parse(content);
76
+ const parsedRules = parsed.checks?.rules;
77
+ const normalizedRules = {
78
+ ...DEFAULT_CHECKS_CONFIG.rules,
79
+ };
80
+ if (parsedRules && typeof parsedRules === "object") {
81
+ for (const [key, value] of Object.entries(parsedRules)) {
82
+ if (typeof value === "boolean") {
83
+ normalizedRules[key] = value;
84
+ }
85
+ // Ignore non-boolean values (they are not added to normalizedRules, preserving defaults)
86
+ }
87
+ }
88
+ const parsedSt = parsed.checks?.symbolTraceability;
89
+ const normalizedSt = { ...DEFAULT_CHECKS_CONFIG.symbolTraceability };
90
+ if (parsedSt && typeof parsedSt === "object") {
91
+ if (typeof parsedSt.requireAdr === "boolean") {
92
+ normalizedSt.requireAdr = parsedSt.requireAdr;
93
+ }
94
+ }
95
+ return {
96
+ rules: normalizedRules,
97
+ symbolTraceability: normalizedSt,
98
+ };
99
+ }
100
+ catch {
101
+ return DEFAULT_CHECKS_CONFIG;
102
+ }
103
+ }
104
+ // implements REQ-002
105
+ function getEffectiveRules(configRules, requestedRules) {
106
+ const effective = new Set();
107
+ for (const rule of ALL_RULES) {
108
+ const enabled = configRules[rule] ?? true;
109
+ if (enabled) {
110
+ effective.add(rule);
111
+ }
112
+ }
113
+ if (requestedRules && requestedRules.length > 0) {
114
+ const allowed = new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
115
+ for (const rule of Array.from(effective)) {
116
+ if (!allowed.has(rule)) {
117
+ effective.delete(rule);
118
+ }
119
+ }
120
+ }
121
+ return effective;
122
+ }
50
123
  /**
51
124
  * Handle kb_check tool calls - run validation rules on the KB
52
125
  * Reuses validation logic from CLI check command
53
126
  */
127
+ // implements REQ-002
54
128
  export async function handleKbCheck(prolog, args) {
55
129
  const { rules } = args;
56
130
  try {
57
- const violations = [];
58
- let allEntityIds = null;
59
- // Run all validation rules (or specific rules if provided)
60
- const allRules = [
61
- "must-priority-coverage",
62
- "no-dangling-refs",
63
- "no-cycles",
64
- "required-fields",
65
- "symbol-coverage",
66
- "symbol-traceability",
67
- "deprecated-adr-no-successor",
68
- "domain-contradictions",
69
- ];
70
- const rulesToRun = rules && rules.length > 0 ? rules : allRules;
71
- const rulesAllowlist = new Set(rulesToRun);
72
- const aggregatedViolations = await runAggregatedChecks(prolog, rulesAllowlist);
73
- if (aggregatedViolations) {
74
- const diagnostics = aggregatedViolations.map((v) => ({
75
- category: "SYNC_ERROR",
76
- severity: "error",
77
- message: v.description,
78
- file: v.source,
79
- suggestion: v.suggestion,
80
- }));
81
- const summary = aggregatedViolations.length === 0
82
- ? "No violations found"
83
- : `${aggregatedViolations.length} violations found`;
131
+ const workspaceRoot = resolveWorkspaceRoot();
132
+ const checksConfig = loadChecksConfig(workspaceRoot);
133
+ const rulesAllowlist = getEffectiveRules(checksConfig.rules, rules);
134
+ if (rulesAllowlist.size === 0) {
84
135
  return {
85
- content: [
86
- {
87
- type: "text",
88
- text: summary,
89
- },
90
- ],
136
+ content: [{ type: "text", text: "No violations found" }],
91
137
  structuredContent: {
92
- violations: aggregatedViolations,
93
- count: aggregatedViolations.length,
94
- diagnostics: formatDiagnosticsForMcp(diagnostics),
138
+ violations: [],
139
+ count: 0,
140
+ diagnostics: [],
95
141
  },
96
142
  };
97
143
  }
98
- if (rulesToRun.includes("must-priority-coverage")) {
99
- violations.push(...(await checkMustPriorityCoverage(prolog)));
100
- }
101
- if (rulesToRun.includes("no-dangling-refs")) {
102
- violations.push(...(await checkNoDanglingRefs(prolog)));
103
- }
104
- if (rulesToRun.includes("no-cycles")) {
105
- violations.push(...(await checkNoCycles(prolog)));
106
- }
107
- if (rulesToRun.includes("required-fields")) {
108
- if (!allEntityIds) {
109
- allEntityIds = await getAllEntityIds(prolog);
110
- }
111
- violations.push(...(await checkRequiredFields(prolog, allEntityIds)));
112
- }
113
- if (rulesToRun.includes("symbol-coverage")) {
114
- violations.push(...(await checkSymbolCoverage(prolog)));
115
- }
116
- if (rulesToRun.includes("symbol-traceability")) {
117
- violations.push(...(await checkSymbolTraceability(prolog, false)));
118
- }
119
- const diagnostics = violations.map((v) => ({
144
+ // Run aggregated checks using same approach as CLI
145
+ // This now runs ALL rules including symbol-traceability
146
+ const aggregatedViolations = await runAggregatedChecks(prolog, rulesAllowlist, checksConfig.symbolTraceability.requireAdr);
147
+ const diagnostics = aggregatedViolations.map((v) => ({
120
148
  category: "SYNC_ERROR",
121
149
  severity: "error",
122
150
  message: v.description,
123
151
  file: v.source,
124
152
  suggestion: v.suggestion,
125
153
  }));
126
- const summary = violations.length === 0
154
+ const summary = aggregatedViolations.length === 0
127
155
  ? "No violations found"
128
- : `${violations.length} violations found`;
156
+ : `${aggregatedViolations.length} violations found`;
129
157
  return {
130
158
  content: [
131
159
  {
@@ -134,8 +162,8 @@ export async function handleKbCheck(prolog, args) {
134
162
  },
135
163
  ],
136
164
  structuredContent: {
137
- violations,
138
- count: violations.length,
165
+ violations: aggregatedViolations,
166
+ count: aggregatedViolations.length,
139
167
  diagnostics: formatDiagnosticsForMcp(diagnostics),
140
168
  },
141
169
  };
@@ -145,341 +173,49 @@ export async function handleKbCheck(prolog, args) {
145
173
  throw new Error(`Check execution failed: ${message}`);
146
174
  }
147
175
  }
148
- async function runAggregatedChecks(prolog, rulesAllowlist) {
176
+ // implements REQ-002
177
+ async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
178
+ const violations = [];
149
179
  const checksPlPath = resolveChecksPlPath();
150
180
  const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
151
181
  const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
152
- const violations = [];
153
- const ruleToPredicate = {
154
- "must-priority-coverage": "check_must_priority_coverage",
155
- "no-dangling-refs": "check_no_dangling_refs",
156
- "no-cycles": "check_no_cycles",
157
- "required-fields": "check_required_fields",
158
- "symbol-coverage": "check_symbol_coverage",
159
- };
160
- for (const rule of rulesAllowlist) {
161
- const predicate = ruleToPredicate[rule];
162
- if (!predicate) {
163
- continue;
164
- }
165
- const query = `(use_module('${checksPlPathEscaped}'), call(checks:${predicate}(Violations)), findall(_{rule:Rule,entityId:EntityId,description:Description,suggestion:Suggestion,source:Source}, member(violation(Rule, EntityId, Description, Suggestion, Source), Violations), Rows), call(checks:atom_json_dict(JsonString, Rows, [])))`;
166
- const result = await prolog.query(query);
167
- if (!result.success || !result.bindings.JsonString) {
168
- return null;
169
- }
170
- let parsedRows;
171
- try {
172
- parsedRows = JSON.parse(result.bindings.JsonString);
173
- if (typeof parsedRows === "string") {
174
- parsedRows = JSON.parse(parsedRows);
175
- }
176
- }
177
- catch {
178
- return null;
179
- }
180
- if (!Array.isArray(parsedRows)) {
181
- return null;
182
- }
183
- for (const row of parsedRows) {
184
- if (!row || typeof row !== "object") {
185
- continue;
186
- }
187
- const raw = row;
188
- const rule = typeof raw.rule === "string" ? raw.rule : "";
189
- if (!rulesAllowlist.has(rule)) {
190
- continue;
191
- }
192
- const entityId = typeof raw.entityId === "string"
193
- ? raw.entityId
194
- : typeof raw.entity_id === "string"
195
- ? raw.entity_id
196
- : "";
197
- const description = typeof raw.description === "string" ? raw.description : "";
198
- const suggestion = typeof raw.suggestion === "string" ? raw.suggestion : undefined;
199
- const source = typeof raw.source === "string" ? raw.source : undefined;
200
- if (!rule || !entityId || !description) {
201
- continue;
202
- }
203
- violations.push({
204
- rule,
205
- entityId,
206
- description,
207
- suggestion,
208
- source,
209
- });
210
- }
211
- }
212
- return violations;
213
- }
214
- async function checkSymbolTraceability(prolog, requireAdr) {
215
- const violations = [];
182
+ // Use check_all_json_with_options if available, otherwise fall back to check_all_json
216
183
  const requireAdrStr = requireAdr ? "true" : "false";
217
- const result = await prolog.query(`findall(violation(Rule, EntityId, Desc, Sugg, Src), checks:symbol_traceability_violation(${requireAdrStr}, violation(Rule, EntityId, Desc, Sugg, Src)), Violations)`);
218
- if (!result.success || !result.bindings.Violations) {
219
- return violations;
220
- }
221
- const violationsStr = result.bindings.Violations;
222
- if (violationsStr && violationsStr !== "[]") {
223
- const violationRegex = /violation\(([^,]+),'?([^',]+)'?,([^,]+),([^,]+),'?([^']*)'?\)/g;
224
- let match;
225
- do {
226
- match = violationRegex.exec(violationsStr);
227
- if (match) {
228
- violations.push({
229
- rule: match[1].trim().replace(/^'|'$/g, ""),
230
- entityId: match[2].trim(),
231
- description: match[3].trim().replace(/^"|"$/g, ""),
232
- suggestion: match[4].trim().replace(/^"|"$/g, ""),
233
- source: match[5].trim() || undefined,
234
- });
235
- }
236
- } while (match);
237
- }
238
- return violations;
239
- }
240
- async function checkMustPriorityCoverage(prolog) {
241
- const violations = [];
242
- const gapsResult = await prolog.query("setof([Req,Reason], coverage_gap(Req, Reason), Rows)");
243
- if (!gapsResult.success || !gapsResult.bindings.Rows) {
244
- return violations;
245
- }
246
- const gaps = parsePairList(gapsResult.bindings.Rows);
247
- for (const [reqId, reason] of gaps) {
248
- const entityResult = await prolog.query(`kb_entity('${reqId}', req, Props)`);
249
- let source = "";
250
- if (entityResult.success && entityResult.bindings.Props) {
251
- const propsStr = entityResult.bindings.Props;
252
- const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
253
- if (sourceMatch) {
254
- source = sourceMatch[1];
255
- }
256
- }
257
- const missing = [];
258
- if (reason.includes("scenario")) {
259
- missing.push("scenario");
260
- }
261
- if (reason.includes("test")) {
262
- missing.push("test");
263
- }
264
- violations.push({
265
- rule: "must-priority-coverage",
266
- entityId: reqId,
267
- description: `Must-priority requirement lacks ${missing.join(" and ")} coverage`,
268
- source,
269
- suggestion: missing
270
- .map((m) => `Create ${m} that covers this requirement`)
271
- .join("; "),
272
- });
273
- }
274
- return violations;
275
- }
276
- async function getAllEntityIds(prolog, type) {
277
- const typeFilter = type ? `, Type = ${type}` : "";
278
- const query = `findall(Id, (kb_entity(Id, Type, _)${typeFilter}), Ids)`;
184
+ const query = `(use_module('${checksPlPathEscaped}'),
185
+ ( predicate_property(checks:check_all_json_with_options(_, _), _)
186
+ -> call(checks:check_all_json_with_options(JsonString, ${requireAdrStr}))
187
+ ; call(checks:check_all_json(JsonString))
188
+ ))`;
279
189
  const result = await prolog.query(query);
280
- if (!result.success || !result.bindings.Ids) {
281
- return [];
282
- }
283
- const idsStr = result.bindings.Ids;
284
- const match = idsStr.match(/\[(.*)\]/);
285
- if (!match) {
286
- return [];
287
- }
288
- const content = match[1].trim();
289
- if (!content) {
290
- return [];
190
+ if (!result.success) {
191
+ throw new Error(`Aggregated checks query failed: ${result.error || "Unknown error"}`);
291
192
  }
292
- return content.split(",").map((id) => id.trim().replace(/^'|'$/g, ""));
293
- }
294
- async function checkNoDanglingRefs(prolog) {
295
- const violations = [];
296
- // Get all entity IDs once
297
- const allEntityIds = new Set(await getAllEntityIds(prolog));
298
- // Get all relationships by querying all known relationship types
299
- const relTypes = [
300
- "depends_on",
301
- "verified_by",
302
- "validates",
303
- "specified_by",
304
- "relates_to",
305
- ];
306
- const allRels = [];
307
- for (const relType of relTypes) {
308
- const relsResult = await prolog.query(`findall([From,To], kb_relationship(${relType}, From, To), Rels)`);
309
- if (relsResult.success && relsResult.bindings.Rels) {
310
- const relsStr = relsResult.bindings.Rels;
311
- const match = relsStr.match(/\[(.*)\]/);
312
- if (match) {
313
- const content = match[1].trim();
314
- if (content) {
315
- const relMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
316
- for (const relMatch of relMatches) {
317
- const fromId = relMatch[1].trim().replace(/^'|'$/g, "");
318
- const toId = relMatch[2].trim().replace(/^'|'$/g, "");
319
- allRels.push({ from: fromId, to: toId });
320
- }
321
- }
322
- }
323
- }
324
- }
325
- // Check all collected relationships for dangling refs
326
- for (const rel of allRels) {
327
- if (!allEntityIds.has(rel.from)) {
328
- violations.push({
329
- rule: "no-dangling-refs",
330
- entityId: rel.from,
331
- description: `Relationship references non-existent entity: ${rel.from}`,
332
- suggestion: "Remove relationship or create missing entity",
333
- });
334
- }
335
- if (!allEntityIds.has(rel.to)) {
336
- violations.push({
337
- rule: "no-dangling-refs",
338
- entityId: rel.to,
339
- description: `Relationship references non-existent entity: ${rel.to}`,
340
- suggestion: "Remove relationship or create missing entity",
341
- });
342
- }
343
- }
344
- return violations;
345
- }
346
- async function checkNoCycles(prolog) {
347
- const violations = [];
348
- // Get all depends_on relationships
349
- const depsResult = await prolog.query("findall([From,To], kb_relationship(depends_on, From, To), Deps)");
350
- if (!depsResult.success || !depsResult.bindings.Deps) {
351
- return violations;
352
- }
353
- const depsStr = depsResult.bindings.Deps;
354
- const match = depsStr.match(/\[(.*)\]/);
355
- if (!match) {
356
- return violations;
357
- }
358
- const content = match[1].trim();
359
- if (!content) {
360
- return violations;
361
- }
362
- // Build adjacency map
363
- const graph = new Map();
364
- const depMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
365
- for (const depMatch of depMatches) {
366
- const from = depMatch[1].trim().replace(/^'|'$/g, "");
367
- const to = depMatch[2].trim().replace(/^'|'$/g, "");
368
- if (!graph.has(from)) {
369
- graph.set(from, []);
193
+ let violationsDict;
194
+ try {
195
+ const jsonString = result.bindings.JsonString;
196
+ if (!jsonString) {
197
+ throw new Error("No JSON string in binding");
370
198
  }
371
- const fromList = graph.get(from);
372
- if (fromList) {
373
- fromList.push(to);
199
+ let parsed = JSON.parse(jsonString);
200
+ if (typeof parsed === "string") {
201
+ parsed = JSON.parse(parsed);
374
202
  }
203
+ violationsDict = parsed;
375
204
  }
376
- // DFS to detect cycles
377
- const visited = new Set();
378
- const recStack = new Set();
379
- function hasCycleDFS(node, path) {
380
- visited.add(node);
381
- recStack.add(node);
382
- path.push(node);
383
- const neighbors = graph.get(node) || [];
384
- for (const neighbor of neighbors) {
385
- if (!visited.has(neighbor)) {
386
- const cyclePath = hasCycleDFS(neighbor, [...path]);
387
- if (cyclePath)
388
- return cyclePath;
389
- }
390
- else if (recStack.has(neighbor)) {
391
- // Cycle detected
392
- return [...path, neighbor];
393
- }
394
- }
395
- recStack.delete(node);
396
- return null;
205
+ catch (parseError) {
206
+ throw new Error(`Failed to parse violations JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
397
207
  }
398
- // Check each node for cycles
399
- for (const node of graph.keys()) {
400
- if (!visited.has(node)) {
401
- const cyclePath = hasCycleDFS(node, []);
402
- if (cyclePath) {
403
- const cycleWithSources = [];
404
- for (const entityId of cyclePath) {
405
- const entityResult = await prolog.query(`kb_entity('${entityId}', _, Props)`);
406
- let sourceName = entityId;
407
- if (entityResult.success && entityResult.bindings.Props) {
408
- const propsStr = entityResult.bindings.Props;
409
- const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
410
- if (sourceMatch) {
411
- sourceName = path.basename(sourceMatch[1], ".md");
412
- }
413
- }
414
- cycleWithSources.push(sourceName);
415
- }
208
+ for (const ruleViolations of Object.values(violationsDict)) {
209
+ for (const v of ruleViolations) {
210
+ const isAllowed = rulesAllowlist.has(v.rule);
211
+ if (isAllowed) {
416
212
  violations.push({
417
- rule: "no-cycles",
418
- entityId: cyclePath[0],
419
- description: `Circular dependency detected: ${cycleWithSources.join(" → ")}`,
420
- suggestion: "Break the cycle by removing one of the depends_on relationships",
213
+ rule: v.rule,
214
+ entityId: v.entityId,
215
+ description: v.description,
216
+ suggestion: v.suggestion || undefined,
217
+ source: v.source || undefined,
421
218
  });
422
- break; // Report only first cycle found
423
- }
424
- }
425
- }
426
- return violations;
427
- }
428
- async function checkRequiredFields(prolog, allEntityIds) {
429
- const violations = [];
430
- const required = [
431
- "id",
432
- "title",
433
- "status",
434
- "created_at",
435
- "updated_at",
436
- "source",
437
- ];
438
- for (const entityId of allEntityIds) {
439
- const result = await prolog.query(`kb_entity('${entityId}', Type, Props)`);
440
- if (result.success && result.bindings.Props) {
441
- // Parse properties list: [key1=value1, key2=value2, ...]
442
- const propsStr = result.bindings.Props;
443
- const propKeys = new Set();
444
- // Extract keys from Props
445
- const keyMatches = propsStr.matchAll(/(\w+)\s*=/g);
446
- for (const match of keyMatches) {
447
- propKeys.add(match[1]);
448
- }
449
- // Check for missing required fields
450
- for (const field of required) {
451
- if (!propKeys.has(field)) {
452
- violations.push({
453
- rule: "required-fields",
454
- entityId: entityId,
455
- description: `Missing required field: ${field}`,
456
- suggestion: `Add ${field} to entity definition`,
457
- });
458
- }
459
- }
460
- }
461
- }
462
- return violations;
463
- }
464
- async function checkSymbolCoverage(prolog) {
465
- const violations = [];
466
- const uncoveredResult = await prolog.query("setof(Symbol, (kb_entity(Symbol, symbol, _), \\+ transitively_implements(Symbol, _)), Symbols)");
467
- if (uncoveredResult.success && uncoveredResult.bindings.Symbols) {
468
- const symbolsStr = uncoveredResult.bindings.Symbols;
469
- const match = symbolsStr.match(/\[(.*)\]/);
470
- if (match) {
471
- const content = match[1].trim();
472
- if (content) {
473
- const symbolMatches = content.matchAll(/'([^']+)'/g);
474
- for (const symbolMatch of symbolMatches) {
475
- const symbolId = symbolMatch[1];
476
- violations.push({
477
- rule: "symbol-coverage",
478
- entityId: symbolId,
479
- description: "Code symbol is not traceable to any functional requirement.",
480
- suggestion: "Update symbols.yaml to link this symbol to a related requirement.",
481
- });
482
- }
483
219
  }
484
220
  }
485
221
  }
@@ -51,7 +51,10 @@ export async function handleKbDelete(prolog, args) {
51
51
  }
52
52
  }
53
53
  // Save KB to disk
54
- await prolog.query("kb_save");
54
+ const saveResult = await prolog.query("kb_save");
55
+ if (!saveResult.success) {
56
+ throw new Error(`Failed to save KB after delete: ${saveResult.error || "Unknown error"}`);
57
+ }
55
58
  prolog.invalidateCache();
56
59
  return {
57
60
  content: [
@@ -45,9 +45,7 @@ export async function handleKbQuery(prolog, args) {
45
45
  goal = `findall(['${safeId}',Type,Props], kb_entity('${safeId}', Type, Props), Results)`;
46
46
  }
47
47
  else if (tags && tags.length > 0) {
48
- // TODO: Reintroduce server-side (Prolog) tag filtering once normalization
49
- // issues with tag list formats are resolved, to avoid fetching all entities
50
- // before filtering in JS for large knowledge bases.
48
+ // JS-side fallback until REQ-mcp-tag-filtering-server-side is implemented.
51
49
  if (type) {
52
50
  const safeType = escapeAtomContent(type);
53
51
  goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
@@ -27,6 +27,7 @@ const validateRelationship = ajv.compile(relationshipSchema);
27
27
  * Handle kb.upsert tool calls
28
28
  * Accepts { type, id, properties } — the flat format matching the tool schema.
29
29
  * Validates the assembled entity against JSON Schema before Prolog writes.
30
+ * implements REQ-002, REQ-011
30
31
  */
31
32
  export async function handleKbUpsert(prolog, args) {
32
33
  const { type, id, properties, relationships = [] } = args;
@@ -80,49 +81,56 @@ export async function handleKbUpsert(prolog, args) {
80
81
  for (const entity of entities) {
81
82
  const id = entity.id;
82
83
  const type = entity.type;
83
- // Check if entity exists
84
+ // Check if entity exists before transaction (to determine created vs updated)
84
85
  const checkGoal = `once(kb_entity('${escapeAtom(id)}', _, _))`;
85
86
  const checkResult = await prolog.query(checkGoal);
86
87
  const isUpdate = checkResult.success;
87
88
  // Build property list for Prolog
88
89
  const props = buildPropertyList(entity);
89
- // Assert entity (upsert)
90
+ // Build relationship goals
91
+ const relationshipGoals = [];
92
+ for (const rel of relationships) {
93
+ const relType = rel.type;
94
+ const from = rel.from;
95
+ const to = rel.to;
96
+ const metadata = buildRelationshipMetadata(rel);
97
+ relationshipGoals.push(`kb_assert_relationship(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
98
+ }
99
+ // Build atomic transaction goal wrapping entity + all relationships
100
+ // implements REQ-002, REQ-011
101
+ let transactionGoal;
102
+ if (relationshipGoals.length === 0) {
103
+ // Simple case: just entity
104
+ transactionGoal = `rdf_transaction((kb_assert_entity(${type}, ${props})))`;
105
+ }
106
+ else {
107
+ // Entity + relationships in one transaction
108
+ const goals = [
109
+ `kb_assert_entity(${type}, ${props})`,
110
+ ...relationshipGoals,
111
+ ].join(", ");
112
+ transactionGoal = `rdf_transaction((${goals}))`;
113
+ }
114
+ const txResult = await prolog.query(transactionGoal);
115
+ if (!txResult.success) {
116
+ throw new Error(`Failed to upsert entity ${id}: ${txResult.error || "Unknown error"} (goal: ${transactionGoal})`);
117
+ }
118
+ // Update counters
90
119
  if (isUpdate) {
91
- // Update counter only. kb_assert_entity implements upsert semantics in Prolog.
92
120
  updated++;
93
121
  }
94
122
  else {
95
123
  created++;
96
124
  }
97
- const assertGoal = `kb_assert_entity(${type}, ${props})`;
98
- const assertResult = await prolog.query(assertGoal);
99
- if (!assertResult.success) {
100
- throw new Error(`Failed to assert entity ${id}: ${assertResult.error || "Unknown error"}`);
101
- }
125
+ relationshipsCreated += relationships.length;
102
126
  }
103
- // Process relationships
104
- for (const rel of relationships) {
105
- const relType = rel.type;
106
- const from = rel.from;
107
- const to = rel.to;
108
- // Build metadata
109
- const metadata = buildRelationshipMetadata(rel);
110
- const relGoal = `kb_assert_relationship(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`;
111
- const relResult = await prolog.query(relGoal);
112
- if (!relResult.success) {
113
- throw new Error(`Failed to assert relationship ${relType} from ${from} to ${to}: ${relResult.error || "Unknown error"}`);
114
- }
115
- relationshipsCreated++;
116
- }
117
- // Note: kb_save is intentionally NOT called here for performance.
118
- // Callers that need durability across restarts should explicitly call kb_save.
119
- // This allows batching multiple upserts before a single disk write.
120
- prolog.invalidateCache();
121
- // Save KB to disk to ensure durability across process restarts
122
- await prolog.query("kb_save");
123
- prolog.invalidateCache();
124
- // multiple upserts and save once at the end for better performance.
127
+ // Save KB to disk after all entities/relationships are written to ensure
128
+ // durability across process restarts.
125
129
  prolog.invalidateCache();
130
+ const saveResult = await prolog.query("kb_save");
131
+ if (!saveResult.success) {
132
+ throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
133
+ }
126
134
  let contradictionPairsDetected;
127
135
  if (type === "req" && !args._skipContradictionCheck) {
128
136
  contradictionPairsDetected = await detectContradictionPairs(prolog, id);
@@ -14,8 +14,9 @@
14
14
 
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
- */
18
- export const TOOLS = [
17
+ */
18
+ import { DIAGNOSTIC_MODE_ENABLED, DIAGNOSTIC_TELEMETRY_SCHEMA, } from "./diagnostics.js";
19
+ const BASE_TOOLS = [
19
20
  // implements REQ-002
20
21
  {
21
22
  name: "kb_query",
@@ -216,3 +217,31 @@ export const TOOLS = [
216
217
  },
217
218
  },
218
219
  ];
220
+ /**
221
+ * Inject _diagnostic_telemetry schema into tool inputs when diagnostic mode is enabled.
222
+ */
223
+ function withDiagnosticTelemetrySchema(tools) {
224
+ return tools.map((tool) => {
225
+ const schema = tool.inputSchema;
226
+ const properties = schema.properties && typeof schema.properties === "object"
227
+ ? schema.properties
228
+ : {};
229
+ return {
230
+ ...tool,
231
+ inputSchema: {
232
+ ...schema,
233
+ properties: {
234
+ ...properties,
235
+ _diagnostic_telemetry: DIAGNOSTIC_TELEMETRY_SCHEMA,
236
+ },
237
+ },
238
+ };
239
+ });
240
+ }
241
+ /**
242
+ * Active tools list.
243
+ * In diagnostic mode, all tools include the _diagnostic_telemetry parameter.
244
+ */
245
+ export const TOOLS = DIAGNOSTIC_MODE_ENABLED
246
+ ? withDiagnosticTelemetrySchema(BASE_TOOLS)
247
+ : BASE_TOOLS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -9,7 +9,7 @@
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.2.6",
12
+ "kibi-cli": "^0.2.7",
13
13
  "kibi-core": "^0.1.10",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "latest",
43
- "@types/node": "latest"
43
+ "@types/node": "latest",
44
+ "typescript": "^5.7.0"
44
45
  },
45
46
  "license": "AGPL-3.0-or-later",
46
47
  "author": "Piotr Franczyk",