Skip to content

Commit a2631cc

Browse files
2233adminclaude
andcommitted
feat(mcp): Phase 6 — holon/causal/provenance MCP tools
Adds 7 new read-only MCP operations backed by context-core.json: holon.get — look up a holon by ID holon.list — list with kind/status filter holon.search — case-insensitive substring search on title+summary holon.tasks — list knowledge-task holons with stats causal.chain — BFS traversal from a node (max_depth, min_confidence) causal.neighbors — depth-1 inbound/outbound/both edges provenance.get — content hash, wikilinks, causal edges with target titles Implementation: - src/holons/loader.ts: lazy ContextCoreLoader (in-memory cache, invalidate()) - src/holons/holon.ts, causal.ts, provenance.ts: factory functions - core/types.ts: extend namespace union with holon|causal|provenance - core/operations.ts: wire via makeAllOperations; CONTEXT_CORE_PATH env override - 28 new unit tests (209/209 total); docs/mcp-tools-reference.md regenerated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 67835a4 commit a2631cc

8 files changed

Lines changed: 768 additions & 3 deletions

File tree

docs/mcp-tools-reference.md

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> Auto-generated from `mcp-server/src/core/operations.ts`.
44
> Run `npm run generate-tools-doc` to regenerate. Do not edit by hand.
55
6-
Total: **50** operations across **7** namespaces.
6+
Total: **57** operations across **10** namespaces.
77

88
## `vault.*` (29)
99

@@ -595,3 +595,85 @@ Send a vault-relative file into an external LightRAG server. Markdown/text files
595595
- `path` (string, required) — Vault-relative source file path
596596
- `mode` (string, optional, default: `"auto"`, enum: `auto` | `text` | `upload`) — Ingest mode. auto sends .md/.txt as text and other files as upload.
597597
- `dryRun` (boolean, optional, default: `true`) — Return the planned LightRAG request without sending it (default: true)
598+
599+
## `holon.*` (4)
600+
601+
### `holon.get`
602+
603+
Get a compiled holon by ID
604+
605+
**Mutating:** no
606+
607+
**Parameters:**
608+
609+
- `id` (string, required) — Holon ID (e.g. concepts/attention)
610+
611+
### `holon.list`
612+
613+
List compiled holons with optional kind/status filter
614+
615+
**Mutating:** no
616+
617+
**Parameters:**
618+
619+
- `kind` (string, optional) — Filter by kind (research, decision, note, knowledge-task, …)
620+
- `status` (string, optional) — Filter by status (active, frozen, …)
621+
- `limit` (number, optional, default: `50`) — Max results (default: 50)
622+
623+
### `holon.search`
624+
625+
Search holons by title or summary (case-insensitive substring)
626+
627+
**Mutating:** no
628+
629+
**Parameters:**
630+
631+
- `query` (string, required) — Search string
632+
- `limit` (number, optional, default: `20`) — Max results (default: 20)
633+
634+
### `holon.tasks`
635+
636+
List knowledge-task holons with task stats
637+
638+
**Mutating:** no
639+
640+
**Parameters:**
641+
642+
- `status` (string, optional) — Filter by status (active, frozen, …)
643+
644+
## `causal.*` (2)
645+
646+
### `causal.chain`
647+
648+
BFS-traverse the causal graph outward from a starting holon
649+
650+
**Mutating:** no
651+
652+
**Parameters:**
653+
654+
- `id` (string, required) — Starting holon ID
655+
- `max_depth` (number, optional, default: `3`) — Max traversal depth (default: 3)
656+
- `min_confidence` (number, optional, default: `0`) — Min edge confidence 0–1 (default: 0)
657+
658+
### `causal.neighbors`
659+
660+
Get direct causal neighbors (depth 1) of a holon
661+
662+
**Mutating:** no
663+
664+
**Parameters:**
665+
666+
- `id` (string, required) — Holon ID
667+
- `direction` (string, optional, default: `"outbound"`, enum: `outbound` | `inbound` | `both`) — outbound | inbound | both (default: outbound)
668+
669+
## `provenance.*` (1)
670+
671+
### `provenance.get`
672+
673+
Get provenance for a holon: content hash, wikilinks, and annotated causal edges
674+
675+
**Mutating:** no
676+
677+
**Parameters:**
678+
679+
- `id` (string, required) — Holon ID

mcp-server/src/core/operations.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import type { VaultBrainAdapter } from '../adapters/vaultbrain/index.js';
1313
import type { RAGAnythingAdapter } from '../adapters/raganything.js';
1414
import type { LightRAGAdapter } from '../adapters/lightrag.js';
1515
import type { CompileTrigger } from '../compile-trigger.js';
16+
import { ContextCoreLoader } from '../holons/loader.js';
17+
import { makeHolonOps } from '../holons/holon.js';
18+
import { makeCausalOps } from '../holons/causal.js';
19+
import { makeProvenanceOps } from '../holons/provenance.js';
1620

1721
const execAsync = promisify(execFile);
1822
const PROTECTED_DIRS = new Set(['.obsidian', '.trash', '.git', 'node_modules']);
@@ -526,10 +530,15 @@ export interface AllOperationsDeps {
526530
compilerPath: string;
527531
vaultPath: string;
528532
configPath?: string;
533+
contextCorePath?: string;
529534
}
530535

531536
export function makeAllOperations(deps: AllOperationsDeps): Operation[] {
532537
const { compileTrigger, registry, defaultWeights, python, compilerPath, vaultPath, configPath } = deps;
538+
const ccPath = deps.contextCorePath
539+
?? process.env['CONTEXT_CORE_PATH']
540+
?? join(dirname(compilerPath), 'context-core.json');
541+
const contextCoreLoader = new ContextCoreLoader(ccPath);
533542

534543
const compileOps: Operation[] = [
535544
{
@@ -993,7 +1002,12 @@ export function makeAllOperations(deps: AllOperationsDeps): Operation[] {
9931002
},
9941003
];
9951004

996-
return [...operations, ...compileOps, ...queryOps, ...multimodalOps, ...lightRagOps, ...agentOps];
1005+
const holonOps = [
1006+
...makeHolonOps(contextCoreLoader),
1007+
...makeCausalOps(contextCoreLoader),
1008+
...makeProvenanceOps(contextCoreLoader),
1009+
];
1010+
return [...operations, ...compileOps, ...queryOps, ...multimodalOps, ...lightRagOps, ...agentOps, ...holonOps];
9971011
}
9981012

9991013
function normalizeVaultRelPath(path: string): string {

mcp-server/src/core/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface ParamDef {
3030

3131
export interface Operation {
3232
name: string;
33-
namespace: 'vault' | 'compile' | 'query' | 'agent' | 'recipe' | 'multimodal' | 'lightrag';
33+
namespace: 'vault' | 'compile' | 'query' | 'agent' | 'recipe' | 'multimodal' | 'lightrag' | 'holon' | 'causal' | 'provenance';
3434
description: string;
3535
params: Record<string, ParamDef>;
3636
handler: (ctx: OperationContext, params: Record<string, unknown>) => Promise<unknown>;

mcp-server/src/holons/causal.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Operation } from '../core/types.js';
2+
import type { ContextCoreLoader, Holon } from './loader.js';
3+
4+
interface ChainNode {
5+
id: string;
6+
title: string;
7+
kind: string;
8+
depth: number;
9+
via_edge?: { relation: string; confidence: number };
10+
}
11+
12+
function bfsChain(
13+
loader: ContextCoreLoader,
14+
startId: string,
15+
maxDepth: number,
16+
minConf: number,
17+
): ChainNode[] {
18+
const start = loader.byId(startId);
19+
if (!start) return [];
20+
const visited = new Set<string>();
21+
const queue: Array<{ holon: Holon; depth: number; via?: { relation: string; confidence: number } }> = [
22+
{ holon: start, depth: 0 },
23+
];
24+
const result: ChainNode[] = [];
25+
while (queue.length > 0) {
26+
const item = queue.shift()!;
27+
if (visited.has(item.holon.id)) continue;
28+
visited.add(item.holon.id);
29+
result.push({
30+
id: item.holon.id,
31+
title: item.holon.title,
32+
kind: item.holon.kind,
33+
depth: item.depth,
34+
...(item.via ? { via_edge: item.via } : {}),
35+
});
36+
if (item.depth < maxDepth) {
37+
for (const edge of item.holon.causal_edges) {
38+
if (edge.confidence < minConf) continue;
39+
const target = loader.byId(edge.target_id);
40+
if (target && !visited.has(target.id)) {
41+
queue.push({
42+
holon: target,
43+
depth: item.depth + 1,
44+
via: { relation: edge.relation, confidence: edge.confidence },
45+
});
46+
}
47+
}
48+
}
49+
}
50+
return result;
51+
}
52+
53+
function notReady(path: string) {
54+
return { error: 'context-core.json not found', hint: `Run: python -m compiler <vault_path> -o ${path}` };
55+
}
56+
57+
export function makeCausalOps(loader: ContextCoreLoader): Operation[] {
58+
return [
59+
{
60+
name: 'causal.chain',
61+
namespace: 'causal' as Operation['namespace'],
62+
description: 'BFS-traverse the causal graph outward from a starting holon',
63+
mutating: false,
64+
params: {
65+
id: { type: 'string', required: true, description: 'Starting holon ID' },
66+
max_depth: { type: 'number', required: false, description: 'Max traversal depth (default: 3)', default: 3 },
67+
min_confidence: { type: 'number', required: false, description: 'Min edge confidence 0–1 (default: 0)', default: 0 },
68+
},
69+
handler: async (_ctx, params) => {
70+
if (!loader.get()) return notReady(loader.path);
71+
const id = params.id as string;
72+
const maxDepth = (params.max_depth as number | undefined) ?? 3;
73+
const minConf = (params.min_confidence as number | undefined) ?? 0;
74+
if (!loader.byId(id)) return { error: `Holon not found: ${id}` };
75+
const nodes = bfsChain(loader, id, maxDepth, minConf);
76+
return { start_id: id, node_count: nodes.length, nodes };
77+
},
78+
},
79+
80+
{
81+
name: 'causal.neighbors',
82+
namespace: 'causal' as Operation['namespace'],
83+
description: 'Get direct causal neighbors (depth 1) of a holon',
84+
mutating: false,
85+
params: {
86+
id: { type: 'string', required: true, description: 'Holon ID' },
87+
direction: {
88+
type: 'string', required: false,
89+
description: 'outbound | inbound | both (default: outbound)',
90+
enum: ['outbound', 'inbound', 'both'],
91+
default: 'outbound',
92+
},
93+
},
94+
handler: async (_ctx, params) => {
95+
const cc = loader.get();
96+
if (!cc) return notReady(loader.path);
97+
const id = params.id as string;
98+
const dir = (params.direction as string | undefined) ?? 'outbound';
99+
const h = loader.byId(id);
100+
if (!h) return { error: `Holon not found: ${id}` };
101+
102+
const outbound = (dir === 'outbound' || dir === 'both')
103+
? h.causal_edges.map(e => ({
104+
target_id: e.target_id,
105+
target_title: loader.byId(e.target_id)?.title ?? e.target_id,
106+
relation: e.relation,
107+
confidence: e.confidence,
108+
}))
109+
: [];
110+
111+
const inbound = (dir === 'inbound' || dir === 'both')
112+
? cc.holons.flatMap(src =>
113+
src.causal_edges
114+
.filter(e => e.target_id === id)
115+
.map(e => ({
116+
source_id: src.id,
117+
source_title: src.title,
118+
relation: e.relation,
119+
confidence: e.confidence,
120+
}))
121+
)
122+
: [];
123+
124+
return { id, outbound, inbound };
125+
},
126+
},
127+
];
128+
}

mcp-server/src/holons/holon.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { Operation } from '../core/types.js';
2+
import type { ContextCoreLoader, Holon } from './loader.js';
3+
4+
function notReady(path: string) {
5+
return { error: 'context-core.json not found', hint: `Run: python -m compiler <vault_path> -o ${path}` };
6+
}
7+
8+
export function makeHolonOps(loader: ContextCoreLoader): Operation[] {
9+
return [
10+
{
11+
name: 'holon.get',
12+
namespace: 'holon' as Operation['namespace'],
13+
description: 'Get a compiled holon by ID',
14+
mutating: false,
15+
params: {
16+
id: { type: 'string', required: true, description: 'Holon ID (e.g. concepts/attention)' },
17+
},
18+
handler: async (_ctx, params) => {
19+
const cc = loader.get();
20+
if (!cc) return notReady(loader.path);
21+
const h = loader.byId(params.id as string);
22+
if (!h) return { error: `Holon not found: ${params.id as string}` };
23+
return h;
24+
},
25+
},
26+
27+
{
28+
name: 'holon.list',
29+
namespace: 'holon' as Operation['namespace'],
30+
description: 'List compiled holons with optional kind/status filter',
31+
mutating: false,
32+
params: {
33+
kind: { type: 'string', required: false, description: 'Filter by kind (research, decision, note, knowledge-task, …)' },
34+
status: { type: 'string', required: false, description: 'Filter by status (active, frozen, …)' },
35+
limit: { type: 'number', required: false, description: 'Max results (default: 50)', default: 50 },
36+
},
37+
handler: async (_ctx, params) => {
38+
const cc = loader.get();
39+
if (!cc) return notReady(loader.path);
40+
const kind = params.kind as string | undefined;
41+
const status = params.status as string | undefined;
42+
const limit = (params.limit as number | undefined) ?? 50;
43+
let holons: Holon[] = cc.holons;
44+
if (kind) holons = holons.filter(h => h.kind === kind);
45+
if (status) holons = holons.filter(h => h.status === status);
46+
return { holons: holons.slice(0, limit), total: holons.length, exported_at: cc.exported_at };
47+
},
48+
},
49+
50+
{
51+
name: 'holon.search',
52+
namespace: 'holon' as Operation['namespace'],
53+
description: 'Search holons by title or summary (case-insensitive substring)',
54+
mutating: false,
55+
params: {
56+
query: { type: 'string', required: true, description: 'Search string' },
57+
limit: { type: 'number', required: false, description: 'Max results (default: 20)', default: 20 },
58+
},
59+
handler: async (_ctx, params) => {
60+
const cc = loader.get();
61+
if (!cc) return notReady(loader.path);
62+
const q = (params.query as string).toLowerCase();
63+
const limit = (params.limit as number | undefined) ?? 20;
64+
const hits = cc.holons.filter(h =>
65+
h.title.toLowerCase().includes(q) || h.summary.toLowerCase().includes(q)
66+
);
67+
return { holons: hits.slice(0, limit), total: hits.length, query: params.query };
68+
},
69+
},
70+
71+
{
72+
name: 'holon.tasks',
73+
namespace: 'holon' as Operation['namespace'],
74+
description: 'List knowledge-task holons with task stats',
75+
mutating: false,
76+
params: {
77+
status: { type: 'string', required: false, description: 'Filter by status (active, frozen, …)' },
78+
},
79+
handler: async (_ctx, params) => {
80+
const cc = loader.get();
81+
if (!cc) return notReady(loader.path);
82+
const status = params.status as string | undefined;
83+
const allTasks = cc.holons.filter(h => h.kind === 'knowledge-task');
84+
const byStatus: Record<string, number> = {};
85+
for (const t of allTasks) byStatus[t.status] = (byStatus[t.status] ?? 0) + 1;
86+
let tasks = allTasks.sort((a, b) => a.id.localeCompare(b.id));
87+
if (status) tasks = tasks.filter(h => h.status === status);
88+
return {
89+
tasks,
90+
stats: { total: allTasks.length, by_status: byStatus },
91+
};
92+
},
93+
},
94+
];
95+
}

0 commit comments

Comments
 (0)