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.
- package/dist/server/tools.js +11 -0
- package/dist/tools/model-requirement.js +10 -0
- package/dist/tools/sparql.js +96 -0
- package/dist/tools/suggest-predicates.js +3 -0
- package/dist/tools/upsert.js +203 -79
- package/dist/tools/validate-upsert.js +37 -0
- package/dist/tools-config.js +73 -7
- package/package.json +3 -3
package/dist/server/tools.js
CHANGED
|
@@ -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
|
package/dist/tools/upsert.js
CHANGED
|
@@ -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
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
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
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
]
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
85
|
-
|
|
80
|
+
if (typeof value === "string") {
|
|
81
|
+
return `Use value_type: "string" plus value_string: ${JSON.stringify(value)}.`;
|
|
86
82
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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) =>
|
|
239
|
-
TRACEABILITY_RELATIONSHIP_TYPES.has(relationship.type));
|
|
342
|
+
return relationships.some((relationship) => isTraceabilityRelationshipType(relationship.type));
|
|
240
343
|
}
|
|
241
344
|
function hasAllowedGranularityReason(entity) {
|
|
242
|
-
|
|
243
|
-
return typeof reason === "string" && ALLOWED_GRANULARITY_REASONS.has(reason);
|
|
345
|
+
return isAllowedGranularityReason(entity.granularity_reason);
|
|
244
346
|
}
|
|
245
|
-
function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
374
|
+
candidates.push(createSymbolCandidate(name, "class"));
|
|
265
375
|
for (const method of cls.getMethods()) {
|
|
266
376
|
const methodName = method.getName();
|
|
267
|
-
if (name)
|
|
268
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
+
candidates.push(createSymbolCandidate(en.getName(), "enum"));
|
|
288
403
|
}
|
|
289
|
-
return
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
428
|
+
const behavioralNames = getBehavioralSymbolNames(candidates);
|
|
429
|
+
if (behavioralNames.length === 0)
|
|
311
430
|
return;
|
|
312
|
-
|
|
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
|
+
}
|
package/dist/tools-config.js
CHANGED
|
@@ -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.
|
|
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.
|
|
13
|
-
"kibi-core": "^0.6.
|
|
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"
|