kibi-mcp 0.16.0 → 0.16.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.
@@ -30,9 +30,11 @@ import { handleKbModelRequirement, } from "../tools/model-requirement.js";
30
30
  import { handleKbQuery } from "../tools/query.js";
31
31
  import { handleKbSearch } from "../tools/search.js";
32
32
  import { handleKbSkillsList, handleKbSkillsLoad, handleKbSkillsRead, } from "../tools/skills.js";
33
+ import { handleSparql } from "../tools/sparql.js";
33
34
  import { handleKbStatus } from "../tools/status.js";
34
35
  import { handleKbSuggestPredicates, } from "../tools/suggest-predicates.js";
35
36
  import { handleKbUpsert } from "../tools/upsert.js";
37
+ import { handleKbValidateUpsert } from "../tools/validate-upsert.js";
36
38
  const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
37
39
  const TOOL_TIMEOUT_ENV = "KIBI_MCP_TOOL_TIMEOUT_MS";
38
40
  const defaultToolsServerDeps = {
@@ -79,6 +81,7 @@ const DEFAULT_TOOLS_RUNTIME = {
79
81
  handleKbDelete,
80
82
  handleKbFindGaps,
81
83
  handleKbGraph,
84
+ handleSparql,
82
85
  handleKbQuery,
83
86
  handleKbSearch,
84
87
  handleKbStatus,
@@ -86,6 +89,7 @@ const DEFAULT_TOOLS_RUNTIME = {
86
89
  handleKbSkillsLoad,
87
90
  handleKbSkillsRead,
88
91
  handleKbUpsert,
92
+ handleKbValidateUpsert,
89
93
  handleKbModelRequirement,
90
94
  handleKbSuggestPredicates,
91
95
  handleKbAutopilotGenerate,
@@ -393,10 +397,17 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
393
397
  const prolog = await runtime.ensureProlog();
394
398
  return runtime.handleKbGraph(prolog, args);
395
399
  }, runtime);
400
+ addTool(server, "kb_sparql_remote", toolDef("kb_sparql_remote").description, toolDef("kb_sparql_remote").inputSchema, async (args) => {
401
+ const prolog = await runtime.ensureProlog();
402
+ return runtime.handleSparql(prolog, args);
403
+ }, runtime);
396
404
  addTool(server, "kb_upsert", toolDef("kb_upsert").description, toolDef("kb_upsert").inputSchema, async (args) => {
397
405
  const prolog = await runtime.ensureProlog();
398
406
  return runtime.handleKbUpsert(prolog, args);
399
407
  }, runtime);
408
+ addTool(server, "kb_validate_upsert", toolDef("kb_validate_upsert").description, toolDef("kb_validate_upsert").inputSchema, async (args) => {
409
+ return runtime.handleKbValidateUpsert(args);
410
+ }, runtime);
400
411
  addTool(server, "kb_delete", toolDef("kb_delete").description, toolDef("kb_delete").inputSchema, async (args) => {
401
412
  const prolog = await runtime.ensureProlog();
402
413
  return runtime.handleKbDelete(prolog, args);
@@ -308,6 +308,15 @@ export async function handleKbModelRequirement(_prolog, args) {
308
308
  });
309
309
  const applyPlan = strictWriteSetToApplyPlan(writeSet);
310
310
  const migrationWarning = await getWorkspaceMigrationWarning();
311
+ const warnings = writeSet.isStrict
312
+ ? []
313
+ : [
314
+ {
315
+ kind: "low_confidence_observation_downgrade",
316
+ message: `Claim confidence ${writeSet.confidence.toFixed(2)} is below the strict threshold 0.70, so Kibi emitted an observation fact instead of strict subject/property facts.`,
317
+ nextAction: "If this is normative, provide subjectKey, propertyKey, operator, and value explicitly, then apply the returned strict write-set sequentially.",
318
+ },
319
+ ];
311
320
  const strictSummary = writeSet.isStrict
312
321
  ? `Modeled strict requirement into ${applyPlan.length} sequential applyPlan step(s).`
313
322
  : "Modeled a non-blocking observation review artifact; deterministic claim extraction stayed below the strict threshold.";
@@ -322,6 +331,7 @@ export async function handleKbModelRequirement(_prolog, args) {
322
331
  confidence: writeSet.confidence,
323
332
  extractionMode: extracted.extractionMode,
324
333
  extractionWarnings: extracted.extractionWarnings,
334
+ warnings,
325
335
  migrationWarning,
326
336
  };
327
337
  return {
@@ -0,0 +1,96 @@
1
+ import { runJsonModuleQuery, toPrologAtom } from "./core-module.js";
2
+ // implements REQ-002, REQ-013
3
+ export async function handleSparql(prolog, args) {
4
+ validateSparqlArgs(args);
5
+ try {
6
+ const payload = await runJsonModuleQuery(prolog, "sparql_client.pl", `kibi_sparql_client:remote_sparql_select_json(${toPrologAtom(args.endpoint)}, ${toPrologAtom(args.query)}, ${toSparqlOptions(args)}, JsonString)`, "SPARQL remote query");
7
+ const rows = payload?.rows ?? [];
8
+ return {
9
+ content: [
10
+ {
11
+ type: "text",
12
+ text: `Remote SPARQL query returned ${rows.length} row${rows.length === 1 ? "" : "s"}.`,
13
+ },
14
+ ],
15
+ ...(payload !== undefined ? { structuredContent: payload } : {}),
16
+ };
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ throw new Error(`SPARQL remote query failed: ${message}`);
21
+ }
22
+ }
23
+ function validateSparqlArgs(args) {
24
+ if (!args.endpoint || args.endpoint.trim().length === 0) {
25
+ throw new Error("SPARQL endpoint is required");
26
+ }
27
+ if (!args.query || args.query.trim().length === 0) {
28
+ throw new Error("SPARQL query is required");
29
+ }
30
+ if (!isRemoteHttpEndpoint(args.endpoint)) {
31
+ throw new Error("SPARQL endpoint must be an http:// or https:// URL");
32
+ }
33
+ if (!isSelectQuery(args.query)) {
34
+ throw new Error("SPARQL query must be a SELECT query");
35
+ }
36
+ if (!isPublicRemoteEndpoint(args.endpoint)) {
37
+ throw new Error("SPARQL endpoint must target a public remote host");
38
+ }
39
+ if (args.timeoutMs !== undefined &&
40
+ (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
41
+ throw new Error("SPARQL timeoutMs must be a positive number when provided");
42
+ }
43
+ }
44
+ function isRemoteHttpEndpoint(endpoint) {
45
+ try {
46
+ const url = new URL(endpoint);
47
+ return url.protocol === "http:" || url.protocol === "https:";
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function isSelectQuery(query) {
54
+ return /^\s*select\b/i.test(query);
55
+ }
56
+ function isPublicRemoteEndpoint(endpoint) {
57
+ const url = new URL(endpoint);
58
+ const host = normalizeHostname(url.hostname);
59
+ return !isLocalOrPrivateHost(host);
60
+ }
61
+ function normalizeHostname(hostname) {
62
+ return hostname
63
+ .toLowerCase()
64
+ .replace(/^\[/, "")
65
+ .replace(/\]$/, "")
66
+ .replace(/\.$/, "");
67
+ }
68
+ function isLocalOrPrivateHost(host) {
69
+ if (host === "localhost" ||
70
+ host.endsWith(".localhost") ||
71
+ host === "0.0.0.0" ||
72
+ host === "::1" ||
73
+ (host.includes(":") &&
74
+ (host.startsWith("fe80:") ||
75
+ host.startsWith("fc") ||
76
+ host.startsWith("fd")))) {
77
+ return true;
78
+ }
79
+ const octets = host.split(".").map((part) => Number(part));
80
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) {
81
+ return false;
82
+ }
83
+ const [first = -1, second = -1] = octets;
84
+ return (first === 10 ||
85
+ first === 127 ||
86
+ (first === 169 && second === 254) ||
87
+ (first === 172 && second >= 16 && second <= 31) ||
88
+ (first === 192 && second === 168));
89
+ }
90
+ function toSparqlOptions(args) {
91
+ if (args.timeoutMs === undefined) {
92
+ return "[]";
93
+ }
94
+ const timeoutSeconds = Math.max(1, Math.ceil(args.timeoutMs / 1000));
95
+ return `[timeout(${timeoutSeconds})]`;
96
+ }
@@ -525,6 +525,9 @@ export async function handleKbSuggestPredicates(prolog, args) {
525
525
  })
526
526
  .slice(0, maxCandidates)
527
527
  .map((scored) => buildSuggestion(scored.schema, text, subject, scored.score));
528
+ if (candidates.length === 0) {
529
+ warnings.push("No predicate candidate met minScore. If this is recurring domain language, create a fact_kind=predicate_schema fact; otherwise keep the generated review:ontology-gap observation. Do not invent unsupported predicate names without a predicate_schema.");
530
+ }
528
531
  const recommendedAction = candidates.length > 0 ? "apply_requires_predicate" : "record_ontology_gap";
529
532
  const firstCandidate = candidates[0];
530
533
  const applyPlan = firstCandidate
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  */
20
20
  import Ajv from "ajv";
21
21
  import { escapeAtom, toPrologAtom, toPrologString, } from "kibi-cli/prolog/codec";
22
+ import { SYMBOL_ROLES, getBehavioralSymbolNames, getNonBehavioralSymbolNames, inferSymbolRole, isAllowedGranularityReason, isTraceabilityRelationshipType, } from "kibi-cli/public/symbol-granularity";
22
23
  import entitySchema from "kibi-cli/schemas/entity";
23
24
  import relationshipSchema from "kibi-cli/schemas/relationship";
24
25
  import { Project, ScriptKind } from "ts-morph";
@@ -46,72 +47,136 @@ const validateEntity = ajv.compile({
46
47
  "legacy-link",
47
48
  ],
48
49
  },
50
+ symbol_role: {
51
+ type: "string",
52
+ enum: [...SYMBOL_ROLES],
53
+ },
49
54
  },
50
55
  });
51
56
  const validateRelationship = ajv.compile(relationshipSchema);
52
- const TRACEABILITY_RELATIONSHIP_TYPES = new Set([
53
- "implements",
54
- "covered_by",
55
- "executable_for",
57
+ const PROPERTY_ALIAS_HINTS = new Map([
58
+ ["subjectKey", "subject_key"],
59
+ ["propertyKey", "property_key"],
60
+ ["predicateName", "predicate_name"],
61
+ ["predicateArgs", "predicate_args"],
62
+ ["canonicalKey", "canonical_key"],
63
+ ["closedWorld", "closed_world"],
56
64
  ]);
57
- const ALLOWED_GRANULARITY_REASONS = new Set([
58
- "config-artifact",
59
- "module-level-behavior",
60
- "extractor-miss",
61
- "legacy-link",
62
- ]);
63
- /**
64
- * Handle kb.upsert tool calls
65
- * Accepts { type, id, properties } — the flat format matching the tool schema.
66
- * Validates the assembled entity against JSON Schema before Prolog writes.
67
- * implements REQ-002, REQ-011
68
- */
69
- export async function handleKbUpsert(prolog, args) {
70
- const { type, id, properties, relationships = [] } = args;
71
- if (!type || !id) {
72
- throw new Error("'type' and 'id' are required for upsert");
65
+ const PROPERTY_VALUE_FIELDS = [
66
+ "value_string",
67
+ "value_int",
68
+ "value_number",
69
+ "value_bool",
70
+ ];
71
+ function valueFieldHint(value) {
72
+ if (typeof value === "boolean") {
73
+ return `Use value_type: "bool" plus value_bool: ${String(value)}.`;
73
74
  }
74
- // Assemble full entity from flat args + properties
75
- const entity = {
76
- id,
77
- type,
78
- ...properties,
79
- };
80
- // Fill in defaults for optional required fields
81
- if (!entity.created_at) {
82
- entity.created_at = new Date().toISOString();
75
+ if (typeof value === "number") {
76
+ return Number.isInteger(value)
77
+ ? `Use value_type: "int" plus value_int: ${String(value)}.`
78
+ : `Use value_type: "number" plus value_number: ${String(value)}.`;
83
79
  }
84
- if (!entity.updated_at) {
85
- entity.updated_at = new Date().toISOString();
80
+ if (typeof value === "string") {
81
+ return `Use value_type: "string" plus value_string: ${JSON.stringify(value)}.`;
86
82
  }
87
- if (!entity.source) {
88
- entity.source = "mcp://kibi/upsert";
83
+ return "Use value_type plus exactly one of value_string, value_int, value_number, or value_bool.";
84
+ }
85
+ function ajvErrorParams(error) {
86
+ return error.params;
87
+ }
88
+ function factKindShapeHints(entity) {
89
+ if (entity.type !== "fact")
90
+ return [];
91
+ if (entity.fact_kind === "property_value") {
92
+ const missing = [
93
+ "subject_key",
94
+ "property_key",
95
+ "operator",
96
+ "value_type",
97
+ ].filter((field) => entity[field] === undefined);
98
+ const presentValueFields = PROPERTY_VALUE_FIELDS.filter((field) => entity[field] !== undefined);
99
+ const hints = [];
100
+ if (missing.length > 0) {
101
+ hints.push(`fact_kind 'property_value' requires ${missing.join(", ")}.`);
102
+ }
103
+ if (presentValueFields.length !== 1) {
104
+ hints.push("fact_kind 'property_value' requires exactly one typed value field: value_string, value_int, value_number, or value_bool.");
105
+ }
106
+ if (hints.length > 0) {
107
+ hints.push("Next action: use kb_model_requirement for prose claims, or provide subject_key, property_key, operator, value_type, and one value_* field in kb_upsert.properties.");
108
+ }
109
+ return hints;
89
110
  }
90
- const entities = [entity];
91
- // Validate all entities
92
- for (let i = 0; i < entities.length; i++) {
93
- const ent = entities[i];
94
- if (!validateEntity(ent)) {
95
- const errors = validateEntity.errors || [];
96
- const errorMessages = errors
97
- .map((e) => `${e.instancePath || "root"}: ${e.message}`)
98
- .join("; ");
99
- throw new Error(`Entity validation failed: ${errorMessages}`);
111
+ if (entity.fact_kind === "predicate") {
112
+ const predicateArgs = entity.predicate_args;
113
+ const hasPredicateArgs = Array.isArray(predicateArgs) && predicateArgs.length > 0;
114
+ const missing = [
115
+ ...(entity.predicate_name === undefined ? ["predicate_name"] : []),
116
+ ...(!hasPredicateArgs ? ["predicate_args"] : []),
117
+ ...(entity.canonical_key === undefined ? ["canonical_key"] : []),
118
+ ];
119
+ const hints = [];
120
+ if (missing.length > 0) {
121
+ hints.push(`fact_kind 'predicate' requires ${missing.join(", ")}.`);
100
122
  }
123
+ if (hints.length > 0) {
124
+ hints.push("Next action: call kb_suggest_predicates before hand-writing ontology predicate facts.");
125
+ }
126
+ return hints;
101
127
  }
102
- // Validate all relationships
103
- for (let i = 0; i < relationships.length; i++) {
104
- const rel = relationships[i];
105
- if (!validateRelationship(rel)) {
106
- const errors = validateRelationship.errors || [];
107
- const errorMessages = errors
108
- .map((e) => `${e.instancePath || "root"}: ${e.message}`)
109
- .join("; ");
110
- throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
128
+ return [];
129
+ }
130
+ function formatEntityValidationErrors(entity, errors) {
131
+ const messages = errors.map((error) => {
132
+ const path = error.instancePath || "root";
133
+ const params = ajvErrorParams(error);
134
+ if (error.keyword === "additionalProperties" && params.additionalProperty) {
135
+ const property = params.additionalProperty;
136
+ const suggested = PROPERTY_ALIAS_HINTS.get(property);
137
+ if (property === "value") {
138
+ return `${path}: unknown property 'value'. ${valueFieldHint(entity.value)} Do not use generic value in kb_upsert.properties.`;
139
+ }
140
+ if (suggested) {
141
+ return `${path}: unknown property '${property}'. Did you mean '${suggested}'? kb_upsert.properties uses snake_case typed fact fields.`;
142
+ }
143
+ }
144
+ if (error.keyword === "enum" && params.allowedValues) {
145
+ return `${path}: ${error.message}. Allowed values: ${params.allowedValues.map(String).join(", ")}`;
146
+ }
147
+ return `${path}: ${error.message}`;
148
+ });
149
+ const extraHints = factKindShapeHints(entity);
150
+ for (const [alias, canonical] of PROPERTY_ALIAS_HINTS) {
151
+ if (Object.prototype.hasOwnProperty.call(entity, alias)) {
152
+ extraHints.push(`Unknown property '${alias}'. Use '${canonical}' in kb_upsert.properties.`);
111
153
  }
112
154
  }
113
- validateRelationshipSources(id, relationships);
114
- validateSymbolGranularity(entity, relationships);
155
+ if (Object.prototype.hasOwnProperty.call(entity, "value")) {
156
+ extraHints.push(valueFieldHint(entity.value));
157
+ }
158
+ if (Object.keys(entity).some((key) => PROPERTY_ALIAS_HINTS.has(key)) ||
159
+ Object.prototype.hasOwnProperty.call(entity, "value")) {
160
+ extraHints.push("Next action: if starting from prose, call kb_model_requirement and apply its sequential applyPlan instead of guessing field names.");
161
+ }
162
+ return [...messages, ...extraHints].join("; ");
163
+ }
164
+ function validateFactModelingShape(entity) {
165
+ const hints = factKindShapeHints(entity);
166
+ if (hints.length > 0) {
167
+ throw new Error(`Entity validation failed: ${hints.join("; ")}`);
168
+ }
169
+ }
170
+ /**
171
+ * Handle kb.upsert tool calls
172
+ * Accepts { type, id, properties } — the flat format matching the tool schema.
173
+ * Validates the assembled entity against JSON Schema before Prolog writes.
174
+ * implements REQ-002, REQ-011
175
+ */
176
+ export async function handleKbUpsert(prolog, args) {
177
+ const { entity, relationships } = validateKbUpsertArgs(args);
178
+ const type = entity.type;
179
+ const entities = [entity];
115
180
  // Validate strict-lane fact_kind pairing for constrains/requires_property
116
181
  // implements REQ-011
117
182
  await validateStrictLanePairing(prolog, relationships);
@@ -193,12 +258,12 @@ export async function handleKbUpsert(prolog, args) {
193
258
  }
194
259
  if (type === "symbol") {
195
260
  try {
196
- await refreshCoordinatesForSymbolIdImpl(id);
261
+ await refreshCoordinatesForSymbolIdImpl(entity.id);
197
262
  }
198
263
  catch (error) {
199
264
  const message = error instanceof Error ? error.message : String(error);
200
265
  if (isMcpDebugEnabled()) {
201
- console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${id}: ${message}`);
266
+ console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${String(entity.id)}: ${message}`);
202
267
  }
203
268
  }
204
269
  }
@@ -206,7 +271,7 @@ export async function handleKbUpsert(prolog, args) {
206
271
  content: [
207
272
  {
208
273
  type: "text",
209
- text: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
274
+ text: `Upserted ${String(entity.id)} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
210
275
  },
211
276
  ],
212
277
  structuredContent: {
@@ -221,6 +286,45 @@ export async function handleKbUpsert(prolog, args) {
221
286
  throw new Error(`Upsert execution failed: ${message}`);
222
287
  }
223
288
  }
289
+ export function validateKbUpsertArgs(args) {
290
+ const { type, id, properties, relationships = [] } = args;
291
+ if (!type || !id) {
292
+ throw new Error("'type' and 'id' are required for upsert");
293
+ }
294
+ const entity = {
295
+ id,
296
+ type,
297
+ ...properties,
298
+ };
299
+ if (!entity.created_at) {
300
+ entity.created_at = new Date().toISOString();
301
+ }
302
+ if (!entity.updated_at) {
303
+ entity.updated_at = new Date().toISOString();
304
+ }
305
+ if (!entity.source) {
306
+ entity.source = "mcp://kibi/upsert";
307
+ }
308
+ if (!validateEntity(entity)) {
309
+ const errors = validateEntity.errors || [];
310
+ const errorMessages = formatEntityValidationErrors(entity, errors);
311
+ throw new Error(`Entity validation failed: ${errorMessages}`);
312
+ }
313
+ validateFactModelingShape(entity);
314
+ for (let i = 0; i < relationships.length; i++) {
315
+ const rel = relationships[i];
316
+ if (!validateRelationship(rel)) {
317
+ const errors = validateRelationship.errors || [];
318
+ const errorMessages = errors
319
+ .map((e) => `${e.instancePath || "root"}: ${e.message}`)
320
+ .join("; ");
321
+ throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
322
+ }
323
+ }
324
+ validateRelationshipSources(id, relationships);
325
+ validateSymbolGranularity(entity, relationships);
326
+ return { entity, relationships };
327
+ }
224
328
  function chooseScriptKind(filePath) {
225
329
  const lower = filePath.toLowerCase();
226
330
  if (lower.endsWith(".tsx"))
@@ -235,58 +339,69 @@ function chooseScriptKind(filePath) {
235
339
  return ScriptKind.JS;
236
340
  }
237
341
  function hasTraceabilityRelationship(relationships) {
238
- return relationships.some((relationship) => typeof relationship.type === "string" &&
239
- TRACEABILITY_RELATIONSHIP_TYPES.has(relationship.type));
342
+ return relationships.some((relationship) => isTraceabilityRelationshipType(relationship.type));
240
343
  }
241
344
  function hasAllowedGranularityReason(entity) {
242
- const reason = entity.granularity_reason;
243
- return typeof reason === "string" && ALLOWED_GRANULARITY_REASONS.has(reason);
345
+ return isAllowedGranularityReason(entity.granularity_reason);
244
346
  }
245
- function collectNarrowExportNames(filePath, content) {
347
+ function createSymbolCandidate(name, kind) {
348
+ return {
349
+ name,
350
+ kind,
351
+ role: inferSymbolRole(kind),
352
+ };
353
+ }
354
+ function collectGranularSymbolCandidates(filePath, content) {
246
355
  const project = new Project({ skipAddingFilesFromTsConfig: true });
247
356
  const sourceFile = project.createSourceFile(`${filePath}::granularity`, content, {
248
357
  overwrite: true,
249
358
  scriptKind: chooseScriptKind(filePath),
250
359
  });
251
- const names = new Set();
360
+ const candidates = [];
252
361
  const methodNameCounts = new Map();
362
+ const bareMethodCandidates = new Map();
253
363
  for (const fn of sourceFile.getFunctions()) {
254
364
  if (fn.isExported()) {
255
365
  const name = fn.getName();
256
366
  if (name)
257
- names.add(name);
367
+ candidates.push(createSymbolCandidate(name, "function"));
258
368
  }
259
369
  }
260
370
  for (const cls of sourceFile.getClasses()) {
261
371
  if (cls.isExported()) {
262
372
  const name = cls.getName();
263
373
  if (name)
264
- names.add(name);
374
+ candidates.push(createSymbolCandidate(name, "class"));
265
375
  for (const method of cls.getMethods()) {
266
376
  const methodName = method.getName();
267
- if (name)
268
- names.add(`${name}.${methodName}`);
377
+ if (name) {
378
+ candidates.push(createSymbolCandidate(`${name}.${methodName}`, "method"));
379
+ }
380
+ bareMethodCandidates.set(methodName, createSymbolCandidate(methodName, "method"));
269
381
  methodNameCounts.set(methodName, (methodNameCounts.get(methodName) ?? 0) + 1);
270
382
  }
271
383
  }
272
384
  }
273
385
  for (const [methodName, count] of methodNameCounts) {
274
- if (count === 1)
275
- names.add(methodName);
386
+ const candidate = bareMethodCandidates.get(methodName);
387
+ if (count === 1 && candidate)
388
+ candidates.push(candidate);
276
389
  }
277
390
  for (const iface of sourceFile.getInterfaces()) {
278
- if (iface.isExported())
279
- names.add(iface.getName());
391
+ if (iface.isExported()) {
392
+ candidates.push(createSymbolCandidate(iface.getName(), "interface"));
393
+ }
280
394
  }
281
395
  for (const alias of sourceFile.getTypeAliases()) {
282
- if (alias.isExported())
283
- names.add(alias.getName());
396
+ if (alias.isExported()) {
397
+ candidates.push(createSymbolCandidate(alias.getName(), "type"));
398
+ }
284
399
  }
285
400
  for (const en of sourceFile.getEnums()) {
286
401
  if (en.isExported())
287
- names.add(en.getName());
402
+ candidates.push(createSymbolCandidate(en.getName(), "enum"));
288
403
  }
289
- return [...names].sort();
404
+ return candidates.sort((a, b) => a.name.localeCompare(b.name));
290
405
  }
291
406
  function validateSymbolGranularity(entity, relationships) {
292
407
  if (entity.type !== "symbol")
@@ -304,12 +419,20 @@ function validateSymbolGranularity(entity, relationships) {
304
419
  : path.resolve(process.cwd(), entity.sourceFile);
305
420
  if (!existsSync(sourcePath))
306
421
  return;
307
- const narrowNames = collectNarrowExportNames(entity.sourceFile, readFileSync(sourcePath, "utf8"));
308
- if (narrowNames.length === 0)
422
+ const candidates = collectGranularSymbolCandidates(entity.sourceFile, readFileSync(sourcePath, "utf8"));
423
+ const candidateNames = [
424
+ ...new Set(candidates.map((candidate) => candidate.name)),
425
+ ];
426
+ if (candidateNames.includes(entity.title))
309
427
  return;
310
- if (narrowNames.includes(entity.title))
428
+ const behavioralNames = getBehavioralSymbolNames(candidates);
429
+ if (behavioralNames.length === 0)
311
430
  return;
312
- throw new Error(`Symbol ${String(entity.id)} links ${entity.sourceFile} coarsely while granular symbols are available: ${narrowNames.join(", ")}. Move relationships to the narrow symbol or set granularity_reason to config-artifact, module-level-behavior, extractor-miss, or legacy-link.`);
431
+ const nonBehavioralNames = getNonBehavioralSymbolNames(candidates);
432
+ const ignoredSymbolsMessage = nonBehavioralNames.length > 0
433
+ ? ` Non-behavioral symbols in the file were ignored for this decision: ${nonBehavioralNames.join(", ")}.`
434
+ : "";
435
+ throw new Error(`Symbol ${String(entity.id)} links ${entity.sourceFile} coarsely while granular symbols are available (behavioral only): ${behavioralNames.join(", ")}. Move relationships to a behavioral symbol, add a manifest behavioral anchor, or set granularity_reason to config-artifact, module-level-behavior, extractor-miss, or legacy-link.${ignoredSymbolsMessage}`);
313
436
  }
314
437
  export const __test__ = {
315
438
  // implements REQ-vscode-traceability
@@ -333,6 +456,7 @@ function buildPropertyList(entity) {
333
456
  "owner",
334
457
  "priority",
335
458
  "severity",
459
+ "symbol_role",
336
460
  // Typed fact enum fields must be atoms for Prolog validation
337
461
  "fact_kind",
338
462
  "operator",
@@ -0,0 +1,37 @@
1
+ import { validateKbUpsertArgs } from "./upsert.js";
2
+ export async function handleKbValidateUpsert(args) {
3
+ try {
4
+ const { entity } = validateKbUpsertArgs(args);
5
+ return {
6
+ content: [
7
+ {
8
+ type: "text",
9
+ text: "kb_validate_upsert: payload is valid for kb_upsert preflight checks. No mutation was performed.",
10
+ },
11
+ ],
12
+ structuredContent: {
13
+ valid: true,
14
+ errors: [],
15
+ warnings: [],
16
+ normalizedPreview: entity,
17
+ },
18
+ };
19
+ }
20
+ catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ return {
23
+ content: [
24
+ {
25
+ type: "text",
26
+ text: `kb_validate_upsert: payload is invalid. ${message}`,
27
+ },
28
+ ],
29
+ structuredContent: {
30
+ valid: false,
31
+ errors: [message],
32
+ warnings: [],
33
+ normalizedPreview: null,
34
+ },
35
+ };
36
+ }
37
+ }
@@ -270,9 +270,31 @@ const BASE_TOOLS = [
270
270
  },
271
271
  },
272
272
  },
273
+ {
274
+ name: "kb_sparql_remote",
275
+ description: "Opt-in remote SPARQL query tool for external HTTP(S) RDF endpoints. This does not query Kibi's local RDF store directly, stores no credentials, and depends on network availability.",
276
+ inputSchema: {
277
+ type: "object",
278
+ required: ["endpoint", "query"],
279
+ properties: {
280
+ endpoint: {
281
+ type: "string",
282
+ description: "Remote SPARQL endpoint URL. Must start with http:// or https://.",
283
+ },
284
+ query: {
285
+ type: "string",
286
+ description: "SPARQL SELECT query to send to the remote endpoint.",
287
+ },
288
+ timeoutMs: {
289
+ type: "number",
290
+ description: "Optional positive timeout in milliseconds for the remote query.",
291
+ },
292
+ },
293
+ },
294
+ },
273
295
  {
274
296
  name: "kb_upsert",
275
- description: "Create or update one entity and optional relationships. Use for KB mutations after validating intent. Use the `relationships` array for batch creation of multiple links in a single call (e.g., linking a requirement to multiple tests or facts). Prefer modeling requirements as reusable fact links (`constrains`, `requires_property`, or `requires_predicate`) so consistency and contradiction checks remain queryable. Relationship endpoints must already exist in KB. For requirements, the write will be rejected if it contradicts existing current requirements that constrain the same subject with incompatible properties. To replace a conflicting requirement, include a `supersedes` relationship from the new requirement to the old one in the same request. Do not use for read-only inspection. Side effects: writes KB, may refresh symbol coordinates.",
297
+ description: "Create or update one entity and optional relationships. Use for KB mutations after validating intent. Use kb_model_requirement before hand-writing strict property facts from prose, and kb_suggest_predicates before hand-writing ontology predicate facts. Use the `relationships` array for batch creation of multiple links in a single call (e.g., linking a requirement to multiple tests or facts). Prefer modeling requirements as reusable fact links (`constrains`, `requires_property`, or `requires_predicate`) so consistency and contradiction checks remain queryable. Relationship endpoints must already exist in KB. For requirements, the write will be rejected if it contradicts existing current requirements that constrain the same subject with incompatible properties. To replace a conflicting requirement, include a `supersedes` relationship from the new requirement to the old one in the same request. Do not use for read-only inspection. Side effects: writes KB, may refresh symbol coordinates.",
276
298
  inputSchema: {
277
299
  type: "object",
278
300
  required: ["type", "id", "properties"],
@@ -351,6 +373,18 @@ const BASE_TOOLS = [
351
373
  ],
352
374
  description: "Optional justification for a coarse file/module-level symbol traceability relationship when narrower function/class/type symbols exist.",
353
375
  },
376
+ symbol_role: {
377
+ type: "string",
378
+ enum: [
379
+ "behavioral",
380
+ "structural",
381
+ "type-shape",
382
+ "config",
383
+ "module",
384
+ "unknown",
385
+ ],
386
+ description: "Optional role classification for symbol entities. Example: 'behavioral'.",
387
+ },
354
388
  fact_kind: {
355
389
  type: "string",
356
390
  enum: [
@@ -361,15 +395,15 @@ const BASE_TOOLS = [
361
395
  "predicate_schema",
362
396
  "predicate",
363
397
  ],
364
- description: "Optional fact lane kind for fact entities. Strict lane uses 'subject' and 'property_value'; context lane uses 'observation' or 'meta'.",
398
+ description: "Optional fact lane kind for fact entities. Strict lane uses 'subject' and 'property_value'; context lane uses 'observation' or 'meta'; ontology lane uses 'predicate_schema' or 'predicate'. Use kb_model_requirement or kb_suggest_predicates when starting from prose.",
365
399
  },
366
400
  subject_key: {
367
401
  type: "string",
368
- description: "Optional canonical subject key for strict fact entities. Example: 'user.session'.",
402
+ description: "Snake_case only. Optional canonical subject key for strict fact entities. Example: 'user.session'. Do not use subjectKey in kb_upsert.properties.",
369
403
  },
370
404
  property_key: {
371
405
  type: "string",
372
- description: "Optional canonical property key for property_value facts. Example: 'session.timeout_minutes'.",
406
+ description: "Snake_case only. Optional canonical property key for property_value facts. Example: 'session.timeout_minutes'. Do not use propertyKey in kb_upsert.properties.",
373
407
  },
374
408
  operator: {
375
409
  type: "string",
@@ -379,7 +413,7 @@ const BASE_TOOLS = [
379
413
  value_type: {
380
414
  type: "string",
381
415
  enum: ["string", "int", "number", "bool"],
382
- description: "Optional typed value discriminator for property_value facts.",
416
+ description: "Optional typed value discriminator for property_value facts. Pair with exactly one value_string, value_int, value_number, or value_bool; do not use generic value.",
383
417
  },
384
418
  value_string: {
385
419
  type: "string",
@@ -420,12 +454,12 @@ const BASE_TOOLS = [
420
454
  },
421
455
  predicate_name: {
422
456
  type: "string",
423
- description: "Optional predicate name for ontology predicate facts.",
457
+ description: "Optional predicate name for ontology predicate facts. Prefer kb_suggest_predicates before hand-writing predicate_name.",
424
458
  },
425
459
  predicate_args: {
426
460
  type: "array",
427
461
  items: { type: "string" },
428
- description: "Optional ordered predicate arguments for ontology predicate facts.",
462
+ description: "Optional ordered predicate arguments for ontology predicate facts. Prefer kb_suggest_predicates before hand-writing predicate_args.",
429
463
  },
430
464
  },
431
465
  required: ["title", "status"],
@@ -473,6 +507,38 @@ const BASE_TOOLS = [
473
507
  },
474
508
  },
475
509
  },
510
+ {
511
+ name: "kb_validate_upsert",
512
+ description: "Validate a kb_upsert payload without mutating the KB. Use this read-only preflight when modeling strict facts or predicates and you want actionable schema/modeling errors before calling kb_upsert.",
513
+ inputSchema: {
514
+ type: "object",
515
+ required: ["type", "id", "properties"],
516
+ properties: {
517
+ type: {
518
+ type: "string",
519
+ enum: [
520
+ "req",
521
+ "scenario",
522
+ "test",
523
+ "adr",
524
+ "flag",
525
+ "event",
526
+ "symbol",
527
+ "fact",
528
+ ],
529
+ },
530
+ id: { type: "string" },
531
+ properties: {
532
+ type: "object",
533
+ description: "Entity properties to validate using the same snake_case field names accepted by kb_upsert.",
534
+ },
535
+ relationships: {
536
+ type: "array",
537
+ items: { type: "object" },
538
+ },
539
+ },
540
+ },
541
+ },
476
542
  {
477
543
  name: "kb_delete",
478
544
  description: "Delete entities by ID. Use only for intentional removals after dependency checks. Do not use as a bulk cleanup shortcut. Side effects: mutates and saves KB; skips entities with dependents.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -9,8 +9,8 @@
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.12.4",
13
- "kibi-core": "^0.6.0",
12
+ "kibi-cli": "^0.12.5",
13
+ "kibi-core": "^0.6.1",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"