@revealui/mcp 0.8.9 → 0.8.10

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.
@@ -32,12 +32,20 @@
32
32
  * reserved for `revkg scan`), Zod-validates `episodeType` plus every node kind
33
33
  * and edge relation against the ontology enums, and accepts no raw SQL or
34
34
  * table-name input of any kind.
35
+ *
36
+ * Product mode (`mode: 'product'`) extracts `createKnowledgeGraphToolset` so
37
+ * stdio and a later hosted composite share one dispatcher. CallTool threads
38
+ * `extra.authInfo` / `extra.sessionId`. Writes stamp actor + scope from
39
+ * `principalProvider` (client `source` / `contentRef.actorDid` are ignored).
40
+ * Compat mode (default) keeps today's unwrapped JSON for in-repo tests.
35
41
  */
42
+ import { createHash } from 'node:crypto';
36
43
  import { hostname } from 'node:os';
37
44
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
38
45
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
39
- import { EDGE_RELATIONS, EPISODE_TYPES, ingestEpisode, kgAtTime, kgNeighbors, kgPath, kgSearch, makePoolExecutor, NODE_KINDS, } from '@revealui/knowledge-graph';
46
+ import { assembleContext, EDGE_RELATIONS, EPISODE_TYPES, ingestEpisode, kgAtTime, kgNeighbors, kgPath, kgSearch, makePoolExecutor, NODE_KINDS, } from '@revealui/knowledge-graph';
40
47
  import { resolveNaturalKey } from '@revealui/knowledge-graph/ingest';
48
+ import { countDeniedMemoryHits, inspectNodeVisibility, MEMORY_MAX_CONTENT_CHARS, MEMORY_MAX_EDGES, MEMORY_MAX_NODES, MEMORY_SCHEMA, shouldNamespaceKeys, tenantNaturalKey, validatePrincipal, } from '@revealui/knowledge-graph/memory';
41
49
  import { z } from 'zod/v4';
42
50
  import { validateToolArgs } from '../../validate-tool-args.js';
43
51
  const SERVER_NAME = 'knowledge-graph';
@@ -47,6 +55,11 @@ const DEFAULT_CONTEXT_CHAR_BUDGET = 16_000;
47
55
  /** Hard caps for kg_add_episode payloads (prevents unbounded Neon writes). */
48
56
  const MAX_EPISODE_CONTENT_CHARS = 64_000;
49
57
  const MAX_EPISODE_ATTRIBUTES_JSON_CHARS = 16_000;
58
+ /** Product-mode write types. Compat still accepts the full ontology enum. */
59
+ const PRODUCT_EPISODE_TYPES = ['agent-fact', 'memory', 'manual'];
60
+ /** Dispatch Promise.race budget. Hung writes must not block the agent. */
61
+ export const DEFAULT_KG_TOOL_TIMEOUT_MS = 4_000;
62
+ const DEFAULT_MUTATING_TOOLS = new Set(['kg_add_episode']);
50
63
  // ---------------------------------------------------------------------------
51
64
  // Tool argument schemas (Zod 4)
52
65
  // ---------------------------------------------------------------------------
@@ -102,22 +115,7 @@ const EdgeInputArgsSchema = z
102
115
  attributes: z.record(z.string(), z.unknown()).optional(),
103
116
  })
104
117
  .strict();
105
- export const KgAddEpisodeArgsSchema = z
106
- .object({
107
- episodeType: z.enum(EPISODE_TYPES),
108
- source: z.string().min(1),
109
- content: z
110
- .string()
111
- .max(MAX_EPISODE_CONTENT_CHARS, `content max ${MAX_EPISODE_CONTENT_CHARS} chars`)
112
- .optional(),
113
- contentRef: z.record(z.string(), z.unknown()).optional(),
114
- referenceTime: z.string().datetime().optional(),
115
- siteId: z.string().min(1).optional(),
116
- nodes: z.array(NodeInputArgsSchema).default([]),
117
- edges: z.array(EdgeInputArgsSchema).default([]),
118
- })
119
- .strict()
120
- .superRefine((val, ctx) => {
118
+ function refineEpisodeJsonCaps(val, ctx) {
121
119
  if (val.contentRef) {
122
120
  const size = JSON.stringify(val.contentRef).length;
123
121
  if (size > MAX_EPISODE_ATTRIBUTES_JSON_CHARS) {
@@ -154,7 +152,41 @@ export const KgAddEpisodeArgsSchema = z
154
152
  });
155
153
  }
156
154
  }
157
- });
155
+ }
156
+ export const KgAddEpisodeArgsSchema = z
157
+ .object({
158
+ episodeType: z.enum(EPISODE_TYPES),
159
+ source: z.string().min(1),
160
+ content: z
161
+ .string()
162
+ .max(MAX_EPISODE_CONTENT_CHARS, `content max ${MAX_EPISODE_CONTENT_CHARS} chars`)
163
+ .optional(),
164
+ contentRef: z.record(z.string(), z.unknown()).optional(),
165
+ referenceTime: z.string().datetime().optional(),
166
+ siteId: z.string().min(1).optional(),
167
+ nodes: z.array(NodeInputArgsSchema).default([]),
168
+ edges: z.array(EdgeInputArgsSchema).default([]),
169
+ })
170
+ .strict()
171
+ .superRefine(refineEpisodeJsonCaps);
172
+ export const KgProductAddEpisodeArgsSchema = z
173
+ .object({
174
+ episodeType: z.enum(PRODUCT_EPISODE_TYPES),
175
+ /** Ignored; stamped from `principalProvider`. */
176
+ source: z.string().min(1).optional(),
177
+ content: z
178
+ .string()
179
+ .max(MEMORY_MAX_CONTENT_CHARS, `content max ${MEMORY_MAX_CONTENT_CHARS} chars`)
180
+ .optional(),
181
+ contentRef: z.record(z.string(), z.unknown()).optional(),
182
+ classification: z.enum(['private', 'workspace']).optional(),
183
+ referenceTime: z.string().datetime().optional(),
184
+ siteId: z.string().min(1).optional(),
185
+ nodes: z.array(NodeInputArgsSchema).max(MEMORY_MAX_NODES).default([]),
186
+ edges: z.array(EdgeInputArgsSchema).max(MEMORY_MAX_EDGES).default([]),
187
+ })
188
+ .strict()
189
+ .superRefine(refineEpisodeJsonCaps);
158
190
  export const KgPathArgsSchema = z
159
191
  .object({
160
192
  fromNaturalKey: z.string().min(1),
@@ -368,6 +400,86 @@ const TOOLS = [
368
400
  },
369
401
  },
370
402
  ];
403
+ function productAddEpisodeTool() {
404
+ return {
405
+ name: 'kg_add_episode',
406
+ description: 'Publish an episode (provenance unit) plus candidate nodes/edges into the ' +
407
+ 'graph. The ONLY write tool. Always additive (never a rescan). Product ' +
408
+ 'mode stamps actor and scope from the session principal — client `source` ' +
409
+ 'and `contentRef.actorDid` are ignored. episodeType is agent-fact, memory, ' +
410
+ 'or manual.',
411
+ inputSchema: {
412
+ type: 'object',
413
+ properties: {
414
+ episodeType: { type: 'string', enum: [...PRODUCT_EPISODE_TYPES] },
415
+ source: {
416
+ type: 'string',
417
+ description: 'Ignored in product mode; stamped from the session principal.',
418
+ },
419
+ content: {
420
+ type: 'string',
421
+ description: `Raw payload or pointer summary (max ${MEMORY_MAX_CONTENT_CHARS} chars).`,
422
+ },
423
+ contentRef: {
424
+ type: 'object',
425
+ description: 'e.g. { repo, path, sha }. actorDid is ignored.',
426
+ },
427
+ classification: {
428
+ type: 'string',
429
+ enum: ['private', 'workspace'],
430
+ description: 'Memory classification. Default workspace. public is not allowed.',
431
+ },
432
+ referenceTime: {
433
+ type: 'string',
434
+ description: 'ISO-8601; when the described state was true. Defaults to now.',
435
+ },
436
+ siteId: {
437
+ type: 'string',
438
+ description: 'Origin machine/replica id. Defaults to hostname().',
439
+ },
440
+ nodes: {
441
+ type: 'array',
442
+ description: `Candidate nodes to upsert (max ${MEMORY_MAX_NODES}).`,
443
+ items: {
444
+ type: 'object',
445
+ properties: {
446
+ kind: { type: 'string', enum: NODE_KIND_ENUM },
447
+ name: { type: 'string' },
448
+ naturalKey: { type: 'string' },
449
+ repo: { type: 'string' },
450
+ summary: { type: 'string' },
451
+ attributes: { type: 'object' },
452
+ },
453
+ required: ['kind', 'name', 'naturalKey'],
454
+ },
455
+ },
456
+ edges: {
457
+ type: 'array',
458
+ description: `Candidate facts between nodes (max ${MEMORY_MAX_EDGES}).`,
459
+ items: {
460
+ type: 'object',
461
+ properties: {
462
+ source: NODE_REF_SCHEMA,
463
+ target: NODE_REF_SCHEMA,
464
+ relation: { type: 'string', enum: EDGE_RELATION_ENUM },
465
+ fact: { type: 'string' },
466
+ repo: { type: 'string' },
467
+ validAt: { type: 'string', description: 'ISO-8601; defaults to referenceTime.' },
468
+ attributes: { type: 'object' },
469
+ },
470
+ required: ['source', 'target', 'relation', 'fact'],
471
+ },
472
+ },
473
+ },
474
+ required: ['episodeType'],
475
+ },
476
+ };
477
+ }
478
+ function toolsForMode(mode) {
479
+ if (mode === 'compat')
480
+ return TOOLS;
481
+ return TOOLS.map((tool) => (tool.name === 'kg_add_episode' ? productAddEpisodeTool() : tool));
482
+ }
371
483
  // ---------------------------------------------------------------------------
372
484
  // Result helpers
373
485
  // ---------------------------------------------------------------------------
@@ -382,106 +494,140 @@ function errorResult(message) {
382
494
  isError: true,
383
495
  };
384
496
  }
385
- async function assembleContext(exec, anchorId, opts) {
386
- const neighbors = await kgNeighbors(exec, anchorId, { depth: opts.depth, at: opts.at });
387
- const nodeIds = neighbors.nodes.map((n) => n.id);
388
- const summaryRows = nodeIds.length
389
- ? await exec.query(`SELECT id, summary FROM kg_nodes WHERE id = ANY($1::text[])`, [nodeIds])
390
- : [];
391
- const summaryById = new Map(summaryRows.map((r) => [r.id, r.summary]));
392
- const rankedNodes = neighbors.nodes
393
- .map((n) => ({
394
- id: n.id,
395
- kind: n.kind,
396
- name: n.name,
397
- naturalKey: n.naturalKey,
398
- summary: summaryById.get(n.id) ?? null,
399
- distance: n.distance,
400
- }))
401
- .sort((a, b) => a.distance - b.distance !== 0
402
- ? a.distance - b.distance
403
- : a.naturalKey < b.naturalKey
404
- ? -1
405
- : 1);
406
- const distanceById = new Map(rankedNodes.map((n) => [n.id, n.distance]));
407
- const edgeIds = neighbors.edges.map((e) => e.id);
408
- const mentionRows = edgeIds.length
409
- ? await exec.query(`SELECT e.id, count(ee.episode_id)::int AS mentions,
410
- coalesce(array_agg(ee.episode_id) FILTER (WHERE ee.episode_id IS NOT NULL), '{}') AS episode_ids
411
- FROM kg_edges e
412
- LEFT JOIN kg_edge_episodes ee ON ee.edge_id = e.id
413
- WHERE e.id = ANY($1::text[])
414
- GROUP BY e.id`, [edgeIds])
415
- : [];
416
- const mentionById = new Map(mentionRows.map((r) => [
417
- r.id,
418
- { mentions: Number(r.mentions), episodeIds: r.episode_ids ?? [] },
419
- ]));
420
- const rankedFacts = neighbors.edges
421
- .map((e) => {
422
- const m = mentionById.get(e.id) ?? { mentions: 0, episodeIds: [] };
423
- const nearDistance = Math.min(distanceById.get(e.sourceId) ?? Number.POSITIVE_INFINITY, distanceById.get(e.targetId) ?? Number.POSITIVE_INFINITY);
424
- return {
425
- id: e.id,
426
- sourceId: e.sourceId,
427
- targetId: e.targetId,
428
- relation: e.relation,
429
- fact: e.fact,
430
- mentions: m.mentions,
431
- episodeIds: m.episodeIds,
432
- rankDistance: nearDistance,
433
- };
434
- })
435
- .sort((a, b) => {
436
- if (b.mentions - a.mentions !== 0)
437
- return b.mentions - a.mentions;
438
- if (a.rankDistance - b.rankDistance !== 0)
439
- return a.rankDistance - b.rankDistance;
440
- return a.id < b.id ? -1 : 1;
441
- })
442
- .map(({ rankDistance: _rankDistance, ...fact }) => fact);
443
- const lines = [`# Context for ${anchorId} (depth=${opts.depth})`, '', '## Nodes'];
444
- for (const n of rankedNodes) {
445
- const summaryPart = n.summary ? ` — ${n.summary}` : '';
446
- lines.push(`- [${n.kind}] ${n.naturalKey} (${n.distance} hop)${summaryPart}`);
447
- }
448
- lines.push('', '## Facts');
449
- for (const f of rankedFacts) {
450
- const provenance = f.episodeIds.length > 0 ? f.episodeIds.join(', ') : 'none';
451
- lines.push(`- (${f.relation}) ${f.fact} [episodes: ${provenance}]`);
452
- }
453
- let charsUsed = 0;
454
- let truncated = false;
455
- const packed = [];
456
- for (const line of lines) {
457
- const addedLength = line.length + 1; // + newline
458
- if (charsUsed + addedLength > opts.charBudget) {
459
- truncated = true;
460
- break;
461
- }
462
- packed.push(line);
463
- charsUsed += addedLength;
497
+ // ---------------------------------------------------------------------------
498
+ // Product-mode helpers
499
+ // ---------------------------------------------------------------------------
500
+ const AUDIT_SCALAR_ALLOWLIST = [
501
+ 'naturalKey',
502
+ 'fromNaturalKey',
503
+ 'toNaturalKey',
504
+ 'episodeType',
505
+ 'classification',
506
+ 'query',
507
+ ];
508
+ function extraToContext(extra) {
509
+ const record = extra;
510
+ return { authInfo: record?.authInfo, sessionId: record?.sessionId };
511
+ }
512
+ function canonicalJson(value) {
513
+ if (value === null || typeof value !== 'object')
514
+ return JSON.stringify(value) ?? 'null';
515
+ if (Array.isArray(value))
516
+ return `[${value.map(canonicalJson).join(',')}]`;
517
+ const entries = Object.entries(value).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
518
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
519
+ }
520
+ function digestArgs(args) {
521
+ return createHash('sha256').update(canonicalJson(args)).digest('hex');
522
+ }
523
+ function pickAuditScalars(args) {
524
+ const out = {};
525
+ if (!args || typeof args !== 'object')
526
+ return out;
527
+ const record = args;
528
+ for (const key of AUDIT_SCALAR_ALLOWLIST) {
529
+ const raw = record[key];
530
+ if (typeof raw === 'string' && raw.length > 0)
531
+ out[key] = raw;
464
532
  }
533
+ return out;
534
+ }
535
+ function namespaceKey(principal, kind, key) {
536
+ if (kind === 'agent')
537
+ return key;
538
+ if (!shouldNamespaceKeys(principal))
539
+ return key;
540
+ return tenantNaturalKey(principal.tenantId, key);
541
+ }
542
+ function inboundKey(principal, key) {
543
+ if (!(principal && shouldNamespaceKeys(principal)))
544
+ return key;
545
+ return tenantNaturalKey(principal.tenantId, key);
546
+ }
547
+ function stampContentRef(principal, clientRef, classification) {
548
+ const rest = { ...(clientRef ?? {}) };
549
+ const repo = typeof rest.repo === 'string' ? rest.repo : undefined;
465
550
  return {
466
- context: packed.join('\n'),
467
- anchor: {
468
- id: anchorId,
469
- naturalKey: rankedNodes.find((n) => n.id === anchorId)?.naturalKey ?? anchorId,
551
+ ...rest,
552
+ schema: MEMORY_SCHEMA,
553
+ actorDid: principal.did,
554
+ harness: principal.harness,
555
+ scope: {
556
+ tenantId: principal.tenantId,
557
+ workspaceId: principal.workspaceId,
558
+ repo,
559
+ classification,
470
560
  },
471
- nodeCount: rankedNodes.length,
472
- factCount: rankedFacts.length,
473
- charBudget: opts.charBudget,
474
- charsUsed,
475
- truncated,
476
561
  };
477
562
  }
563
+ function productUnavailable(reason, message) {
564
+ return {
565
+ content: [
566
+ {
567
+ type: 'text',
568
+ text: JSON.stringify({
569
+ status: 'unavailable',
570
+ available: false,
571
+ reason,
572
+ message,
573
+ }),
574
+ },
575
+ ],
576
+ isError: true,
577
+ };
578
+ }
579
+ function wrapOk(mode, data, principal, deniedCount = 0) {
580
+ if (mode === 'compat')
581
+ return textResult(data);
582
+ const enforcement = principal?.trustBoundary === 'hosted' ? 'enforced' : 'deferred';
583
+ return textResult({
584
+ status: 'ok',
585
+ available: true,
586
+ enforcement,
587
+ deniedCount,
588
+ data,
589
+ });
590
+ }
591
+ function wrapDenied(principal, deniedCount) {
592
+ return textResult({
593
+ status: 'denied',
594
+ available: true,
595
+ reason: 'scope-denied',
596
+ deniedCount,
597
+ scope: {
598
+ tenantId: principal.tenantId,
599
+ workspaceId: principal.workspaceId,
600
+ classification: 'workspace',
601
+ },
602
+ message: 'memory-schema hits existed but none were in scope',
603
+ });
604
+ }
605
+ async function raceTimeout(work, timeoutMs, onTimeout) {
606
+ if (timeoutMs <= 0)
607
+ return work;
608
+ let timer;
609
+ const timeout = new Promise((resolve) => {
610
+ timer = setTimeout(() => resolve(onTimeout()), timeoutMs);
611
+ });
612
+ try {
613
+ return await Promise.race([work, timeout]);
614
+ }
615
+ finally {
616
+ if (timer)
617
+ clearTimeout(timer);
618
+ }
619
+ }
478
620
  /**
479
- * Create a fresh `knowledge-graph` MCP Server instance. Safe to call multiple
480
- * times each call returns an independent Server with its own request
481
- * handlers and its own lazily-resolved executor/embedder cache.
621
+ * Shared dispatcher for stdio and a later hosted composite. CallTool must pass
622
+ * `extra` so `principalProvider` can read `authInfo` / `sessionId`.
482
623
  */
483
- export function createKnowledgeGraphServer(options) {
484
- const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
624
+ export function createKnowledgeGraphToolset(options) {
625
+ const mode = options?.mode ?? 'compat';
626
+ const trustBoundary = options?.trustBoundary ?? 'studio-local';
627
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_KG_TOOL_TIMEOUT_MS;
628
+ const mutatingTools = options?.mutatingTools ?? DEFAULT_MUTATING_TOOLS;
629
+ const tools = toolsForMode(mode);
630
+ const names = new Set(tools.map((tool) => tool.name));
485
631
  const defaultSiteId = options?.siteId ?? hostname();
486
632
  let cachedExecutor = options?.executor;
487
633
  async function resolveExecutor() {
@@ -534,92 +680,218 @@ export function createKnowledgeGraphServer(options) {
534
680
  return row ? [row] : [];
535
681
  });
536
682
  }
537
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
538
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
683
+ function dbUnavailable(err) {
684
+ const detail = err instanceof Error ? err.message : String(err);
685
+ const message = `knowledge graph database unavailable: ${detail}`;
686
+ return mode === 'product'
687
+ ? productUnavailable('kg-database-unavailable', message)
688
+ : errorResult(message);
689
+ }
690
+ async function handleTool(request, extra) {
691
+ const startTime = Date.now();
539
692
  const toolName = request.params.name;
693
+ const rawArgs = request.params.arguments;
694
+ const ctx = extraToContext(extra);
695
+ const mutating = mutatingTools.has(toolName);
696
+ async function writeReceipt(outcome, opts) {
697
+ if (!options?.auditSink)
698
+ return true;
699
+ const record = {
700
+ outcome,
701
+ tool: toolName,
702
+ argsDigest: digestArgs(rawArgs ?? null),
703
+ scalars: pickAuditScalars(rawArgs),
704
+ durationMs: Date.now() - startTime,
705
+ reason: opts?.reason,
706
+ context: ctx,
707
+ };
708
+ try {
709
+ await options.auditSink(record);
710
+ return true;
711
+ }
712
+ catch {
713
+ return false;
714
+ }
715
+ }
716
+ if (!names.has(toolName)) {
717
+ await writeReceipt('denied');
718
+ return errorResult(`Unknown tool: ${toolName}`);
719
+ }
720
+ let principal = null;
721
+ if (mode === 'product') {
722
+ try {
723
+ principal = (await options?.principalProvider?.(ctx)) ?? null;
724
+ }
725
+ catch {
726
+ principal = null;
727
+ }
728
+ const missing = validatePrincipal(principal);
729
+ if (missing || !principal) {
730
+ await writeReceipt('denied', { reason: 'principal-missing' });
731
+ return productUnavailable('principal-missing', missing ?? 'principal is required');
732
+ }
733
+ if (principal.trustBoundary !== trustBoundary) {
734
+ await writeReceipt('denied', { reason: 'principal-missing' });
735
+ return productUnavailable('principal-missing', 'principal trustBoundary does not match the server');
736
+ }
737
+ }
738
+ if (mutating) {
739
+ const recorded = await writeReceipt('invoked');
740
+ if (!recorded) {
741
+ return errorResult('audit log unavailable; mutating tool refused');
742
+ }
743
+ }
540
744
  let exec;
541
745
  try {
542
746
  exec = await resolveExecutor();
543
747
  }
544
748
  catch (err) {
545
- return errorResult(`knowledge graph database unavailable: ${err instanceof Error ? err.message : String(err)}`);
749
+ if (!mutating)
750
+ await writeReceipt('failed');
751
+ return dbUnavailable(err);
546
752
  }
547
753
  try {
548
- switch (toolName) {
549
- case 'kg_search': {
550
- const parsed = validateToolArgs(KgSearchArgsSchema, request.params.arguments, toolName);
551
- if (!parsed.ok)
552
- return parsed.error;
553
- const { query, anchor, kinds, relations, at, limit, bfsDepth } = parsed.value;
554
- let anchorId = anchor;
555
- if (anchor) {
556
- // Accept natural key or node id: try resolveNaturalKey first; fall back to raw id.
557
- const resolved = await resolveNaturalKey(exec, anchor);
558
- if (resolved) {
559
- anchorId = resolved;
560
- }
754
+ const result = await dispatchNamedTool(exec, toolName, request, principal);
755
+ if (!mutating) {
756
+ await writeReceipt(result.isError ? 'failed' : 'invoked');
757
+ }
758
+ return result;
759
+ }
760
+ catch (err) {
761
+ if (!mutating)
762
+ await writeReceipt('failed');
763
+ return mode === 'product'
764
+ ? dbUnavailable(err)
765
+ : errorResult(err instanceof Error ? err.message : String(err));
766
+ }
767
+ }
768
+ async function dispatchNamedTool(exec, toolName, request, principal) {
769
+ switch (toolName) {
770
+ case 'kg_search': {
771
+ const parsed = validateToolArgs(KgSearchArgsSchema, request.params.arguments, toolName);
772
+ if (!parsed.ok)
773
+ return parsed.error;
774
+ const { query, anchor, kinds, relations, at, limit, bfsDepth } = parsed.value;
775
+ let anchorId = anchor ? inboundKey(principal, anchor) : anchor;
776
+ if (anchorId) {
777
+ const resolved = await resolveNaturalKey(exec, anchorId);
778
+ if (resolved) {
779
+ anchorId = resolved;
561
780
  }
562
- const queryEmbedding = await tryEmbed(query);
563
- const result = await kgSearch(exec, {
564
- query,
565
- anchor: anchorId,
566
- kinds: kinds,
567
- relations: relations,
568
- at: at ? new Date(at) : undefined,
569
- limit,
570
- bfsDepth,
571
- queryEmbedding,
572
- });
573
- return textResult(result);
574
781
  }
575
- case 'kg_get_node': {
576
- const parsed = validateToolArgs(KgGetNodeArgsSchema, request.params.arguments, toolName);
577
- if (!parsed.ok)
578
- return parsed.error;
579
- const { naturalKey } = parsed.value;
580
- const id = await resolveNaturalKey(exec, naturalKey);
581
- if (!id)
782
+ const queryEmbedding = await tryEmbed(query);
783
+ const scoped = mode === 'product' ? (principal ?? undefined) : undefined;
784
+ const result = await kgSearch(exec, {
785
+ query,
786
+ anchor: anchorId,
787
+ kinds: kinds,
788
+ relations: relations,
789
+ at: at ? new Date(at) : undefined,
790
+ limit,
791
+ bfsDepth,
792
+ queryEmbedding,
793
+ principal: scoped,
794
+ });
795
+ const deniedCount = scoped ? await countDeniedMemoryHits(exec, query, scoped) : 0;
796
+ if (mode === 'product' &&
797
+ principal &&
798
+ result.nodes.length === 0 &&
799
+ result.facts.length === 0 &&
800
+ deniedCount > 0) {
801
+ return wrapDenied(principal, deniedCount);
802
+ }
803
+ return wrapOk(mode, result, principal, deniedCount);
804
+ }
805
+ case 'kg_get_node': {
806
+ const parsed = validateToolArgs(KgGetNodeArgsSchema, request.params.arguments, toolName);
807
+ if (!parsed.ok)
808
+ return parsed.error;
809
+ const naturalKey = inboundKey(principal, parsed.value.naturalKey);
810
+ const id = await resolveNaturalKey(exec, naturalKey);
811
+ if (!id)
812
+ return errorResult(`no node with natural key: ${naturalKey}`);
813
+ const scoped = mode === 'product' ? (principal ?? undefined) : undefined;
814
+ if (scoped) {
815
+ const visibility = await inspectNodeVisibility(exec, id, scoped);
816
+ if (visibility === 'missing') {
582
817
  return errorResult(`no node with natural key: ${naturalKey}`);
818
+ }
583
819
  const rows = await exec.query(`SELECT id, kind, name, natural_key, repo, summary, attributes, first_seen_at, last_confirmed_at
584
820
  FROM kg_nodes WHERE id = $1`, [id]);
585
821
  const node = rows[0];
586
822
  if (!node)
587
823
  return errorResult(`node ${id} vanished`);
588
- const facts = await kgAtTime(exec, id, new Date());
589
- return textResult({ node, facts });
824
+ const facts = await kgAtTime(exec, id, new Date(), { principal: scoped });
825
+ if (visibility === 'shell') {
826
+ return wrapOk(mode, {
827
+ node: {
828
+ id: node.id,
829
+ kind: node.kind,
830
+ name: node.name,
831
+ natural_key: node.natural_key,
832
+ },
833
+ facts,
834
+ }, principal);
835
+ }
836
+ return wrapOk(mode, { node, facts }, principal);
590
837
  }
591
- case 'kg_neighbors': {
592
- const parsed = validateToolArgs(KgNeighborsArgsSchema, request.params.arguments, toolName);
593
- if (!parsed.ok)
594
- return parsed.error;
595
- const { naturalKey, depth, relations, at } = parsed.value;
596
- const id = await resolveNaturalKey(exec, naturalKey);
597
- if (!id)
598
- return errorResult(`no node with natural key: ${naturalKey}`);
599
- const result = await kgNeighbors(exec, id, {
600
- depth,
601
- relations: relations,
602
- at: at ? new Date(at) : undefined,
603
- });
604
- return textResult(result);
838
+ const rows = await exec.query(`SELECT id, kind, name, natural_key, repo, summary, attributes, first_seen_at, last_confirmed_at
839
+ FROM kg_nodes WHERE id = $1`, [id]);
840
+ const node = rows[0];
841
+ if (!node)
842
+ return errorResult(`node ${id} vanished`);
843
+ const facts = await kgAtTime(exec, id, new Date());
844
+ return wrapOk(mode, { node, facts }, principal);
845
+ }
846
+ case 'kg_neighbors': {
847
+ const parsed = validateToolArgs(KgNeighborsArgsSchema, request.params.arguments, toolName);
848
+ if (!parsed.ok)
849
+ return parsed.error;
850
+ const { depth, relations, at } = parsed.value;
851
+ const naturalKey = inboundKey(principal, parsed.value.naturalKey);
852
+ const id = await resolveNaturalKey(exec, naturalKey);
853
+ if (!id)
854
+ return errorResult(`no node with natural key: ${naturalKey}`);
855
+ const scoped = mode === 'product' ? (principal ?? undefined) : undefined;
856
+ if (scoped && (await inspectNodeVisibility(exec, id, scoped)) === 'missing') {
857
+ return errorResult(`no node with natural key: ${naturalKey}`);
605
858
  }
606
- case 'kg_add_episode': {
607
- const parsed = validateToolArgs(KgAddEpisodeArgsSchema, request.params.arguments, toolName);
859
+ const result = await kgNeighbors(exec, id, {
860
+ depth,
861
+ relations: relations,
862
+ at: at ? new Date(at) : undefined,
863
+ principal: scoped,
864
+ });
865
+ return wrapOk(mode, result, principal);
866
+ }
867
+ case 'kg_add_episode': {
868
+ if (mode === 'product') {
869
+ if (!principal) {
870
+ return productUnavailable('principal-missing', 'principal is required');
871
+ }
872
+ const parsed = validateToolArgs(KgProductAddEpisodeArgsSchema, request.params.arguments, toolName);
608
873
  if (!parsed.ok)
609
874
  return parsed.error;
610
875
  const v = parsed.value;
876
+ const classification = v.classification ?? 'workspace';
611
877
  const referenceTime = v.referenceTime ? new Date(v.referenceTime) : new Date();
612
878
  const nodes = v.nodes.map((n) => ({
613
879
  kind: n.kind,
614
880
  name: n.name,
615
- naturalKey: n.naturalKey,
881
+ naturalKey: namespaceKey(principal, n.kind, n.naturalKey),
616
882
  repo: n.repo,
617
883
  summary: n.summary,
618
884
  attributes: n.attributes,
619
885
  }));
620
886
  const edges = v.edges.map((e) => ({
621
- source: e.source,
622
- target: e.target,
887
+ source: {
888
+ kind: e.source.kind,
889
+ naturalKey: namespaceKey(principal, e.source.kind, e.source.naturalKey),
890
+ },
891
+ target: {
892
+ kind: e.target.kind,
893
+ naturalKey: namespaceKey(principal, e.target.kind, e.target.naturalKey),
894
+ },
623
895
  relation: e.relation,
624
896
  fact: e.fact,
625
897
  repo: e.repo,
@@ -630,75 +902,155 @@ export function createKnowledgeGraphServer(options) {
630
902
  const result = await ingestEpisode(exec, {
631
903
  episode: {
632
904
  episodeType: v.episodeType,
633
- source: v.source,
905
+ source: `agent:${principal.did}`,
634
906
  siteId: v.siteId ?? defaultSiteId,
635
907
  content: v.content,
636
- contentRef: v.contentRef,
908
+ contentRef: stampContentRef(principal, v.contentRef, classification),
637
909
  referenceTime,
638
910
  },
639
911
  nodes,
640
912
  edges,
641
- }, { embedder, recordOutbox: true });
642
- return textResult({
913
+ }, { embedder, recordOutbox: true, invalidateContradictions: false });
914
+ return wrapOk(mode, {
643
915
  episodeId: result.episodeId,
644
916
  nodeCount: result.nodeCount,
645
917
  edgeCount: result.edgeCount,
646
- });
918
+ }, principal);
647
919
  }
648
- case 'kg_path': {
649
- const parsed = validateToolArgs(KgPathArgsSchema, request.params.arguments, toolName);
650
- if (!parsed.ok)
651
- return parsed.error;
652
- const { fromNaturalKey, toNaturalKey, at, maxDepth } = parsed.value;
653
- const fromId = await resolveNaturalKey(exec, fromNaturalKey);
654
- if (!fromId)
920
+ const parsed = validateToolArgs(KgAddEpisodeArgsSchema, request.params.arguments, toolName);
921
+ if (!parsed.ok)
922
+ return parsed.error;
923
+ const v = parsed.value;
924
+ const referenceTime = v.referenceTime ? new Date(v.referenceTime) : new Date();
925
+ const nodes = v.nodes.map((n) => ({
926
+ kind: n.kind,
927
+ name: n.name,
928
+ naturalKey: n.naturalKey,
929
+ repo: n.repo,
930
+ summary: n.summary,
931
+ attributes: n.attributes,
932
+ }));
933
+ const edges = v.edges.map((e) => ({
934
+ source: e.source,
935
+ target: e.target,
936
+ relation: e.relation,
937
+ fact: e.fact,
938
+ repo: e.repo,
939
+ validAt: e.validAt ? new Date(e.validAt) : undefined,
940
+ attributes: e.attributes,
941
+ }));
942
+ const embedder = await resolveEmbedder();
943
+ const result = await ingestEpisode(exec, {
944
+ episode: {
945
+ episodeType: v.episodeType,
946
+ source: v.source,
947
+ siteId: v.siteId ?? defaultSiteId,
948
+ content: v.content,
949
+ contentRef: v.contentRef,
950
+ referenceTime,
951
+ },
952
+ nodes,
953
+ edges,
954
+ }, { embedder, recordOutbox: true });
955
+ return wrapOk(mode, {
956
+ episodeId: result.episodeId,
957
+ nodeCount: result.nodeCount,
958
+ edgeCount: result.edgeCount,
959
+ }, principal);
960
+ }
961
+ case 'kg_path': {
962
+ const parsed = validateToolArgs(KgPathArgsSchema, request.params.arguments, toolName);
963
+ if (!parsed.ok)
964
+ return parsed.error;
965
+ const { at, maxDepth } = parsed.value;
966
+ const fromNaturalKey = inboundKey(principal, parsed.value.fromNaturalKey);
967
+ const toNaturalKey = inboundKey(principal, parsed.value.toNaturalKey);
968
+ const fromId = await resolveNaturalKey(exec, fromNaturalKey);
969
+ if (!fromId)
970
+ return errorResult(`no node with natural key: ${fromNaturalKey}`);
971
+ const toId = await resolveNaturalKey(exec, toNaturalKey);
972
+ if (!toId)
973
+ return errorResult(`no node with natural key: ${toNaturalKey}`);
974
+ const scoped = mode === 'product' ? (principal ?? undefined) : undefined;
975
+ if (scoped) {
976
+ if ((await inspectNodeVisibility(exec, fromId, scoped)) === 'missing') {
655
977
  return errorResult(`no node with natural key: ${fromNaturalKey}`);
656
- const toId = await resolveNaturalKey(exec, toNaturalKey);
657
- if (!toId)
978
+ }
979
+ if ((await inspectNodeVisibility(exec, toId, scoped)) === 'missing') {
658
980
  return errorResult(`no node with natural key: ${toNaturalKey}`);
659
- const path = await kgPath(exec, fromId, toId, {
660
- at: at ? new Date(at) : undefined,
661
- maxDepth,
662
- });
663
- if (!path)
664
- return textResult({ path: null });
665
- const detail = await hydratePath(exec, path);
666
- return textResult({ path: detail });
981
+ }
667
982
  }
668
- case 'kg_at_time': {
669
- const parsed = validateToolArgs(KgAtTimeArgsSchema, request.params.arguments, toolName);
670
- if (!parsed.ok)
671
- return parsed.error;
672
- const { naturalKey, at } = parsed.value;
673
- const id = await resolveNaturalKey(exec, naturalKey);
674
- if (!id)
675
- return errorResult(`no node with natural key: ${naturalKey}`);
676
- const facts = await kgAtTime(exec, id, new Date(at));
677
- return textResult({ facts });
983
+ const path = await kgPath(exec, fromId, toId, {
984
+ at: at ? new Date(at) : undefined,
985
+ maxDepth,
986
+ principal: scoped,
987
+ });
988
+ if (!path)
989
+ return wrapOk(mode, { path: null }, principal);
990
+ const detail = await hydratePath(exec, path);
991
+ return wrapOk(mode, { path: detail }, principal);
992
+ }
993
+ case 'kg_at_time': {
994
+ const parsed = validateToolArgs(KgAtTimeArgsSchema, request.params.arguments, toolName);
995
+ if (!parsed.ok)
996
+ return parsed.error;
997
+ const { at } = parsed.value;
998
+ const naturalKey = inboundKey(principal, parsed.value.naturalKey);
999
+ const id = await resolveNaturalKey(exec, naturalKey);
1000
+ if (!id)
1001
+ return errorResult(`no node with natural key: ${naturalKey}`);
1002
+ const scoped = mode === 'product' ? (principal ?? undefined) : undefined;
1003
+ if (scoped && (await inspectNodeVisibility(exec, id, scoped)) === 'missing') {
1004
+ return errorResult(`no node with natural key: ${naturalKey}`);
678
1005
  }
679
- case 'kg_context': {
680
- const parsed = validateToolArgs(KgContextArgsSchema, request.params.arguments, toolName);
681
- if (!parsed.ok)
682
- return parsed.error;
683
- const { naturalKey, charBudget, depth, at } = parsed.value;
684
- const id = await resolveNaturalKey(exec, naturalKey);
685
- if (!id)
686
- return errorResult(`no node with natural key: ${naturalKey}`);
687
- const assembled = await assembleContext(exec, id, {
688
- charBudget: charBudget ?? DEFAULT_CONTEXT_CHAR_BUDGET,
689
- depth: depth ?? 3,
690
- at: at ? new Date(at) : undefined,
691
- });
692
- return textResult(assembled);
1006
+ const facts = await kgAtTime(exec, id, new Date(at), { principal: scoped });
1007
+ return wrapOk(mode, { facts }, principal);
1008
+ }
1009
+ case 'kg_context': {
1010
+ const parsed = validateToolArgs(KgContextArgsSchema, request.params.arguments, toolName);
1011
+ if (!parsed.ok)
1012
+ return parsed.error;
1013
+ const { charBudget, depth, at } = parsed.value;
1014
+ const naturalKey = inboundKey(principal, parsed.value.naturalKey);
1015
+ const id = await resolveNaturalKey(exec, naturalKey);
1016
+ if (!id)
1017
+ return errorResult(`no node with natural key: ${naturalKey}`);
1018
+ const scoped = mode === 'product' ? (principal ?? undefined) : undefined;
1019
+ if (scoped && (await inspectNodeVisibility(exec, id, scoped)) === 'missing') {
1020
+ return errorResult(`no node with natural key: ${naturalKey}`);
693
1021
  }
694
- default:
695
- return errorResult(`Unknown tool: ${toolName}`);
1022
+ const assembled = await assembleContext(exec, id, {
1023
+ charBudget: charBudget ?? DEFAULT_CONTEXT_CHAR_BUDGET,
1024
+ depth: depth ?? 3,
1025
+ at: at ? new Date(at) : undefined,
1026
+ principal: scoped,
1027
+ });
1028
+ return wrapOk(mode, assembled, principal);
696
1029
  }
1030
+ default:
1031
+ return errorResult(`Unknown tool: ${toolName}`);
697
1032
  }
698
- catch (err) {
699
- return errorResult(err instanceof Error ? err.message : String(err));
700
- }
701
- });
1033
+ }
1034
+ return {
1035
+ tools,
1036
+ names,
1037
+ async dispatch(request, extra) {
1038
+ return raceTimeout(handleTool(request, extra), timeoutMs, () => mode === 'product'
1039
+ ? productUnavailable('timeout', 'knowledge graph tool timed out')
1040
+ : errorResult('knowledge graph tool timed out'));
1041
+ },
1042
+ };
1043
+ }
1044
+ /**
1045
+ * Create a fresh `knowledge-graph` MCP Server instance. Safe to call multiple
1046
+ * times — each call returns an independent Server with its own request
1047
+ * handlers and its own lazily-resolved executor/embedder cache.
1048
+ */
1049
+ export function createKnowledgeGraphServer(options) {
1050
+ const toolset = createKnowledgeGraphToolset(options);
1051
+ const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
1052
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolset.tools }));
1053
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => toolset.dispatch(request, extra));
702
1054
  return server;
703
1055
  }
704
1056
  //# sourceMappingURL=knowledge-graph.js.map