kibi-mcp 0.2.2 → 0.2.4

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.
@@ -42,8 +42,29 @@
42
42
  fi
43
43
  done
44
44
  */
45
+ import { existsSync } from "node:fs";
46
+ import { createRequire } from "node:module";
45
47
  import * as path from "node:path";
46
48
  import { parsePairList } from "./prolog-list.js";
49
+ const require = createRequire(import.meta.url);
50
+ function resolveChecksPlPath() {
51
+ const overrideChecksPath = process.env.KIBI_CHECKS_PL_PATH;
52
+ if (overrideChecksPath && existsSync(overrideChecksPath)) {
53
+ return overrideChecksPath;
54
+ }
55
+ try {
56
+ const installedChecksPl = require.resolve("kibi-core/src/checks.pl");
57
+ if (existsSync(installedChecksPl)) {
58
+ return installedChecksPl;
59
+ }
60
+ }
61
+ catch { }
62
+ const localChecksPl = path.join(process.cwd(), "packages/core/src/checks.pl");
63
+ if (existsSync(localChecksPl)) {
64
+ return localChecksPl;
65
+ }
66
+ throw new Error("Unable to resolve checks.pl path");
67
+ }
47
68
  function formatDiagnosticsForMcp(diagnostics) {
48
69
  return diagnostics.map((d) => ({
49
70
  category: d.category,
@@ -61,7 +82,7 @@ export async function handleKbCheck(prolog, args) {
61
82
  const { rules } = args;
62
83
  try {
63
84
  const violations = [];
64
- const allEntityIds = await getAllEntityIds(prolog);
85
+ let allEntityIds = null;
65
86
  // Run all validation rules (or specific rules if provided)
66
87
  const allRules = [
67
88
  "must-priority-coverage",
@@ -71,6 +92,33 @@ export async function handleKbCheck(prolog, args) {
71
92
  "symbol-coverage",
72
93
  ];
73
94
  const rulesToRun = rules && rules.length > 0 ? rules : allRules;
95
+ const rulesAllowlist = new Set(rulesToRun);
96
+ const aggregatedViolations = await runAggregatedChecks(prolog, rulesAllowlist);
97
+ if (aggregatedViolations) {
98
+ const diagnostics = aggregatedViolations.map((v) => ({
99
+ category: "SYNC_ERROR",
100
+ severity: "error",
101
+ message: v.description,
102
+ file: v.source,
103
+ suggestion: v.suggestion,
104
+ }));
105
+ const summary = aggregatedViolations.length === 0
106
+ ? "No violations found"
107
+ : `${aggregatedViolations.length} violations found`;
108
+ return {
109
+ content: [
110
+ {
111
+ type: "text",
112
+ text: summary,
113
+ },
114
+ ],
115
+ structuredContent: {
116
+ violations: aggregatedViolations,
117
+ count: aggregatedViolations.length,
118
+ diagnostics: formatDiagnosticsForMcp(diagnostics),
119
+ },
120
+ };
121
+ }
74
122
  if (rulesToRun.includes("must-priority-coverage")) {
75
123
  violations.push(...(await checkMustPriorityCoverage(prolog)));
76
124
  }
@@ -81,6 +129,9 @@ export async function handleKbCheck(prolog, args) {
81
129
  violations.push(...(await checkNoCycles(prolog)));
82
130
  }
83
131
  if (rulesToRun.includes("required-fields")) {
132
+ if (!allEntityIds) {
133
+ allEntityIds = await getAllEntityIds(prolog);
134
+ }
84
135
  violations.push(...(await checkRequiredFields(prolog, allEntityIds)));
85
136
  }
86
137
  if (rulesToRun.includes("symbol-coverage")) {
@@ -115,6 +166,72 @@ export async function handleKbCheck(prolog, args) {
115
166
  throw new Error(`Check execution failed: ${message}`);
116
167
  }
117
168
  }
169
+ async function runAggregatedChecks(prolog, rulesAllowlist) {
170
+ const checksPlPath = resolveChecksPlPath();
171
+ const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
172
+ const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
173
+ const violations = [];
174
+ const ruleToPredicate = {
175
+ "must-priority-coverage": "check_must_priority_coverage",
176
+ "no-dangling-refs": "check_no_dangling_refs",
177
+ "no-cycles": "check_no_cycles",
178
+ "required-fields": "check_required_fields",
179
+ "symbol-coverage": "check_symbol_coverage",
180
+ };
181
+ for (const rule of rulesAllowlist) {
182
+ const predicate = ruleToPredicate[rule];
183
+ if (!predicate) {
184
+ continue;
185
+ }
186
+ 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, [])))`;
187
+ const result = await prolog.query(query);
188
+ if (!result.success || !result.bindings.JsonString) {
189
+ return null;
190
+ }
191
+ let parsedRows;
192
+ try {
193
+ parsedRows = JSON.parse(result.bindings.JsonString);
194
+ if (typeof parsedRows === "string") {
195
+ parsedRows = JSON.parse(parsedRows);
196
+ }
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ if (!Array.isArray(parsedRows)) {
202
+ return null;
203
+ }
204
+ for (const row of parsedRows) {
205
+ if (!row || typeof row !== "object") {
206
+ continue;
207
+ }
208
+ const raw = row;
209
+ const rule = typeof raw.rule === "string" ? raw.rule : "";
210
+ if (!rulesAllowlist.has(rule)) {
211
+ continue;
212
+ }
213
+ const entityId = typeof raw.entityId === "string"
214
+ ? raw.entityId
215
+ : typeof raw.entity_id === "string"
216
+ ? raw.entity_id
217
+ : "";
218
+ const description = typeof raw.description === "string" ? raw.description : "";
219
+ const suggestion = typeof raw.suggestion === "string" ? raw.suggestion : undefined;
220
+ const source = typeof raw.source === "string" ? raw.source : undefined;
221
+ if (!rule || !entityId || !description) {
222
+ continue;
223
+ }
224
+ violations.push({
225
+ rule,
226
+ entityId,
227
+ description,
228
+ suggestion,
229
+ source,
230
+ });
231
+ }
232
+ }
233
+ return violations;
234
+ }
118
235
  async function checkMustPriorityCoverage(prolog) {
119
236
  const violations = [];
120
237
  const gapsResult = await prolog.query("setof([Req,Reason], coverage_gap(Req, Reason), Rows)");
@@ -16,7 +16,7 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  function escapeAtom(value) {
19
- return value.replace(/'/g, "\\'");
19
+ return value.replace(/'/g, "''");
20
20
  }
21
21
  /**
22
22
  * Handle kb.delete tool calls
@@ -32,8 +32,9 @@ export async function handleKbDelete(prolog, args) {
32
32
  const errors = [];
33
33
  try {
34
34
  for (const id of ids) {
35
+ const safeId = escapeAtom(id);
35
36
  // Check if entity exists
36
- const checkGoal = `once(kb_entity('${escapeAtom(id)}', _, _))`;
37
+ const checkGoal = `once(kb_entity('${safeId}', _, _))`;
37
38
  const checkResult = await prolog.query(checkGoal);
38
39
  if (!checkResult.success) {
39
40
  errors.push(`Entity ${id} does not exist`);
@@ -41,36 +42,24 @@ export async function handleKbDelete(prolog, args) {
41
42
  continue;
42
43
  }
43
44
  // Check for dependents (entities that reference this one)
44
- // Query each relationship type separately to avoid timeout with unbound Type
45
- const relTypes = [
46
- "depends_on",
47
- "verified_by",
48
- "validates",
49
- "specified_by",
50
- "relates_to",
51
- "guards",
52
- "publishes",
53
- "consumes",
54
- ];
55
- let hasDependents = false;
56
- for (const relType of relTypes) {
57
- const dependentsGoal = `findall(From, kb_relationship(${relType}, From, '${id}'), Dependents)`;
58
- const dependentsResult = await prolog.query(dependentsGoal);
59
- if (dependentsResult.success && dependentsResult.bindings.Dependents) {
60
- const dependentsStr = dependentsResult.bindings.Dependents;
61
- if (dependentsStr !== "[]") {
62
- errors.push(`Cannot delete entity ${id}: has dependents (other entities reference it via ${relType})`);
63
- skipped++;
64
- hasDependents = true;
65
- break;
66
- }
67
- }
45
+ const dependentsGoal = `findall([RelType,From], (member(RelType, [depends_on, verified_by, validates, specified_by, relates_to, guards, publishes, consumes]), kb_relationship(RelType, From, '${safeId}')), Dependents)`;
46
+ const dependentsResult = await prolog.query(dependentsGoal);
47
+ if (!dependentsResult.success) {
48
+ errors.push(`Failed to inspect dependents for entity ${id}: ${dependentsResult.error ?? "Query failed"}`);
49
+ skipped++;
50
+ continue;
51
+ }
52
+ const hasDependents = dependentsResult.bindings.Dependents !== undefined &&
53
+ dependentsResult.bindings.Dependents !== "[]";
54
+ if (hasDependents) {
55
+ errors.push(`Cannot delete entity ${id}: has dependents (other entities reference it)`);
56
+ skipped++;
68
57
  }
69
58
  if (hasDependents) {
70
59
  continue;
71
60
  }
72
61
  // No dependents, safe to delete
73
- const deleteGoal = `kb_retract_entity('${id}')`;
62
+ const deleteGoal = `kb_retract_entity('${safeId}')`;
74
63
  const deleteResult = await prolog.query(deleteGoal);
75
64
  if (!deleteResult.success) {
76
65
  errors.push(`Failed to delete entity ${id}: ${deleteResult.error || "Unknown error"}`);
@@ -82,6 +71,7 @@ export async function handleKbDelete(prolog, args) {
82
71
  }
83
72
  // Save KB to disk
84
73
  await prolog.query("kb_save");
74
+ prolog.invalidateCache();
85
75
  return {
86
76
  content: [
87
77
  {
@@ -15,6 +15,13 @@
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
+ /**
19
+ * Escape a string for embedding inside a single-quoted Prolog atom.
20
+ * Doubles single-quote characters per ISO Prolog standard.
21
+ */
22
+ function escapeAtomContent(value) {
23
+ return value.replace(/'/g, "''");
24
+ }
18
25
  export const VALID_ENTITY_TYPES = [
19
26
  "req",
20
27
  "scenario",
@@ -42,58 +49,63 @@ export async function handleKbQuery(prolog, args) {
42
49
  // Build Prolog query
43
50
  let goal;
44
51
  if (sourceFile) {
45
- const safeSource = sourceFile.replace(/'/g, "\\'");
52
+ const safeSource = escapeAtomContent(sourceFile);
46
53
  if (type) {
47
- goal = `findall([Id,'${type}',Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, '${type}', Props)), Results)`;
54
+ const safeType = escapeAtomContent(type);
55
+ goal = `findall([Id,'${safeType}',Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, '${safeType}', Props)), Results)`;
48
56
  }
49
57
  else {
50
58
  goal = `findall([Id,Type,Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, Type, Props)), Results)`;
51
59
  }
52
60
  }
53
61
  else if (id && type) {
54
- goal = `kb_entity('${id}', '${type}', Props), Id = '${id}', Type = '${type}', Result = [Id, Type, Props]`;
62
+ const safeId = escapeAtomContent(id);
63
+ const safeType = escapeAtomContent(type);
64
+ goal = `findall(['${safeId}','${safeType}',Props], kb_entity('${safeId}', '${safeType}', Props), Results)`;
55
65
  }
56
66
  else if (id) {
57
- goal = `findall(['${id}',Type,Props], kb_entity('${id}', Type, Props), Results)`;
67
+ const safeId = escapeAtomContent(id);
68
+ goal = `findall(['${safeId}',Type,Props], kb_entity('${safeId}', Type, Props), Results)`;
58
69
  }
59
70
  else if (tags && tags.length > 0) {
60
- const tagList = `[${tags.map((t) => `'${t}'`).join(",")}]`;
71
+ // TODO: Reintroduce server-side (Prolog) tag filtering once normalization
72
+ // issues with tag list formats are resolved, to avoid fetching all entities
73
+ // before filtering in JS for large knowledge bases.
61
74
  if (type) {
62
- goal = `findall([Id,'${type}',Props], (kb_entity(Id, '${type}', Props), memberchk(tags=Tags, Props), member(Tag, Tags), member(Tag, ${tagList})), Results)`;
75
+ const safeType = escapeAtomContent(type);
76
+ goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
63
77
  }
64
78
  else {
65
- goal = `findall([Id,Type,Props], (kb_entity(Id, Type, Props), memberchk(tags=Tags, Props), member(Tag, Tags), member(Tag, ${tagList})), Results)`;
79
+ goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
66
80
  }
67
81
  }
68
82
  else if (type) {
69
- goal = `findall([Id,'${type}',Props], kb_entity(Id, '${type}', Props), Results)`;
83
+ const safeType = escapeAtomContent(type);
84
+ goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
70
85
  }
71
86
  else {
72
87
  goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
73
88
  }
74
89
  const queryResult = await prolog.query(goal);
75
90
  if (queryResult.success) {
76
- if (id && type) {
77
- // Single entity query
78
- if (queryResult.bindings.Result) {
79
- const entity = parseEntityFromBinding(queryResult.bindings.Result);
80
- results = [entity];
91
+ if (queryResult.bindings.Results) {
92
+ const entitiesData = parseListOfLists(queryResult.bindings.Results);
93
+ for (const data of entitiesData) {
94
+ const entity = parseEntityFromList(data);
95
+ results.push(entity);
81
96
  }
82
97
  }
83
- else {
84
- // Multiple entities query
85
- if (queryResult.bindings.Results) {
86
- const entitiesData = parseListOfLists(queryResult.bindings.Results);
87
- for (const data of entitiesData) {
88
- const entity = parseEntityFromList(data);
89
- results.push(entity);
90
- }
91
- }
98
+ else if (queryResult.bindings.Result) {
99
+ const entity = parseEntityFromBinding(queryResult.bindings.Result);
100
+ results = [entity];
92
101
  }
93
102
  }
94
103
  else {
95
104
  throw new Error(queryResult.error || "Query failed with unknown error");
96
105
  }
106
+ if (tags && tags.length > 0) {
107
+ results = dedupeEntities(results.filter((entity) => hasAnyTag(entity, tags)));
108
+ }
97
109
  // Apply pagination
98
110
  const paginated = results.slice(offset, offset + limit);
99
111
  // Build human-readable text with entity IDs and titles
@@ -131,6 +143,37 @@ export async function handleKbQuery(prolog, args) {
131
143
  throw new Error(`Query execution failed: ${message}`);
132
144
  }
133
145
  }
146
+ function hasAnyTag(entity, requestedTags) {
147
+ const expected = new Set(requestedTags.map(normalizeTagValue));
148
+ const rawTags = entity.tags;
149
+ if (!Array.isArray(rawTags) || rawTags.length === 0) {
150
+ return false;
151
+ }
152
+ for (const tag of rawTags) {
153
+ if (expected.has(normalizeTagValue(tag))) {
154
+ return true;
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+ function normalizeTagValue(tag) {
160
+ return String(tag).trim();
161
+ }
162
+ function dedupeEntities(entities) {
163
+ const seen = new Set();
164
+ const deduped = [];
165
+ for (const entity of entities) {
166
+ const id = String(entity.id ?? "");
167
+ const type = String(entity.type ?? "");
168
+ const key = `${type}::${id}`;
169
+ if (seen.has(key)) {
170
+ continue;
171
+ }
172
+ seen.add(key);
173
+ deduped.push(entity);
174
+ }
175
+ return deduped;
176
+ }
134
177
  /**
135
178
  * Parse a Prolog list of lists into a JavaScript array.
136
179
  * Input: "[[a,b,c],[d,e,f]]"
@@ -20,7 +20,13 @@ import entitySchema from "kibi-cli/schemas/entity";
20
20
  import relationshipSchema from "kibi-cli/schemas/relationship";
21
21
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
22
22
  function escapeAtom(value) {
23
- return value.replace(/'/g, "\\'");
23
+ return value.replace(/'/g, "''");
24
+ }
25
+ function toPrologAtom(value) {
26
+ const simplePrologAtom = /^[a-z][a-zA-Z0-9_]*$/;
27
+ return simplePrologAtom.test(value)
28
+ ? value
29
+ : `'${value.replace(/'/g, "''")}'`;
24
30
  }
25
31
  const ajv = new Ajv({ strict: false });
26
32
  const validateEntity = ajv.compile(entitySchema);
@@ -118,6 +124,7 @@ export async function handleKbUpsert(prolog, args) {
118
124
  }
119
125
  // Save KB to disk
120
126
  await prolog.query("kb_save");
127
+ prolog.invalidateCache();
121
128
  let contradictionPairsDetected;
122
129
  if (type === "req") {
123
130
  contradictionPairsDetected = await detectContradictionPairs(prolog, id);
@@ -189,13 +196,13 @@ function buildPropertyList(entity) {
189
196
  continue;
190
197
  let prologValue;
191
198
  if (key === "id" && typeof value === "string") {
192
- prologValue = `'${value}'`;
199
+ prologValue = `'${value.replace(/'/g, "''")}'`;
193
200
  }
194
201
  else if (Array.isArray(value)) {
195
202
  prologValue = JSON.stringify(value);
196
203
  }
197
204
  else if (ATOM_FIELDS.includes(key) && typeof value === "string") {
198
- prologValue = value;
205
+ prologValue = toPrologAtom(value);
199
206
  }
200
207
  else if (STRING_FIELDS.includes(key) && typeof value === "string") {
201
208
  prologValue = `"${escapeQuotes(value)}"`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -10,7 +10,7 @@
10
10
  "gray-matter": "^4.0.3",
11
11
  "js-yaml": "^4.1.0",
12
12
  "kibi-cli": "^0.2.3",
13
- "kibi-core": "^0.1.7",
13
+ "kibi-core": "^0.1.8",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"