@revealui/mcp 0.7.0 → 0.7.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/author/errors.d.ts +2 -2
- package/dist/author/errors.d.ts.map +1 -1
- package/dist/author/errors.js +2 -2
- package/dist/author/errors.js.map +1 -1
- package/dist/author/index.d.ts +1 -1
- package/dist/author/index.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/servers/factories/contracts.d.ts +1 -1
- package/dist/servers/factories/contracts.d.ts.map +1 -1
- package/dist/servers/factories/contracts.js +0 -2
- package/dist/servers/factories/contracts.js.map +1 -1
- package/dist/servers/factories/knowledge-graph.d.ts +272 -0
- package/dist/servers/factories/knowledge-graph.d.ts.map +1 -0
- package/dist/servers/factories/knowledge-graph.js +666 -0
- package/dist/servers/factories/knowledge-graph.js.map +1 -0
- package/package.json +17 -8
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory for the `knowledge-graph` MCP server (GAP-349 P2 — agent surfaces for
|
|
3
|
+
* the fleet knowledge graph; P1 shipped the schema + `@revealui/knowledge-graph`
|
|
4
|
+
* core in revealui#1858).
|
|
5
|
+
*
|
|
6
|
+
* Tools exposed:
|
|
7
|
+
* kg_search — hybrid retrieval (vector + FTS + BFS traversal, RRF-fused)
|
|
8
|
+
* kg_get_node — fetch a node + its current facts by natural key
|
|
9
|
+
* kg_neighbors — BFS neighbors of a node (current or point-in-time)
|
|
10
|
+
* kg_add_episode — THE ONLY WRITE TOOL. Additive ingestion (never a rescan).
|
|
11
|
+
* kg_path — shortest path between two nodes
|
|
12
|
+
* kg_at_time — a node's facts as of a point-in-time timestamp
|
|
13
|
+
* kg_context — budgeted context assembly (design spec §8.4): BFS from an
|
|
14
|
+
* anchor, rerank by node-distance + episode-mentions, pack
|
|
15
|
+
* node summaries + edge facts (with provenance episode ids)
|
|
16
|
+
* into a text block capped at `charBudget` characters.
|
|
17
|
+
*
|
|
18
|
+
* All tools operate over a `KgExecutor` (the driver-agnostic query interface
|
|
19
|
+
* `@revealui/knowledge-graph` already exposes for Neon-in-prod / PGlite-in-tests
|
|
20
|
+
* parity). Production resolves a pooled executor lazily from `@revealui/db/pool`
|
|
21
|
+
* (the same connection pattern `revkg`'s CLI uses — extend, not duplicate); tests
|
|
22
|
+
* inject a PGlite-backed executor via `CreateKnowledgeGraphServerOptions.executor`.
|
|
23
|
+
*
|
|
24
|
+
* Read tools degrade gracefully with no pgvector and no embeddings present:
|
|
25
|
+
* `kgSearch`'s vector channel is skipped whenever no query embedding is
|
|
26
|
+
* supplied, and query-embedding generation here is best-effort — an
|
|
27
|
+
* `@revealui/ai` import failure or an Ollama-down `generateEmbedding()` call
|
|
28
|
+
* both fall back to FTS + BFS only, never a hard failure.
|
|
29
|
+
*
|
|
30
|
+
* `kg_add_episode` is the only write tool. It always calls the additive
|
|
31
|
+
* `ingestEpisode` (never `applyScan`, which is the deterministic-rescan path
|
|
32
|
+
* reserved for `revkg scan`), Zod-validates `episodeType` plus every node kind
|
|
33
|
+
* and edge relation against the ontology enums, and accepts no raw SQL or
|
|
34
|
+
* table-name input of any kind.
|
|
35
|
+
*/
|
|
36
|
+
import { hostname } from 'node:os';
|
|
37
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
38
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
39
|
+
import { EDGE_RELATIONS, ingestEpisode, kgAtTime, kgNeighbors, kgPath, kgSearch, makePoolExecutor, NODE_KINDS, } from '@revealui/knowledge-graph';
|
|
40
|
+
import { resolveNaturalKey } from '@revealui/knowledge-graph/ingest';
|
|
41
|
+
import { z } from 'zod/v4';
|
|
42
|
+
import { validateToolArgs } from '../../validate-tool-args.js';
|
|
43
|
+
const SERVER_NAME = 'knowledge-graph';
|
|
44
|
+
const SERVER_VERSION = '0.1.0';
|
|
45
|
+
/** Text block budget for `kg_context` — chars, not tokens, per GAP-349 P2 scope. */
|
|
46
|
+
const DEFAULT_CONTEXT_CHAR_BUDGET = 16_000;
|
|
47
|
+
/**
|
|
48
|
+
* Mirrors `EpisodeType` in `@revealui/knowledge-graph/src/types.ts`. That union
|
|
49
|
+
* is a compile-time type only (not exported as a runtime array by P1), so the
|
|
50
|
+
* literal list is duplicated here for Zod enum validation — keep it in
|
|
51
|
+
* lockstep with `types.ts` on any future P3 change.
|
|
52
|
+
*/
|
|
53
|
+
const EPISODE_TYPES = [
|
|
54
|
+
'code-scan',
|
|
55
|
+
'git-commit',
|
|
56
|
+
'doc',
|
|
57
|
+
'agent-fact',
|
|
58
|
+
'memory',
|
|
59
|
+
'json',
|
|
60
|
+
'manual',
|
|
61
|
+
];
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Tool argument schemas (Zod 4)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
export const KgSearchArgsSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
query: z.string().min(1),
|
|
68
|
+
anchor: z.string().min(1).optional(),
|
|
69
|
+
kinds: z.array(z.enum(NODE_KINDS)).optional(),
|
|
70
|
+
relations: z.array(z.enum(EDGE_RELATIONS)).optional(),
|
|
71
|
+
at: z.string().datetime().optional(),
|
|
72
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
73
|
+
bfsDepth: z.number().int().min(1).max(6).optional(),
|
|
74
|
+
})
|
|
75
|
+
.strict();
|
|
76
|
+
export const KgGetNodeArgsSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
naturalKey: z.string().min(1),
|
|
79
|
+
})
|
|
80
|
+
.strict();
|
|
81
|
+
export const KgNeighborsArgsSchema = z
|
|
82
|
+
.object({
|
|
83
|
+
naturalKey: z.string().min(1),
|
|
84
|
+
depth: z.number().int().min(1).max(6).optional(),
|
|
85
|
+
relations: z.array(z.enum(EDGE_RELATIONS)).optional(),
|
|
86
|
+
at: z.string().datetime().optional(),
|
|
87
|
+
})
|
|
88
|
+
.strict();
|
|
89
|
+
const NodeRefArgsSchema = z
|
|
90
|
+
.object({
|
|
91
|
+
kind: z.enum(NODE_KINDS),
|
|
92
|
+
naturalKey: z.string().min(1),
|
|
93
|
+
})
|
|
94
|
+
.strict();
|
|
95
|
+
const NodeInputArgsSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
kind: z.enum(NODE_KINDS),
|
|
98
|
+
name: z.string().min(1),
|
|
99
|
+
naturalKey: z.string().min(1),
|
|
100
|
+
repo: z.string().min(1).optional(),
|
|
101
|
+
summary: z.string().optional(),
|
|
102
|
+
attributes: z.record(z.string(), z.unknown()).optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
const EdgeInputArgsSchema = z
|
|
106
|
+
.object({
|
|
107
|
+
source: NodeRefArgsSchema,
|
|
108
|
+
target: NodeRefArgsSchema,
|
|
109
|
+
relation: z.enum(EDGE_RELATIONS),
|
|
110
|
+
fact: z.string().min(1),
|
|
111
|
+
repo: z.string().min(1).optional(),
|
|
112
|
+
validAt: z.string().datetime().optional(),
|
|
113
|
+
attributes: z.record(z.string(), z.unknown()).optional(),
|
|
114
|
+
})
|
|
115
|
+
.strict();
|
|
116
|
+
export const KgAddEpisodeArgsSchema = z
|
|
117
|
+
.object({
|
|
118
|
+
episodeType: z.enum(EPISODE_TYPES),
|
|
119
|
+
source: z.string().min(1),
|
|
120
|
+
content: z.string().optional(),
|
|
121
|
+
contentRef: z.record(z.string(), z.unknown()).optional(),
|
|
122
|
+
referenceTime: z.string().datetime().optional(),
|
|
123
|
+
siteId: z.string().min(1).optional(),
|
|
124
|
+
nodes: z.array(NodeInputArgsSchema).default([]),
|
|
125
|
+
edges: z.array(EdgeInputArgsSchema).default([]),
|
|
126
|
+
})
|
|
127
|
+
.strict();
|
|
128
|
+
export const KgPathArgsSchema = z
|
|
129
|
+
.object({
|
|
130
|
+
fromNaturalKey: z.string().min(1),
|
|
131
|
+
toNaturalKey: z.string().min(1),
|
|
132
|
+
at: z.string().datetime().optional(),
|
|
133
|
+
maxDepth: z.number().int().min(1).max(12).optional(),
|
|
134
|
+
})
|
|
135
|
+
.strict();
|
|
136
|
+
export const KgAtTimeArgsSchema = z
|
|
137
|
+
.object({
|
|
138
|
+
naturalKey: z.string().min(1),
|
|
139
|
+
at: z.string().datetime(),
|
|
140
|
+
})
|
|
141
|
+
.strict();
|
|
142
|
+
export const KgContextArgsSchema = z
|
|
143
|
+
.object({
|
|
144
|
+
naturalKey: z.string().min(1),
|
|
145
|
+
charBudget: z.number().int().positive().max(200_000).optional(),
|
|
146
|
+
depth: z.number().int().min(1).max(6).optional(),
|
|
147
|
+
at: z.string().datetime().optional(),
|
|
148
|
+
})
|
|
149
|
+
.strict();
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Tool definitions (MCP SDK JSON Schema)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
const NODE_KIND_ENUM = [...NODE_KINDS];
|
|
154
|
+
const EDGE_RELATION_ENUM = [...EDGE_RELATIONS];
|
|
155
|
+
const NODE_REF_SCHEMA = {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
kind: { type: 'string', enum: NODE_KIND_ENUM },
|
|
159
|
+
naturalKey: { type: 'string' },
|
|
160
|
+
},
|
|
161
|
+
required: ['kind', 'naturalKey'],
|
|
162
|
+
};
|
|
163
|
+
const TOOLS = [
|
|
164
|
+
{
|
|
165
|
+
name: 'kg_search',
|
|
166
|
+
description: 'Hybrid search over the fleet knowledge graph: vector + full-text + BFS ' +
|
|
167
|
+
'traversal, RRF-fused, reranked by node-distance and episode-mentions. ' +
|
|
168
|
+
'Returns nodes AND facts (edges) with provenance episode ids. Prefer ' +
|
|
169
|
+
'kg_context for "what do I need to know before touching X" — this tool ' +
|
|
170
|
+
'is for open-ended queries.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
query: { type: 'string', description: 'Free-text query.' },
|
|
175
|
+
anchor: {
|
|
176
|
+
type: 'string',
|
|
177
|
+
description: 'Anchor node id for the BFS traversal channel + node-distance reranker.',
|
|
178
|
+
},
|
|
179
|
+
kinds: {
|
|
180
|
+
type: 'array',
|
|
181
|
+
items: { type: 'string', enum: NODE_KIND_ENUM },
|
|
182
|
+
description: 'Restrict results to these node kinds.',
|
|
183
|
+
},
|
|
184
|
+
relations: {
|
|
185
|
+
type: 'array',
|
|
186
|
+
items: { type: 'string', enum: EDGE_RELATION_ENUM },
|
|
187
|
+
description: 'Restrict fact results to these edge relations.',
|
|
188
|
+
},
|
|
189
|
+
at: { type: 'string', description: 'ISO-8601 point-in-time; omit for the current graph.' },
|
|
190
|
+
limit: { type: 'number', description: 'Max results per list (default 20).' },
|
|
191
|
+
bfsDepth: {
|
|
192
|
+
type: 'number',
|
|
193
|
+
description: 'Max BFS depth for the traversal channel (default 3).',
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
required: ['query'],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'kg_get_node',
|
|
201
|
+
description: 'Fetch a node and its current facts by natural key.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
naturalKey: {
|
|
206
|
+
type: 'string',
|
|
207
|
+
description: 'e.g. "revealui/packages/ai/src/llm/client.ts#getClient"',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
required: ['naturalKey'],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'kg_neighbors',
|
|
215
|
+
description: 'BFS neighbors of a node (current graph, or as of a point-in-time timestamp).',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
naturalKey: { type: 'string' },
|
|
220
|
+
depth: { type: 'number', description: 'Max hops (default 1).' },
|
|
221
|
+
relations: {
|
|
222
|
+
type: 'array',
|
|
223
|
+
items: { type: 'string', enum: EDGE_RELATION_ENUM },
|
|
224
|
+
description: 'Restrict traversal to these edge relations.',
|
|
225
|
+
},
|
|
226
|
+
at: { type: 'string', description: 'ISO-8601 point-in-time; omit for the current graph.' },
|
|
227
|
+
},
|
|
228
|
+
required: ['naturalKey'],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'kg_add_episode',
|
|
233
|
+
description: 'Publish an episode (provenance unit) plus candidate nodes/edges into the ' +
|
|
234
|
+
'graph. The ONLY write tool. Always additive (never a rescan) — use this ' +
|
|
235
|
+
'to durably record an agent discovery (episodeType "agent-fact") or any ' +
|
|
236
|
+
'other unstructured fact, extending the shared_facts / end-of-session ' +
|
|
237
|
+
'publishing flow.',
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
episodeType: { type: 'string', enum: [...EPISODE_TYPES] },
|
|
242
|
+
source: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description: 'e.g. "claude-session", "shared_facts:coord-abc123"',
|
|
245
|
+
},
|
|
246
|
+
content: { type: 'string', description: 'Raw payload or pointer summary.' },
|
|
247
|
+
contentRef: { type: 'object', description: 'e.g. { repo, path, sha, factId }' },
|
|
248
|
+
referenceTime: {
|
|
249
|
+
type: 'string',
|
|
250
|
+
description: 'ISO-8601; when the described state was true. Defaults to now.',
|
|
251
|
+
},
|
|
252
|
+
siteId: {
|
|
253
|
+
type: 'string',
|
|
254
|
+
description: 'Origin machine/replica id. Defaults to hostname().',
|
|
255
|
+
},
|
|
256
|
+
nodes: {
|
|
257
|
+
type: 'array',
|
|
258
|
+
description: 'Candidate nodes to upsert (deterministic ids derived from kind+naturalKey).',
|
|
259
|
+
items: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
kind: { type: 'string', enum: NODE_KIND_ENUM },
|
|
263
|
+
name: { type: 'string' },
|
|
264
|
+
naturalKey: { type: 'string' },
|
|
265
|
+
repo: { type: 'string' },
|
|
266
|
+
summary: { type: 'string' },
|
|
267
|
+
attributes: { type: 'object' },
|
|
268
|
+
},
|
|
269
|
+
required: ['kind', 'name', 'naturalKey'],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
edges: {
|
|
273
|
+
type: 'array',
|
|
274
|
+
description: 'Candidate facts between nodes referenced by kind+naturalKey.',
|
|
275
|
+
items: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: {
|
|
278
|
+
source: NODE_REF_SCHEMA,
|
|
279
|
+
target: NODE_REF_SCHEMA,
|
|
280
|
+
relation: { type: 'string', enum: EDGE_RELATION_ENUM },
|
|
281
|
+
fact: { type: 'string' },
|
|
282
|
+
repo: { type: 'string' },
|
|
283
|
+
validAt: { type: 'string', description: 'ISO-8601; defaults to referenceTime.' },
|
|
284
|
+
attributes: { type: 'object' },
|
|
285
|
+
},
|
|
286
|
+
required: ['source', 'target', 'relation', 'fact'],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
required: ['episodeType', 'source'],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: 'kg_path',
|
|
295
|
+
description: 'Shortest path (node list) between two nodes, current or as of a point-in-time.',
|
|
296
|
+
inputSchema: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
properties: {
|
|
299
|
+
fromNaturalKey: { type: 'string' },
|
|
300
|
+
toNaturalKey: { type: 'string' },
|
|
301
|
+
at: { type: 'string', description: 'ISO-8601 point-in-time; omit for the current graph.' },
|
|
302
|
+
maxDepth: { type: 'number', description: 'Max hops to search (default 6).' },
|
|
303
|
+
},
|
|
304
|
+
required: ['fromNaturalKey', 'toNaturalKey'],
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'kg_at_time',
|
|
309
|
+
description: "A node's facts as of a point-in-time timestamp.",
|
|
310
|
+
inputSchema: {
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
naturalKey: { type: 'string' },
|
|
314
|
+
at: { type: 'string', description: 'ISO-8601 timestamp.' },
|
|
315
|
+
},
|
|
316
|
+
required: ['naturalKey', 'at'],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'kg_context',
|
|
321
|
+
description: 'Budgeted context assembly (design spec §8.4): BFS from an anchor node, ' +
|
|
322
|
+
'rerank by node-distance + episode-mentions, pack node summaries and edge ' +
|
|
323
|
+
'facts (with provenance episode ids) into a text block capped at ' +
|
|
324
|
+
'charBudget characters. The default entry point for "what do I need to ' +
|
|
325
|
+
'know before touching X" — prefer this over kg_search for that use case.',
|
|
326
|
+
inputSchema: {
|
|
327
|
+
type: 'object',
|
|
328
|
+
properties: {
|
|
329
|
+
naturalKey: { type: 'string', description: 'Anchor node natural key.' },
|
|
330
|
+
charBudget: {
|
|
331
|
+
type: 'number',
|
|
332
|
+
description: `Max characters in the packed context block (default ${DEFAULT_CONTEXT_CHAR_BUDGET}).`,
|
|
333
|
+
},
|
|
334
|
+
depth: { type: 'number', description: 'Max BFS depth from the anchor (default 3).' },
|
|
335
|
+
at: { type: 'string', description: 'ISO-8601 point-in-time; omit for the current graph.' },
|
|
336
|
+
},
|
|
337
|
+
required: ['naturalKey'],
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Result helpers
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
function textResult(data) {
|
|
345
|
+
return {
|
|
346
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function errorResult(message) {
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
352
|
+
isError: true,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
async function assembleContext(exec, anchorId, opts) {
|
|
356
|
+
const neighbors = await kgNeighbors(exec, anchorId, { depth: opts.depth, at: opts.at });
|
|
357
|
+
const nodeIds = neighbors.nodes.map((n) => n.id);
|
|
358
|
+
const summaryRows = nodeIds.length
|
|
359
|
+
? await exec.query(`SELECT id, summary FROM kg_nodes WHERE id = ANY($1::text[])`, [nodeIds])
|
|
360
|
+
: [];
|
|
361
|
+
const summaryById = new Map(summaryRows.map((r) => [r.id, r.summary]));
|
|
362
|
+
const rankedNodes = neighbors.nodes
|
|
363
|
+
.map((n) => ({
|
|
364
|
+
id: n.id,
|
|
365
|
+
kind: n.kind,
|
|
366
|
+
name: n.name,
|
|
367
|
+
naturalKey: n.naturalKey,
|
|
368
|
+
summary: summaryById.get(n.id) ?? null,
|
|
369
|
+
distance: n.distance,
|
|
370
|
+
}))
|
|
371
|
+
.sort((a, b) => a.distance - b.distance !== 0
|
|
372
|
+
? a.distance - b.distance
|
|
373
|
+
: a.naturalKey < b.naturalKey
|
|
374
|
+
? -1
|
|
375
|
+
: 1);
|
|
376
|
+
const distanceById = new Map(rankedNodes.map((n) => [n.id, n.distance]));
|
|
377
|
+
const edgeIds = neighbors.edges.map((e) => e.id);
|
|
378
|
+
const mentionRows = edgeIds.length
|
|
379
|
+
? await exec.query(`SELECT e.id, count(ee.episode_id)::int AS mentions,
|
|
380
|
+
coalesce(array_agg(ee.episode_id) FILTER (WHERE ee.episode_id IS NOT NULL), '{}') AS episode_ids
|
|
381
|
+
FROM kg_edges e
|
|
382
|
+
LEFT JOIN kg_edge_episodes ee ON ee.edge_id = e.id
|
|
383
|
+
WHERE e.id = ANY($1::text[])
|
|
384
|
+
GROUP BY e.id`, [edgeIds])
|
|
385
|
+
: [];
|
|
386
|
+
const mentionById = new Map(mentionRows.map((r) => [
|
|
387
|
+
r.id,
|
|
388
|
+
{ mentions: Number(r.mentions), episodeIds: r.episode_ids ?? [] },
|
|
389
|
+
]));
|
|
390
|
+
const rankedFacts = neighbors.edges
|
|
391
|
+
.map((e) => {
|
|
392
|
+
const m = mentionById.get(e.id) ?? { mentions: 0, episodeIds: [] };
|
|
393
|
+
const nearDistance = Math.min(distanceById.get(e.sourceId) ?? Number.POSITIVE_INFINITY, distanceById.get(e.targetId) ?? Number.POSITIVE_INFINITY);
|
|
394
|
+
return {
|
|
395
|
+
id: e.id,
|
|
396
|
+
sourceId: e.sourceId,
|
|
397
|
+
targetId: e.targetId,
|
|
398
|
+
relation: e.relation,
|
|
399
|
+
fact: e.fact,
|
|
400
|
+
mentions: m.mentions,
|
|
401
|
+
episodeIds: m.episodeIds,
|
|
402
|
+
rankDistance: nearDistance,
|
|
403
|
+
};
|
|
404
|
+
})
|
|
405
|
+
.sort((a, b) => {
|
|
406
|
+
if (b.mentions - a.mentions !== 0)
|
|
407
|
+
return b.mentions - a.mentions;
|
|
408
|
+
if (a.rankDistance - b.rankDistance !== 0)
|
|
409
|
+
return a.rankDistance - b.rankDistance;
|
|
410
|
+
return a.id < b.id ? -1 : 1;
|
|
411
|
+
})
|
|
412
|
+
.map(({ rankDistance: _rankDistance, ...fact }) => fact);
|
|
413
|
+
const lines = [`# Context for ${anchorId} (depth=${opts.depth})`, '', '## Nodes'];
|
|
414
|
+
for (const n of rankedNodes) {
|
|
415
|
+
const summaryPart = n.summary ? ` — ${n.summary}` : '';
|
|
416
|
+
lines.push(`- [${n.kind}] ${n.naturalKey} (${n.distance} hop)${summaryPart}`);
|
|
417
|
+
}
|
|
418
|
+
lines.push('', '## Facts');
|
|
419
|
+
for (const f of rankedFacts) {
|
|
420
|
+
const provenance = f.episodeIds.length > 0 ? f.episodeIds.join(', ') : 'none';
|
|
421
|
+
lines.push(`- (${f.relation}) ${f.fact} [episodes: ${provenance}]`);
|
|
422
|
+
}
|
|
423
|
+
let charsUsed = 0;
|
|
424
|
+
let truncated = false;
|
|
425
|
+
const packed = [];
|
|
426
|
+
for (const line of lines) {
|
|
427
|
+
const addedLength = line.length + 1; // + newline
|
|
428
|
+
if (charsUsed + addedLength > opts.charBudget) {
|
|
429
|
+
truncated = true;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
packed.push(line);
|
|
433
|
+
charsUsed += addedLength;
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
context: packed.join('\n'),
|
|
437
|
+
anchor: {
|
|
438
|
+
id: anchorId,
|
|
439
|
+
naturalKey: rankedNodes.find((n) => n.id === anchorId)?.naturalKey ?? anchorId,
|
|
440
|
+
},
|
|
441
|
+
nodeCount: rankedNodes.length,
|
|
442
|
+
factCount: rankedFacts.length,
|
|
443
|
+
charBudget: opts.charBudget,
|
|
444
|
+
charsUsed,
|
|
445
|
+
truncated,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Create a fresh `knowledge-graph` MCP Server instance. Safe to call multiple
|
|
450
|
+
* times — each call returns an independent Server with its own request
|
|
451
|
+
* handlers and its own lazily-resolved executor/embedder cache.
|
|
452
|
+
*/
|
|
453
|
+
export function createKnowledgeGraphServer(options) {
|
|
454
|
+
const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
455
|
+
const defaultSiteId = options?.siteId ?? hostname();
|
|
456
|
+
let cachedExecutor = options?.executor;
|
|
457
|
+
async function resolveExecutor() {
|
|
458
|
+
if (cachedExecutor)
|
|
459
|
+
return cachedExecutor;
|
|
460
|
+
const poolModule = await import('@revealui/db/pool');
|
|
461
|
+
cachedExecutor = makePoolExecutor(poolModule.getPool());
|
|
462
|
+
return cachedExecutor;
|
|
463
|
+
}
|
|
464
|
+
// Tri-state: `undefined` = not yet resolved, `null` = resolved-unavailable.
|
|
465
|
+
let cachedEmbedder = options?.embedder;
|
|
466
|
+
async function resolveEmbedder() {
|
|
467
|
+
if (cachedEmbedder !== undefined)
|
|
468
|
+
return cachedEmbedder ?? undefined;
|
|
469
|
+
try {
|
|
470
|
+
// Non-literal specifier: `@revealui/ai` is an optional (Pro) dependency,
|
|
471
|
+
// so this package must not carry a hard type/import edge to it.
|
|
472
|
+
const specifier = '@revealui/ai/embeddings';
|
|
473
|
+
const ai = (await import(specifier));
|
|
474
|
+
cachedEmbedder = async (text) => {
|
|
475
|
+
const result = await ai.generateEmbedding(text);
|
|
476
|
+
return result.vector;
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
cachedEmbedder = null;
|
|
481
|
+
}
|
|
482
|
+
return cachedEmbedder ?? undefined;
|
|
483
|
+
}
|
|
484
|
+
/** Best-effort query embedding: Ollama-down or no embedder both fall back to `undefined` (FTS + BFS only). */
|
|
485
|
+
async function tryEmbed(text) {
|
|
486
|
+
const embedder = await resolveEmbedder();
|
|
487
|
+
if (!embedder)
|
|
488
|
+
return undefined;
|
|
489
|
+
try {
|
|
490
|
+
return await embedder(text);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function hydratePath(exec, path) {
|
|
497
|
+
if (path.length === 0)
|
|
498
|
+
return [];
|
|
499
|
+
const rows = await exec.query(`SELECT id, kind, name, natural_key, repo, summary, attributes, first_seen_at, last_confirmed_at
|
|
500
|
+
FROM kg_nodes WHERE id = ANY($1::text[])`, [path]);
|
|
501
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
502
|
+
return path.flatMap((id) => {
|
|
503
|
+
const row = byId.get(id);
|
|
504
|
+
return row ? [row] : [];
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
508
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
509
|
+
const toolName = request.params.name;
|
|
510
|
+
let exec;
|
|
511
|
+
try {
|
|
512
|
+
exec = await resolveExecutor();
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
return errorResult(`knowledge graph database unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
switch (toolName) {
|
|
519
|
+
case 'kg_search': {
|
|
520
|
+
const parsed = validateToolArgs(KgSearchArgsSchema, request.params.arguments, toolName);
|
|
521
|
+
if (!parsed.ok)
|
|
522
|
+
return parsed.error;
|
|
523
|
+
const { query, anchor, kinds, relations, at, limit, bfsDepth } = parsed.value;
|
|
524
|
+
const queryEmbedding = await tryEmbed(query);
|
|
525
|
+
const result = await kgSearch(exec, {
|
|
526
|
+
query,
|
|
527
|
+
anchor,
|
|
528
|
+
kinds: kinds,
|
|
529
|
+
relations: relations,
|
|
530
|
+
at: at ? new Date(at) : undefined,
|
|
531
|
+
limit,
|
|
532
|
+
bfsDepth,
|
|
533
|
+
queryEmbedding,
|
|
534
|
+
});
|
|
535
|
+
return textResult(result);
|
|
536
|
+
}
|
|
537
|
+
case 'kg_get_node': {
|
|
538
|
+
const parsed = validateToolArgs(KgGetNodeArgsSchema, request.params.arguments, toolName);
|
|
539
|
+
if (!parsed.ok)
|
|
540
|
+
return parsed.error;
|
|
541
|
+
const { naturalKey } = parsed.value;
|
|
542
|
+
const id = await resolveNaturalKey(exec, naturalKey);
|
|
543
|
+
if (!id)
|
|
544
|
+
return errorResult(`no node with natural key: ${naturalKey}`);
|
|
545
|
+
const rows = await exec.query(`SELECT id, kind, name, natural_key, repo, summary, attributes, first_seen_at, last_confirmed_at
|
|
546
|
+
FROM kg_nodes WHERE id = $1`, [id]);
|
|
547
|
+
const node = rows[0];
|
|
548
|
+
if (!node)
|
|
549
|
+
return errorResult(`node ${id} vanished`);
|
|
550
|
+
const facts = await kgAtTime(exec, id, new Date());
|
|
551
|
+
return textResult({ node, facts });
|
|
552
|
+
}
|
|
553
|
+
case 'kg_neighbors': {
|
|
554
|
+
const parsed = validateToolArgs(KgNeighborsArgsSchema, request.params.arguments, toolName);
|
|
555
|
+
if (!parsed.ok)
|
|
556
|
+
return parsed.error;
|
|
557
|
+
const { naturalKey, depth, relations, at } = parsed.value;
|
|
558
|
+
const id = await resolveNaturalKey(exec, naturalKey);
|
|
559
|
+
if (!id)
|
|
560
|
+
return errorResult(`no node with natural key: ${naturalKey}`);
|
|
561
|
+
const result = await kgNeighbors(exec, id, {
|
|
562
|
+
depth,
|
|
563
|
+
relations: relations,
|
|
564
|
+
at: at ? new Date(at) : undefined,
|
|
565
|
+
});
|
|
566
|
+
return textResult(result);
|
|
567
|
+
}
|
|
568
|
+
case 'kg_add_episode': {
|
|
569
|
+
const parsed = validateToolArgs(KgAddEpisodeArgsSchema, request.params.arguments, toolName);
|
|
570
|
+
if (!parsed.ok)
|
|
571
|
+
return parsed.error;
|
|
572
|
+
const v = parsed.value;
|
|
573
|
+
const referenceTime = v.referenceTime ? new Date(v.referenceTime) : new Date();
|
|
574
|
+
const nodes = v.nodes.map((n) => ({
|
|
575
|
+
kind: n.kind,
|
|
576
|
+
name: n.name,
|
|
577
|
+
naturalKey: n.naturalKey,
|
|
578
|
+
repo: n.repo,
|
|
579
|
+
summary: n.summary,
|
|
580
|
+
attributes: n.attributes,
|
|
581
|
+
}));
|
|
582
|
+
const edges = v.edges.map((e) => ({
|
|
583
|
+
source: e.source,
|
|
584
|
+
target: e.target,
|
|
585
|
+
relation: e.relation,
|
|
586
|
+
fact: e.fact,
|
|
587
|
+
repo: e.repo,
|
|
588
|
+
validAt: e.validAt ? new Date(e.validAt) : undefined,
|
|
589
|
+
attributes: e.attributes,
|
|
590
|
+
}));
|
|
591
|
+
const embedder = await resolveEmbedder();
|
|
592
|
+
const result = await ingestEpisode(exec, {
|
|
593
|
+
episode: {
|
|
594
|
+
episodeType: v.episodeType,
|
|
595
|
+
source: v.source,
|
|
596
|
+
siteId: v.siteId ?? defaultSiteId,
|
|
597
|
+
content: v.content,
|
|
598
|
+
contentRef: v.contentRef,
|
|
599
|
+
referenceTime,
|
|
600
|
+
},
|
|
601
|
+
nodes,
|
|
602
|
+
edges,
|
|
603
|
+
}, { embedder, recordOutbox: true });
|
|
604
|
+
return textResult({
|
|
605
|
+
episodeId: result.episodeId,
|
|
606
|
+
nodeCount: result.nodeCount,
|
|
607
|
+
edgeCount: result.edgeCount,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
case 'kg_path': {
|
|
611
|
+
const parsed = validateToolArgs(KgPathArgsSchema, request.params.arguments, toolName);
|
|
612
|
+
if (!parsed.ok)
|
|
613
|
+
return parsed.error;
|
|
614
|
+
const { fromNaturalKey, toNaturalKey, at, maxDepth } = parsed.value;
|
|
615
|
+
const fromId = await resolveNaturalKey(exec, fromNaturalKey);
|
|
616
|
+
if (!fromId)
|
|
617
|
+
return errorResult(`no node with natural key: ${fromNaturalKey}`);
|
|
618
|
+
const toId = await resolveNaturalKey(exec, toNaturalKey);
|
|
619
|
+
if (!toId)
|
|
620
|
+
return errorResult(`no node with natural key: ${toNaturalKey}`);
|
|
621
|
+
const path = await kgPath(exec, fromId, toId, {
|
|
622
|
+
at: at ? new Date(at) : undefined,
|
|
623
|
+
maxDepth,
|
|
624
|
+
});
|
|
625
|
+
if (!path)
|
|
626
|
+
return textResult({ path: null });
|
|
627
|
+
const detail = await hydratePath(exec, path);
|
|
628
|
+
return textResult({ path: detail });
|
|
629
|
+
}
|
|
630
|
+
case 'kg_at_time': {
|
|
631
|
+
const parsed = validateToolArgs(KgAtTimeArgsSchema, request.params.arguments, toolName);
|
|
632
|
+
if (!parsed.ok)
|
|
633
|
+
return parsed.error;
|
|
634
|
+
const { naturalKey, at } = parsed.value;
|
|
635
|
+
const id = await resolveNaturalKey(exec, naturalKey);
|
|
636
|
+
if (!id)
|
|
637
|
+
return errorResult(`no node with natural key: ${naturalKey}`);
|
|
638
|
+
const facts = await kgAtTime(exec, id, new Date(at));
|
|
639
|
+
return textResult({ facts });
|
|
640
|
+
}
|
|
641
|
+
case 'kg_context': {
|
|
642
|
+
const parsed = validateToolArgs(KgContextArgsSchema, request.params.arguments, toolName);
|
|
643
|
+
if (!parsed.ok)
|
|
644
|
+
return parsed.error;
|
|
645
|
+
const { naturalKey, charBudget, depth, at } = parsed.value;
|
|
646
|
+
const id = await resolveNaturalKey(exec, naturalKey);
|
|
647
|
+
if (!id)
|
|
648
|
+
return errorResult(`no node with natural key: ${naturalKey}`);
|
|
649
|
+
const assembled = await assembleContext(exec, id, {
|
|
650
|
+
charBudget: charBudget ?? DEFAULT_CONTEXT_CHAR_BUDGET,
|
|
651
|
+
depth: depth ?? 3,
|
|
652
|
+
at: at ? new Date(at) : undefined,
|
|
653
|
+
});
|
|
654
|
+
return textResult(assembled);
|
|
655
|
+
}
|
|
656
|
+
default:
|
|
657
|
+
return errorResult(`Unknown tool: ${toolName}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
return errorResult(err instanceof Error ? err.message : String(err));
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
return server;
|
|
665
|
+
}
|
|
666
|
+
//# sourceMappingURL=knowledge-graph.js.map
|