daftari-router 0.1.0

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/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # daftari-router
2
+
3
+ Multi-vault MCP router — fan out across N Daftari vaults from one MCP connection.
4
+
5
+ ## Quick start
6
+
7
+ The router is part of the [daftari repo](https://github.com/mavaali/daftari) and not yet published independently to npm. To run from source:
8
+
9
+ ```bash
10
+ git clone https://github.com/mavaali/daftari.git
11
+ cd daftari/packages/router
12
+ npm install
13
+ npm run build
14
+ node dist/cli.js --config vaults.yaml
15
+ ```
16
+
17
+ (A `daftari-router` npm package and `npx` quick-start will land in a follow-up release.)
18
+
19
+ The router speaks MCP over stdio. Point any MCP client (Claude Code, Claude Desktop, custom LangGraph agent) at the `daftari-router` binary instead of `daftari`, and it transparently spans multiple vaults.
20
+
21
+ ## Example vaults.yaml
22
+
23
+ ```yaml
24
+ router:
25
+ transport: stdio
26
+
27
+ vaults:
28
+ devops:
29
+ path: ~/vaults/devops
30
+ user: agent
31
+ role: admin
32
+ description: "Runbooks, incident playbooks, infra architecture"
33
+ product:
34
+ path: ~/vaults/product
35
+ user: agent
36
+ role: writer
37
+ description: "Product specs, roadmap decisions, customer research"
38
+ intel:
39
+ path: ~/vaults/intel
40
+ user: agent
41
+ role: reader
42
+ description: "Competitive analysis, market positioning, pricing"
43
+
44
+ defaults:
45
+ search_limit: 10
46
+ ```
47
+
48
+ ## Tool semantics
49
+
50
+ | Tool | No `vault` arg | With `vault` arg |
51
+ |---|---|---|
52
+ | `vault_read` | error: requires vault | reads from named vault |
53
+ | `vault_index` | aggregates all vaults | filters to that vault |
54
+ | `vault_status` | aggregate health + per-vault breakdown | health of that vault |
55
+ | `vault_search` | fan out + merge by score | search only that vault |
56
+ | `vault_search_related` | error: path is vault-specific | finds related docs in that vault |
57
+ | `vault_reindex` | reindexes all | reindexes that vault |
58
+ | `vault_write` / `vault_append` / `vault_promote` / `vault_deprecate` | error: requires vault | writes to that vault |
59
+ | `vault_tension_log` | error: requires vault | logs in that vault |
60
+ | `vault_lint` | lints all + per-vault breakdown | lints that vault |
61
+ | `vault_provenance` | error: path is vault-specific | provenance in that vault |
62
+ | `vault_themes` | merges themes from all vaults | themes from that vault |
63
+
64
+ For tools that take a `path` argument, you can use either explicit `vault: "name"` arg or vault-prefixed paths like `devops:runbooks/k8s.md` — the router strips the prefix before forwarding to the child.
65
+
66
+ Tools with other path-shaped arguments (`vault_provenance` uses `filePath`, `vault_tension_log` uses `sourceA`/`sourceB`) require the explicit `vault:` arg.
67
+
68
+ ## Architecture
69
+
70
+ ```
71
+ ┌──────────────────┐
72
+ │ MCP Client │
73
+ │ (Claude, agent) │
74
+ └────────┬─────────┘
75
+ │ stdio JSON-RPC
76
+
77
+ ┌──────────────────┐
78
+ │ daftari-router │
79
+ │ ─ catalog │
80
+ │ ─ dispatch │
81
+ │ ─ fan-out merge │
82
+ └──┬───────┬───────┘
83
+ │ │ stdio JSON-RPC
84
+ ┌─────────┘ └──────────┐
85
+ ▼ ▼
86
+ ┌─────────────┐ ┌─────────────┐
87
+ │ daftari │ │ daftari │
88
+ │ (devops) │ ... │ (intel) │
89
+ └─────────────┘ └─────────────┘
90
+ ```
91
+
92
+ ## Requirements
93
+
94
+ - Node.js >= 20
95
+ - `daftari` >= 1.10.0 installed and on `PATH` (or pass `--daftari-bin <path>`)
96
+ - Each vault directory initialized with `daftari --init`
97
+
98
+ ## Caveats
99
+
100
+ - **Tool catalog is seeded from the first child's `tools/list`.** If vaults run different daftari versions exposing different tools, the router only exposes the first vault's surface. Mismatches are logged to stderr at startup. Run matched versions across vaults.
101
+ - **Vault names cannot contain `:`** — reserved as the path-prefix separator (e.g., `devops:runbooks/k8s.md`).
102
+ - **Document paths starting with `vault-name:`** collide with the prefix parser. Avoid colons in the first path segment.
103
+ - **v1 search merge assumes homogeneous embedding models** across vaults. Mixing models produces an incoherent ranking. A future Phase 2 normalization pass will fix this.
104
+ - **Write tools never fan out.** You must specify which vault to write to, either via `vault: "name"` arg or a vault-prefixed path. Ambiguous writes are silently rejected as a safety measure.
105
+ - **No auth layer** — the router inherits each child vault's RBAC. Run on trusted infrastructure.
106
+
107
+ ## Running the integration tests
108
+
109
+ The integration test boots real `daftari` subprocesses against fixture vaults at `test/fixtures/vault-{a,b}`. By default it spawns the daftari CLI at `<repo-root>/dist/cli.js`. Override with the `DAFTARI_BIN` env var to test against an installed version:
110
+
111
+ ```bash
112
+ DAFTARI_BIN=/usr/local/bin/daftari npm test
113
+ ```
114
+
115
+ ## What's in Phase 1
116
+
117
+ - Spawn N children, fan-out search, per-vault dispatch
118
+ - 14-tool surface (read/search/write/curation/themes)
119
+ - Vault-prefixed path convention
120
+ - Graceful shutdown on SIGINT/SIGTERM
121
+ - Per-child handshake timeout
122
+
123
+ ## What's NOT in Phase 1
124
+
125
+ - Cross-vault lint (`crossVaultTension`, `crossVaultBrokenRef`, `crossVaultStaleChain`)
126
+ - `vault_discover` tool for write-target suggestion
127
+ - HTTP/SSE transport
128
+ - Auth (API key, OAuth)
129
+ - Score normalization across heterogeneous embedding models
130
+ - Child crash auto-restart
131
+
132
+ See `docs/superpowers/plans/2026-05-29-multivault-router-phase1.md` (in the repo root) for the implementation plan.
@@ -0,0 +1,9 @@
1
+ import { type ChildClient } from "./client.js";
2
+ import type { RouterConfig, VaultConfig } from "./config.js";
3
+ export type ChildPool = {
4
+ get: (name: string) => ChildClient | null;
5
+ all: () => ChildClient[];
6
+ close: () => Promise<void>;
7
+ };
8
+ export declare function createPool(children: ChildClient[]): ChildPool;
9
+ export declare function startPool(config: RouterConfig, daftariBin?: string, spawner?: (vault: VaultConfig, bin: string) => Promise<ChildClient>): Promise<ChildPool>;
@@ -0,0 +1,25 @@
1
+ import { startChild } from "./client.js";
2
+ export function createPool(children) {
3
+ const byName = new Map(children.map((c) => [c.name, c]));
4
+ return {
5
+ get: (n) => byName.get(n) ?? null,
6
+ all: () => [...children],
7
+ close: async () => {
8
+ await Promise.allSettled(children.map((c) => c.close()));
9
+ },
10
+ };
11
+ }
12
+ export async function startPool(config, daftariBin = "daftari", spawner = startChild) {
13
+ const started = [];
14
+ try {
15
+ for (const v of config.vaults) {
16
+ process.stderr.write(`router: starting vault '${v.name}' at ${v.path}\n`);
17
+ started.push(await spawner(v, daftariBin));
18
+ }
19
+ return createPool(started);
20
+ }
21
+ catch (e) {
22
+ await Promise.allSettled(started.map((c) => c.close()));
23
+ throw e;
24
+ }
25
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./index.js";
3
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
4
+ process.stderr.write(`fatal: ${err?.stack ?? String(err)}\n`);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,22 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import type { VaultConfig } from "./config.js";
3
+ export type CallToolResult = {
4
+ content: unknown[];
5
+ isError?: boolean;
6
+ };
7
+ export type ToolDescriptor = {
8
+ name: string;
9
+ description?: string;
10
+ inputSchema: unknown;
11
+ };
12
+ export type ChildClient = {
13
+ name: string;
14
+ callTool: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
15
+ listTools: () => Promise<{
16
+ tools: ToolDescriptor[];
17
+ }>;
18
+ close: () => Promise<void>;
19
+ };
20
+ export declare function wrapChildClient(name: string, mcp: Client): ChildClient;
21
+ export declare function startChild(vault: VaultConfig, daftariBin?: string, startTimeoutMs?: number): Promise<ChildClient>;
22
+ export declare function withTimeout<T>(p: Promise<T>, ms: number, onTimeout: () => unknown, msg: string): Promise<T>;
package/dist/client.js ADDED
@@ -0,0 +1,51 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ export function wrapChildClient(name, mcp) {
4
+ return {
5
+ name,
6
+ callTool: (toolName, args) => mcp.callTool({ name: toolName, arguments: args }),
7
+ listTools: () => mcp.listTools(),
8
+ close: async () => {
9
+ await mcp.close();
10
+ },
11
+ };
12
+ }
13
+ export async function startChild(vault, daftariBin = "daftari", startTimeoutMs = 10_000) {
14
+ // args[] is passed to cross-spawn with shell:false — no shell injection risk.
15
+ const transport = new StdioClientTransport({
16
+ command: daftariBin,
17
+ args: ["--vault", vault.path, "--user", vault.user, "--role", vault.role],
18
+ });
19
+ const mcp = new Client({ name: "daftari-router", version: "0.1.0" }, { capabilities: {} });
20
+ try {
21
+ await withTimeout(mcp.connect(transport), startTimeoutMs, () => mcp.close().catch(() => { }), `vault '${vault.name}' did not complete MCP handshake in ${startTimeoutMs}ms`);
22
+ }
23
+ catch (err) {
24
+ await mcp.close().catch(() => { });
25
+ throw err;
26
+ }
27
+ return wrapChildClient(vault.name, mcp);
28
+ }
29
+ export async function withTimeout(p, ms, onTimeout, msg) {
30
+ let t;
31
+ try {
32
+ return await Promise.race([
33
+ p,
34
+ new Promise((_, reject) => {
35
+ t = setTimeout(() => {
36
+ try {
37
+ onTimeout();
38
+ }
39
+ catch {
40
+ // ignore cleanup errors
41
+ }
42
+ reject(new Error(msg));
43
+ }, ms);
44
+ }),
45
+ ]);
46
+ }
47
+ finally {
48
+ if (t)
49
+ clearTimeout(t);
50
+ }
51
+ }
@@ -0,0 +1,16 @@
1
+ export type Transport = "stdio";
2
+ export type VaultConfig = {
3
+ name: string;
4
+ path: string;
5
+ user: string;
6
+ role: string;
7
+ description: string;
8
+ };
9
+ export type RouterConfig = {
10
+ transport: Transport;
11
+ vaults: VaultConfig[];
12
+ defaults: {
13
+ searchLimit: number;
14
+ };
15
+ };
16
+ export declare function parseConfig(yamlText: string): RouterConfig;
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { homedir } from "node:os";
2
+ import YAML from "yaml";
3
+ function expandHome(p) {
4
+ if (p === "~")
5
+ return homedir();
6
+ if (p.startsWith("~/"))
7
+ return `${homedir()}/${p.slice(2)}`;
8
+ return p;
9
+ }
10
+ export function parseConfig(yamlText) {
11
+ const raw = YAML.parse(yamlText);
12
+ if (!raw || typeof raw !== "object")
13
+ throw new Error("config: empty or non-object root");
14
+ const router = (raw.router ?? {});
15
+ const transport = (router.transport ?? "stdio");
16
+ if (transport !== "stdio")
17
+ throw new Error(`config: unsupported transport: ${transport}`);
18
+ const vaultsObj = (raw.vaults ?? {});
19
+ const names = Object.keys(vaultsObj);
20
+ if (names.length === 0)
21
+ throw new Error("config: at least one vault required");
22
+ const vaults = names.map((name) => {
23
+ if (name.includes(":"))
24
+ throw new Error(`config: vault name must not contain colon: ${name}`);
25
+ const v = vaultsObj[name];
26
+ for (const f of ["path", "user", "role", "description"]) {
27
+ if (typeof v?.[f] !== "string" || !v[f])
28
+ throw new Error(`config: vault ${name} missing ${f}`);
29
+ }
30
+ return {
31
+ name,
32
+ path: expandHome(v.path),
33
+ user: v.user,
34
+ role: v.role,
35
+ description: v.description,
36
+ };
37
+ });
38
+ const defaults = (raw.defaults ?? {});
39
+ return {
40
+ transport,
41
+ vaults,
42
+ defaults: {
43
+ searchLimit: typeof defaults.search_limit === "number" ? defaults.search_limit : 10,
44
+ },
45
+ };
46
+ }
@@ -0,0 +1 @@
1
+ export declare function main(argv: string[]): Promise<number>;
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { startPool } from "./children.js";
4
+ import { parseConfig } from "./config.js";
5
+ import { createRouterServer } from "./server.js";
6
+ function flag(argv, name) {
7
+ const i = argv.indexOf(`--${name}`);
8
+ if (i !== -1 && argv[i + 1])
9
+ return argv[i + 1];
10
+ const prefix = `--${name}=`;
11
+ const eq = argv.find((a) => a.startsWith(prefix));
12
+ return eq ? eq.slice(prefix.length) : null;
13
+ }
14
+ export async function main(argv) {
15
+ const configPath = flag(argv, "config");
16
+ if (!configPath) {
17
+ process.stderr.write("usage: daftari-router --config <vaults.yaml>\n");
18
+ return 2;
19
+ }
20
+ // Register shutdown handlers EARLY so SIGINT during slow startup still
21
+ // cleans up any children that have spawned.
22
+ let pool = null;
23
+ let shuttingDown = false;
24
+ const shutdown = async (sig) => {
25
+ if (shuttingDown)
26
+ return; // C2 fix — interlock against double-signal race
27
+ shuttingDown = true;
28
+ process.stderr.write(`router: ${sig} — closing children\n`);
29
+ let exitCode = 0;
30
+ try {
31
+ if (pool)
32
+ await pool.close();
33
+ }
34
+ catch (e) {
35
+ const reason = e instanceof Error ? e.message : String(e);
36
+ process.stderr.write(`router: error during shutdown: ${reason}\n`);
37
+ exitCode = 1;
38
+ }
39
+ finally {
40
+ process.exit(exitCode);
41
+ }
42
+ };
43
+ process.once("SIGINT", () => void shutdown("SIGINT"));
44
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
45
+ let cfg;
46
+ try {
47
+ cfg = parseConfig(readFileSync(configPath, "utf-8"));
48
+ }
49
+ catch (e) {
50
+ const reason = e instanceof Error ? e.message : String(e);
51
+ process.stderr.write(`router: failed to load config '${configPath}': ${reason}\n`);
52
+ return 1;
53
+ }
54
+ const daftariBin = flag(argv, "daftari-bin") ?? "daftari";
55
+ try {
56
+ pool = await startPool(cfg, daftariBin);
57
+ }
58
+ catch (e) {
59
+ const reason = e instanceof Error ? e.message : String(e);
60
+ process.stderr.write(`router: failed to start pool: ${reason}\n`);
61
+ return 1;
62
+ }
63
+ // Seed catalog from the first child's tools/list. We assume all children
64
+ // expose the same tool surface (documented in README).
65
+ const first = pool.all()[0];
66
+ let toolsResp;
67
+ try {
68
+ toolsResp = await first.listTools();
69
+ }
70
+ catch (e) {
71
+ await pool.close();
72
+ const reason = e instanceof Error ? e.message : String(e);
73
+ process.stderr.write(`router: failed to list tools from first child: ${reason}\n`);
74
+ return 1;
75
+ }
76
+ // Heterogeneity check: warn if any child exposes a different tool surface.
77
+ // The catalog is seeded from the first child only; calls to tools the other
78
+ // children don't have will fail at dispatch time.
79
+ const firstNames = new Set(toolsResp.tools.map((t) => t.name));
80
+ for (const child of pool.all().slice(1)) {
81
+ try {
82
+ const otherTools = await child.listTools();
83
+ const otherNames = new Set(otherTools.tools.map((t) => t.name));
84
+ const missing = [...firstNames].filter((n) => !otherNames.has(n));
85
+ const extra = [...otherNames].filter((n) => !firstNames.has(n));
86
+ if (missing.length > 0) {
87
+ process.stderr.write(`router: warning: vault '${child.name}' is missing tools: ${missing.join(", ")}\n`);
88
+ }
89
+ if (extra.length > 0) {
90
+ process.stderr.write(`router: warning: vault '${child.name}' exposes extra tools (not routed): ${extra.join(", ")}\n`);
91
+ }
92
+ }
93
+ catch (e) {
94
+ const reason = e instanceof Error ? e.message : String(e);
95
+ process.stderr.write(`router: warning: could not list tools for vault '${child.name}': ${reason}\n`);
96
+ }
97
+ }
98
+ // The SDK's tool shape includes inputSchema typed as `unknown` after our T4
99
+ // widening. Catalog accepts ChildToolDescriptor (structural). The cast is
100
+ // safe here because daftari children always return the structural form.
101
+ const { mcp } = createRouterServer(pool, toolsResp.tools);
102
+ // Unlike daftari (which opens stdio before indexing to answer initialize
103
+ // promptly), the router has no usable mode before children are ready —
104
+ // listTools needs them, callTool needs them. Open the transport last.
105
+ const transport = new StdioServerTransport();
106
+ await mcp.connect(transport);
107
+ process.stderr.write(`router: ready (${cfg.vaults.length} vaults)\n`);
108
+ // Hold the process open — signal handler will exit.
109
+ return new Promise(() => { });
110
+ }
package/dist/path.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type VaultPath = {
2
+ vault: string | null;
3
+ path: string;
4
+ };
5
+ export declare function parseVaultPath(raw: string): VaultPath;
6
+ export declare function formatVaultPath(vault: string, path: string): string;
package/dist/path.js ADDED
@@ -0,0 +1,9 @@
1
+ export function parseVaultPath(raw) {
2
+ const idx = raw.indexOf(":");
3
+ if (idx === -1)
4
+ return { vault: null, path: raw };
5
+ return { vault: raw.slice(0, idx), path: raw.slice(idx + 1) };
6
+ }
7
+ export function formatVaultPath(vault, path) {
8
+ return `${vault}:${path}`;
9
+ }
@@ -0,0 +1,13 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import type { ChildPool } from "./children.js";
3
+ import { type CatalogTool, type ChildToolDescriptor } from "./tools/catalog.js";
4
+ export type Result = {
5
+ content: unknown[];
6
+ isError?: boolean;
7
+ };
8
+ export type RouterServer = {
9
+ mcp: Server;
10
+ dispatch: (name: string, args: Record<string, unknown>) => Promise<Result>;
11
+ catalog: CatalogTool[];
12
+ };
13
+ export declare function createRouterServer(pool: ChildPool, childTools: ChildToolDescriptor[]): RouterServer;
package/dist/server.js ADDED
@@ -0,0 +1,145 @@
1
+ // server.ts — Router MCP server wiring.
2
+ //
3
+ // Glues the catalog (T6), single-vault dispatch (T7), and fan-out + mergers
4
+ // (T8) behind a single MCP Server instance.
5
+ //
6
+ // Dual dispatch model
7
+ // -------------------
8
+ // `dispatch(name, args)` is exported alongside the SDK `Server`. It is used by:
9
+ // 1. The `CallToolRequestSchema` handler (transport path) — wraps dispatch
10
+ // in try/catch, so any thrown error becomes an MCP error result and
11
+ // cannot take the stdio connection down.
12
+ // 2. Tests + in-process embedders (direct path) — invoke `dispatch` without
13
+ // the SDK transport.
14
+ //
15
+ // Error semantics in both paths:
16
+ // - Tool-level failures (unknown tool, no merger, child returned isError,
17
+ // fanout child threw) return `{ isError: true, content: [...] }`.
18
+ // - The transport path additionally swallows unexpected throws and converts
19
+ // them to an MCP error. The direct path lets those throws propagate —
20
+ // they indicate router bugs (e.g. JSON.stringify failing on circular
21
+ // refs in a merger output), and silently hiding them from tests would
22
+ // mask regressions. Direct-path callers that want transport semantics
23
+ // can wrap their dispatch call in the same try/catch.
24
+ //
25
+ // Scale / timeout model
26
+ // ---------------------
27
+ // Fan-out hits every child in `pool.all()` concurrently. The pool is sized at
28
+ // startup from `vaults.yaml` (typically a handful of vaults — Phase 1 is not
29
+ // optimized for hundreds). Per-child timeouts live inside `startChild` (T4);
30
+ // the router does not impose an additional aggregate deadline. If a vault
31
+ // child hangs longer than its own timeout, fanoutCall surfaces it as a
32
+ // per-vault error row rather than failing the whole call.
33
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
34
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
35
+ import { parseVaultPath } from "./path.js";
36
+ import { buildCatalog } from "./tools/catalog.js";
37
+ import { fanoutCall } from "./tools/fanout.js";
38
+ import { mergeIndex, mergeLint, mergeReindex, mergeSearch, mergeStatus, mergeThemes, } from "./tools/merge.js";
39
+ import { routeToVault } from "./tools/route.js";
40
+ // Per-tool merger for fanout dispatches. Tools not present here cannot fan
41
+ // out; a fanout call for a tool without a merger is a router bug and surfaces
42
+ // as an MCP error rather than silently swallowing results.
43
+ //
44
+ // The `as never` casts trade strict per-merger input typing for a flat
45
+ // registry — each merger's row shape differs (SearchHit vs IndexEntry vs ...)
46
+ // but they share the VaultResult<T> envelope. The dispatch site only sees
47
+ // `unknown[]` rows from fanoutCall, so a uniform Record signature is the
48
+ // honest type here.
49
+ const MERGERS = {
50
+ vault_search: mergeSearch,
51
+ vault_index: mergeIndex,
52
+ vault_status: mergeStatus,
53
+ vault_lint: mergeLint,
54
+ vault_themes: mergeThemes,
55
+ vault_reindex: mergeReindex,
56
+ };
57
+ const err = (text) => ({
58
+ isError: true,
59
+ content: [{ type: "text", text }],
60
+ });
61
+ // Safely encode a merger result. JSON.stringify can throw on circular refs or
62
+ // BigInt; that indicates a router bug, not a user-input bug. We surface it as
63
+ // an MCP error in both dispatch paths so the failure mode is consistent and
64
+ // observable (rather than silently propagating in the direct path only).
65
+ function encodeMerged(merged, tool) {
66
+ try {
67
+ return { content: [{ type: "text", text: JSON.stringify(merged, null, 2) }] };
68
+ }
69
+ catch (e) {
70
+ const reason = e instanceof Error ? e.message : String(e);
71
+ return err(`router: failed to encode '${tool}' merger result: ${reason}`);
72
+ }
73
+ }
74
+ export function createRouterServer(pool, childTools) {
75
+ const catalog = buildCatalog(childTools);
76
+ const byName = new Map(catalog.map((t) => [t.name, t]));
77
+ const mcp = new Server({ name: "daftari-router", version: "0.1.0" }, { capabilities: { tools: {} } });
78
+ async function dispatch(name, args) {
79
+ // Args shape guard. The MCP CallTool schema permits a missing/null
80
+ // arguments object; the CallToolRequestSchema handler already coalesces
81
+ // to `{}`, but direct callers might not. Reject anything non-object up
82
+ // front so downstream code can assume Record<string, unknown>.
83
+ if (args === null || typeof args !== "object" || Array.isArray(args)) {
84
+ return err(`router: '${name}' arguments must be an object`);
85
+ }
86
+ const tool = byName.get(name);
87
+ if (!tool)
88
+ return err(`unknown tool: ${name}`);
89
+ if (tool.routing === "require-vault") {
90
+ return routeToVault(pool, name, args);
91
+ }
92
+ // routing === "fanout"
93
+ // For parity with routeToVault, a vault can be specified via either an
94
+ // explicit args.vault OR a vault-prefixed args.path (e.g. "a:x.md"). Both
95
+ // signals route to the single-vault path; only an absent/empty vault and
96
+ // no prefix falls through to fanout.
97
+ if (hasExplicitVault(args)) {
98
+ return routeToVault(pool, name, args);
99
+ }
100
+ const merger = MERGERS[name];
101
+ if (!merger) {
102
+ return err(`router: no merger registered for fanout tool '${name}'`);
103
+ }
104
+ // Empty pool is an error rather than a silent empty merge result. An empty
105
+ // pool at this point means startPool succeeded with zero vaults — likely a
106
+ // config bug. Surfacing it loudly beats returning {count: 0, hits: []}
107
+ // and letting the caller wonder why their search found nothing.
108
+ if (pool.all().length === 0) {
109
+ return err(`router: cannot fan out '${name}': no vaults are configured`);
110
+ }
111
+ const rows = await fanoutCall(pool, name, args);
112
+ const merged = merger(rows);
113
+ return encodeMerged(merged, name);
114
+ }
115
+ function hasExplicitVault(args) {
116
+ if (typeof args.vault === "string" && args.vault.length > 0)
117
+ return true;
118
+ if (typeof args.path === "string") {
119
+ const parsed = parseVaultPath(args.path);
120
+ if (parsed.vault && parsed.vault.length > 0)
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
126
+ // The SDK's ListTools schema types inputSchema as a generic object; our
127
+ // CatalogTool.inputSchema is narrower. This map is the right boundary
128
+ // for the structural widening.
129
+ tools: catalog.map((t) => ({
130
+ name: t.name,
131
+ description: t.description ?? "",
132
+ inputSchema: t.inputSchema,
133
+ })),
134
+ }));
135
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
136
+ try {
137
+ return await dispatch(req.params.name, (req.params.arguments ?? {}));
138
+ }
139
+ catch (e) {
140
+ const reason = e instanceof Error ? e.message : String(e);
141
+ return err(`router error in ${req.params.name}: ${reason}`);
142
+ }
143
+ });
144
+ return { mcp, dispatch, catalog };
145
+ }
@@ -0,0 +1,16 @@
1
+ export type Routing = "fanout" | "require-vault";
2
+ export declare const ROUTING: Record<string, Routing>;
3
+ export type ChildToolDescriptor = {
4
+ name: string;
5
+ description?: string;
6
+ inputSchema: {
7
+ type: "object";
8
+ properties: Record<string, unknown>;
9
+ required?: string[];
10
+ additionalProperties?: boolean;
11
+ };
12
+ };
13
+ export type CatalogTool = ChildToolDescriptor & {
14
+ routing: Routing;
15
+ };
16
+ export declare function buildCatalog(childTools: ChildToolDescriptor[]): CatalogTool[];
@@ -0,0 +1,45 @@
1
+ export const ROUTING = {
2
+ vault_read: "require-vault",
3
+ vault_index: "fanout",
4
+ vault_status: "fanout",
5
+ vault_search: "fanout",
6
+ vault_search_related: "require-vault",
7
+ vault_reindex: "fanout",
8
+ vault_write: "require-vault",
9
+ vault_append: "require-vault",
10
+ vault_promote: "require-vault",
11
+ vault_deprecate: "require-vault",
12
+ vault_tension_log: "require-vault",
13
+ vault_lint: "fanout",
14
+ vault_provenance: "require-vault",
15
+ vault_themes: "fanout",
16
+ };
17
+ const VAULT_DESC_FANOUT = "Optional. Limit operation to one vault by name. Omit to fan out to all vaults and merge results.";
18
+ const VAULT_DESC_REQUIRED = "Vault name (required). Alternatively pass a vault-prefixed path like 'devops:runbooks/k8s.md'.";
19
+ function vaultProp(routing) {
20
+ return {
21
+ type: "string",
22
+ description: routing === "fanout" ? VAULT_DESC_FANOUT : VAULT_DESC_REQUIRED,
23
+ };
24
+ }
25
+ export function buildCatalog(childTools) {
26
+ return childTools
27
+ .filter((t) => t.name in ROUTING)
28
+ .map((t) => {
29
+ if ("vault" in t.inputSchema.properties) {
30
+ throw new Error(`tool '${t.name}' already defines a 'vault' property; router cannot add its own vault parameter`);
31
+ }
32
+ const routing = ROUTING[t.name];
33
+ const props = { ...t.inputSchema.properties, vault: vaultProp(routing) };
34
+ return {
35
+ ...t,
36
+ routing,
37
+ inputSchema: {
38
+ ...t.inputSchema, // preserves additionalProperties etc.
39
+ type: "object",
40
+ properties: props,
41
+ required: t.inputSchema.required ?? [],
42
+ },
43
+ };
44
+ });
45
+ }
@@ -0,0 +1,3 @@
1
+ import type { ChildPool } from "../children.js";
2
+ import type { VaultResult } from "./merge.js";
3
+ export declare function fanoutCall<T = unknown>(pool: ChildPool, tool: string, args: Record<string, unknown>): Promise<VaultResult<T>[]>;
@@ -0,0 +1,30 @@
1
+ // fanout.ts — broadcast a single tool call to every child vault concurrently.
2
+ //
3
+ // Per-child failures (thrown exceptions or isError responses) are captured as
4
+ // ok: false rows rather than propagating. The caller decides how to surface
5
+ // them (the mergers pass errors through without failing the aggregate result).
6
+ export async function fanoutCall(pool, tool, args) {
7
+ // Strip the router-level `vault` arg — children don't know about it.
8
+ const rest = { ...args };
9
+ delete rest.vault;
10
+ const calls = pool.all().map(async (c) => {
11
+ try {
12
+ const r = await c.callTool(tool, rest);
13
+ if (r.isError) {
14
+ const text = r.content?.[0]?.text ?? "unknown error";
15
+ return { vault: c.name, ok: false, error: text };
16
+ }
17
+ // Tool responses are JSON-encoded in a single text content block.
18
+ const text = r.content?.[0]?.text ?? "null";
19
+ return { vault: c.name, ok: true, value: JSON.parse(text) };
20
+ }
21
+ catch (e) {
22
+ return {
23
+ vault: c.name,
24
+ ok: false,
25
+ error: e instanceof Error ? e.message : String(e),
26
+ };
27
+ }
28
+ });
29
+ return Promise.all(calls);
30
+ }
@@ -0,0 +1,119 @@
1
+ export type VaultResult<T> = {
2
+ vault: string;
3
+ ok: true;
4
+ value: T;
5
+ } | {
6
+ vault: string;
7
+ ok: false;
8
+ error: string;
9
+ };
10
+ export type VaultError = {
11
+ vault: string;
12
+ error: string;
13
+ };
14
+ export declare function splitOks<T>(rows: VaultResult<T>[]): {
15
+ oks: {
16
+ vault: string;
17
+ value: T;
18
+ }[];
19
+ errors: VaultError[];
20
+ };
21
+ type SearchHit = {
22
+ path: string;
23
+ score: number;
24
+ collection: string;
25
+ } & Record<string, unknown>;
26
+ export declare function mergeSearch(rows: VaultResult<{
27
+ count: number;
28
+ hits: SearchHit[];
29
+ }>[]): {
30
+ count: number;
31
+ hits: (SearchHit & {
32
+ vault: string;
33
+ })[];
34
+ errors: VaultError[];
35
+ };
36
+ type IndexEntry = {
37
+ path: string;
38
+ } & Record<string, unknown>;
39
+ export declare function mergeIndex(rows: VaultResult<{
40
+ count: number;
41
+ entries: IndexEntry[];
42
+ }>[]): {
43
+ count: number;
44
+ entries: (IndexEntry & {
45
+ vault: string;
46
+ })[];
47
+ errors: VaultError[];
48
+ };
49
+ type StatusValue = {
50
+ fileCount: number;
51
+ invalidCount: number;
52
+ embeddingDimMismatches: number;
53
+ stalenessDistribution: {
54
+ fresh: number;
55
+ aging: number;
56
+ stale: number;
57
+ total: number;
58
+ };
59
+ } & Record<string, unknown>;
60
+ export declare function mergeStatus(rows: VaultResult<StatusValue>[]): {
61
+ fileCount: number;
62
+ invalidCount: number;
63
+ embeddingDimMismatches: number;
64
+ stalenessDistribution: {
65
+ fresh: number;
66
+ aging: number;
67
+ stale: number;
68
+ total: number;
69
+ };
70
+ generatedAt: string;
71
+ byVault: Record<string, StatusValue>;
72
+ errors: VaultError[];
73
+ };
74
+ type LintFinding = {
75
+ path: string;
76
+ } & Record<string, unknown>;
77
+ type LintValue = {
78
+ checks: Record<string, LintFinding[]>;
79
+ totalFindings: number;
80
+ generatedAt: string;
81
+ filter: string | null;
82
+ };
83
+ export declare function mergeLint(rows: VaultResult<LintValue>[]): {
84
+ totalFindings: number;
85
+ checks: Record<string, (LintFinding & {
86
+ vault: string;
87
+ })[]>;
88
+ byVault: Record<string, LintValue>;
89
+ errors: VaultError[];
90
+ };
91
+ type VaultTheme = {
92
+ label: string;
93
+ documentCount: number;
94
+ coherence: number | null;
95
+ representativeDocs: string[];
96
+ secondaryDocs: string[];
97
+ relatedTags: string[];
98
+ } & Record<string, unknown>;
99
+ type ThemesValue = {
100
+ themes: VaultTheme[];
101
+ } & Record<string, unknown>;
102
+ export declare function mergeThemes(rows: VaultResult<ThemesValue>[]): {
103
+ themes: (VaultTheme & {
104
+ vault: string;
105
+ })[];
106
+ errors: VaultError[];
107
+ };
108
+ type ReindexValue = {
109
+ vault: string;
110
+ documentCount?: number;
111
+ chunkCount?: number;
112
+ } & Record<string, unknown>;
113
+ export declare function mergeReindex(rows: VaultResult<ReindexValue>[]): {
114
+ documentCount: number;
115
+ chunkCount: number;
116
+ byVault: Record<string, ReindexValue>;
117
+ errors: VaultError[];
118
+ };
119
+ export {};
@@ -0,0 +1,118 @@
1
+ // merge.ts — fan-out result mergers for each router-dispatched tool.
2
+ //
3
+ // Each merger accepts an array of VaultResult<T> (one per child vault),
4
+ // prefixes all path fields with the vault name (via formatVaultPath),
5
+ // aggregates counters, and passes per-vault errors through without failing
6
+ // the whole call.
7
+ //
8
+ // IMPORTANT: Field names are verified against the actual daftari source:
9
+ // - vault_themes: VaultTheme has representativeDocs/secondaryDocs (string[]),
10
+ // NOT sources. The plan's sketch used sources; the real type does not.
11
+ // - vault_reindex: ReindexResult uses documentCount/chunkCount, NOT
12
+ // filesProcessed/chunksProcessed. Merger uses real field names.
13
+ import { formatVaultPath } from "../path.js";
14
+ export function splitOks(rows) {
15
+ const oks = [];
16
+ const errors = [];
17
+ for (const r of rows) {
18
+ if (r.ok)
19
+ oks.push({ vault: r.vault, value: r.value });
20
+ else
21
+ errors.push({ vault: r.vault, error: r.error });
22
+ }
23
+ return { oks, errors };
24
+ }
25
+ export function mergeSearch(rows) {
26
+ const { oks, errors } = splitOks(rows);
27
+ const hits = oks.flatMap(({ vault, value }) => value.hits.map((h) => ({ ...h, vault, path: formatVaultPath(vault, h.path) })));
28
+ hits.sort((a, b) => b.score - a.score || a.vault.localeCompare(b.vault) || a.path.localeCompare(b.path));
29
+ return { count: hits.length, hits, errors };
30
+ }
31
+ export function mergeIndex(rows) {
32
+ const { oks, errors } = splitOks(rows);
33
+ const entries = oks.flatMap(({ vault, value }) => value.entries.map((e) => ({ ...e, vault, path: formatVaultPath(vault, e.path) })));
34
+ entries.sort((a, b) => a.path.localeCompare(b.path));
35
+ return { count: entries.length, entries, errors };
36
+ }
37
+ export function mergeStatus(rows) {
38
+ const { oks, errors } = splitOks(rows);
39
+ // byVault[name] preserves the child's raw result shape — any nested paths
40
+ // inside byVault entries are NOT prefixed with vault: form. Consumers should
41
+ // scope by the byVault key or use the flat top-level fields.
42
+ const byVault = {};
43
+ let fileCount = 0;
44
+ let invalidCount = 0;
45
+ let embeddingDimMismatches = 0;
46
+ const sd = { fresh: 0, aging: 0, stale: 0, total: 0 };
47
+ for (const { vault, value } of oks) {
48
+ byVault[vault] = value;
49
+ fileCount += value.fileCount;
50
+ invalidCount += value.invalidCount;
51
+ embeddingDimMismatches += value.embeddingDimMismatches;
52
+ sd.fresh += value.stalenessDistribution.fresh;
53
+ sd.aging += value.stalenessDistribution.aging;
54
+ sd.stale += value.stalenessDistribution.stale;
55
+ sd.total += value.stalenessDistribution.total;
56
+ }
57
+ return {
58
+ fileCount,
59
+ invalidCount,
60
+ embeddingDimMismatches,
61
+ stalenessDistribution: sd,
62
+ generatedAt: new Date().toISOString(),
63
+ byVault,
64
+ errors,
65
+ };
66
+ }
67
+ export function mergeLint(rows) {
68
+ const { oks, errors } = splitOks(rows);
69
+ const checks = {};
70
+ // byVault[name] preserves the child's raw result shape — any nested paths
71
+ // inside byVault entries are NOT prefixed with vault: form. Consumers should
72
+ // scope by the byVault key or use the flat top-level fields.
73
+ const byVault = {};
74
+ let totalFindings = 0;
75
+ for (const { vault, value } of oks) {
76
+ byVault[vault] = value;
77
+ totalFindings += value.totalFindings;
78
+ for (const [name, findings] of Object.entries(value.checks)) {
79
+ const prefixed = findings.map((f) => typeof f.path === "string"
80
+ ? { ...f, vault, path: formatVaultPath(vault, f.path) }
81
+ : { ...f, vault });
82
+ if (!checks[name])
83
+ checks[name] = [];
84
+ checks[name].push(...prefixed);
85
+ }
86
+ }
87
+ return { totalFindings, checks, byVault, errors };
88
+ }
89
+ export function mergeThemes(rows) {
90
+ const { oks, errors } = splitOks(rows);
91
+ const themes = [];
92
+ for (const { vault, value } of oks) {
93
+ for (const cluster of value.themes ?? []) {
94
+ themes.push({
95
+ ...cluster,
96
+ vault,
97
+ representativeDocs: (cluster.representativeDocs ?? []).map((p) => formatVaultPath(vault, p)),
98
+ secondaryDocs: (cluster.secondaryDocs ?? []).map((p) => formatVaultPath(vault, p)),
99
+ });
100
+ }
101
+ }
102
+ return { themes, errors };
103
+ }
104
+ export function mergeReindex(rows) {
105
+ const { oks, errors } = splitOks(rows);
106
+ // byVault[name] preserves the child's raw result shape — any nested paths
107
+ // inside byVault entries are NOT prefixed with vault: form. Consumers should
108
+ // scope by the byVault key or use the flat top-level fields.
109
+ const byVault = {};
110
+ let documentCount = 0;
111
+ let chunkCount = 0;
112
+ for (const { vault, value } of oks) {
113
+ byVault[vault] = value;
114
+ documentCount += value.documentCount ?? 0;
115
+ chunkCount += value.chunkCount ?? 0;
116
+ }
117
+ return { documentCount, chunkCount, byVault, errors };
118
+ }
@@ -0,0 +1,8 @@
1
+ import type { ChildPool } from "../children.js";
2
+ type Args = Record<string, unknown>;
3
+ type Result = {
4
+ content: unknown[];
5
+ isError?: boolean;
6
+ };
7
+ export declare function routeToVault(pool: ChildPool, tool: string, args: Args): Promise<Result>;
8
+ export {};
@@ -0,0 +1,29 @@
1
+ import { parseVaultPath } from "../path.js";
2
+ const err = (text) => ({ isError: true, content: [{ type: "text", text }] });
3
+ export async function routeToVault(pool, tool, args) {
4
+ let vault = typeof args.vault === "string" && args.vault.length > 0 ? args.vault : null;
5
+ const rest = { ...args };
6
+ delete rest.vault;
7
+ // Prefix-parsing of args.path only runs when no explicit vault was provided.
8
+ // If both args.vault and a vault-prefixed args.path are set, the prefix is
9
+ // forwarded verbatim — the caller chose explicit, so we trust them.
10
+ if (!vault && typeof args.path === "string") {
11
+ const parsed = parseVaultPath(args.path);
12
+ if (parsed.vault && parsed.vault.length > 0) {
13
+ vault = parsed.vault;
14
+ rest.path = parsed.path;
15
+ }
16
+ }
17
+ if (!vault) {
18
+ return err(`${tool} requires a vault: pass {vault: name} or a vault-prefixed path like 'name:path/to.md'`);
19
+ }
20
+ const child = pool.get(vault);
21
+ if (!child) {
22
+ const known = pool
23
+ .all()
24
+ .map((c) => c.name)
25
+ .join(", ") || "(none)";
26
+ return err(`${tool}: unknown vault '${vault}'. Known vaults: ${known}`);
27
+ }
28
+ return child.callTool(tool, rest);
29
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "daftari-router",
3
+ "version": "0.1.0",
4
+ "description": "Multi-vault MCP router for Daftari — fans out across N vaults.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "bin": { "daftari-router": "./dist/cli.js" },
9
+ "engines": { "node": ">=20" },
10
+ "files": ["dist", "README.md", "LICENSE"],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "vitest run",
14
+ "dev": "tsx watch src/cli.ts --config test/fixtures/vaults.yaml",
15
+ "lint": "biome check src test",
16
+ "lint:fix": "biome check --write src test"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.29.0",
20
+ "yaml": "^2.5.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.0.0",
24
+ "tsx": "^4.0.0",
25
+ "typescript": "^5.4.0",
26
+ "vitest": "^1.6.0"
27
+ }
28
+ }