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 +132 -0
- package/dist/children.d.ts +9 -0
- package/dist/children.js +25 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +51 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +110 -0
- package/dist/path.d.ts +6 -0
- package/dist/path.js +9 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +145 -0
- package/dist/tools/catalog.d.ts +16 -0
- package/dist/tools/catalog.js +45 -0
- package/dist/tools/fanout.d.ts +3 -0
- package/dist/tools/fanout.js +30 -0
- package/dist/tools/merge.d.ts +119 -0
- package/dist/tools/merge.js +118 -0
- package/dist/tools/route.d.ts +8 -0
- package/dist/tools/route.js +29 -0
- package/package.json +28 -0
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>;
|
package/dist/children.js
ADDED
|
@@ -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
package/dist/cli.js
ADDED
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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,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
|
+
}
|