Skip to content

Commit f75d83d

Browse files
committed
fix(docker): pin mcp<2 — unpinned install broke the yantrikdb container
Rebuilding yantrikdb.Dockerfile took the stack into a crash-loop: ModuleNotFoundError: No module named 'mcp.server.fastmcp' Cause: the Dockerfile ran an unpinned `pip install 'yantrikdb-mcp[onnx]'`. The `mcp` SDK published 2.0.0, which removed `mcp.server.fastmcp`, while yantrikdb-mcp still imports that path. Nobody edited the Dockerfile — the dependency moved underneath it. This is the precise failure yantrikdb's own v0.10.0 release notes name: "version pins fail (the version doesn't move), signature checks fail (behavior moves without them)... Pin immutable refs, probe features not versions, and declare unsupported capabilities at runtime instead of silently degrading." Fix: * ARG MCP_VERSION=">=1.9,<2" — must stay <2 until yantrikdb-mcp migrates off the removed fastmcp path. * ARG YANTRIKDB_MCP_VERSION="==0.10.0" — pin the wrapper too. * Build-time import gate: `python -c "from mcp.server.fastmcp import FastMCP; import yantrikdb_mcp"`. A future incompatible resolve now fails the BUILD instead of shipping an image that crash-loops at runtime. Mechanical contract test, per the same release notes. Side effect: this rebuild also moved the engine 0.9.4 -> 0.10.1 and the wrapper 0.9.1 -> 0.10.0, which is a real upgrade (see below). Verified after the fix: * container healthy; app HTTP 200; single + batch writes recorded * 26/26 test files green * drive-session end-to-end: recap now references 5/5 established facts (was 4/5 on 0.9.4) in 3279ms from 4 canon facts (was 5603ms from 10) * v0.10 surface confirmed live by probe, not by version string: remember.idempotency_key present recall.include_superseded present (recall now EXCLUDES superseded by default — safe for us: Chronicler filters retcons client-side on canonical_status, it does not rely on engine-level supersede) New probe tooling (scripts/): probe-mcp-tools.ts — enumerate MCP tools, diff against what YantrikClient wraps; flags new/missing tools probe-mcp-full.ts — full schema dump; ONLY=a,b to filter probe-v09-features.ts — exercises the 7 v0.9 capabilities Chronicler does not yet use; 7/7 verified working probe-retry-doublewrite.ts — demonstrates that retryOnBackpressure can durably write the same fact N times when the server commits but the response is lost. Confirmed: 3 writes for 1 logical fact. Fix is remember(idempotency_key) now that we are on the v0.10 engine. Follow-up.
1 parent f5f79b8 commit f75d83d

5 files changed

Lines changed: 286 additions & 7 deletions

File tree

scripts/probe-mcp-full.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Full schema dump for YantrikDB MCP tools. Used to spot what a core
2+
// upgrade added — new tools AND new actions on existing tools.
3+
4+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6+
7+
const STACK = process.env.CHRONICLER_URL ?? "http://127.0.0.1:3001/api/mcp";
8+
const ONLY = process.env.ONLY?.split(",").map((s) => s.trim());
9+
10+
async function main(): Promise<void> {
11+
const client = new Client({ name: "probe", version: "0.1.0" }, { capabilities: {} });
12+
await client.connect(new StreamableHTTPClientTransport(new URL(STACK)));
13+
const { tools } = await client.listTools();
14+
for (const tool of tools) {
15+
if (ONLY && !ONLY.includes(tool.name)) continue;
16+
console.log(`\n${"=".repeat(72)}`);
17+
console.log(`TOOL: ${tool.name}`);
18+
console.log("=".repeat(72));
19+
console.log(tool.description ?? "(no description)");
20+
const schema = tool.inputSchema as {
21+
properties?: Record<string, { type?: string; description?: string; enum?: unknown[] }>;
22+
required?: string[];
23+
};
24+
if (schema?.properties) {
25+
console.log(`\nPARAMS:`);
26+
for (const [name, s] of Object.entries(schema.properties)) {
27+
const req = schema.required?.includes(name) ? "*" : "";
28+
const en = s.enum ? ` enum=${JSON.stringify(s.enum)}` : "";
29+
console.log(` ${name}${req}: ${s.type ?? "?"}${en}${s.description ? ` — ${s.description}` : ""}`);
30+
}
31+
}
32+
}
33+
await client.close();
34+
}
35+
36+
main().catch((e) => { console.error(e); process.exit(1); });

scripts/probe-mcp-tools.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// List all tools YantrikDB exposes over MCP. Diffs against what
2+
// chronicler's YantrikClient knows about, so a version upgrade that
3+
// adds tools (or removes them) is visible.
4+
5+
import { McpTransport } from "../src/lib/yantrikdb/mcp-transport";
6+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8+
9+
const STACK = process.env.CHRONICLER_URL ?? "http://127.0.0.1:3001/api/mcp";
10+
11+
// Tools chronicler's YantrikClient currently wraps (from client.ts).
12+
const KNOWN_TOOLS = new Set([
13+
"remember",
14+
"recall",
15+
"correct",
16+
"forget",
17+
"conflict",
18+
"category",
19+
"graph",
20+
"think",
21+
"personality",
22+
"session",
23+
"temporal",
24+
"trigger",
25+
"procedure",
26+
"skill",
27+
"stats",
28+
"memory",
29+
]);
30+
31+
async function main(): Promise<void> {
32+
const client = new Client({ name: "probe", version: "0.1.0" }, { capabilities: {} });
33+
const transport = new StreamableHTTPClientTransport(new URL(STACK));
34+
await client.connect(transport);
35+
const result = await client.listTools();
36+
const names = result.tools.map((t) => t.name).sort();
37+
console.log(`YantrikDB exposes ${names.length} tools:`);
38+
for (const n of names) {
39+
const marker = KNOWN_TOOLS.has(n) ? " " : "★"; // ★ = new / unknown
40+
console.log(` ${marker} ${n}`);
41+
}
42+
const newTools = names.filter((n) => !KNOWN_TOOLS.has(n));
43+
const missingTools = Array.from(KNOWN_TOOLS).filter((n) => !names.includes(n));
44+
console.log(`\nnew (★) — not wrapped by chronicler: ${newTools.length ? newTools.join(", ") : "none"}`);
45+
console.log(`missing — client references but server no longer exposes: ${missingTools.length ? missingTools.join(", ") : "none"}`);
46+
47+
// Dump signatures for the new tools so we can see what they do.
48+
for (const name of newTools) {
49+
const tool = result.tools.find((t) => t.name === name);
50+
if (!tool) continue;
51+
console.log(`\n── ${name} ──`);
52+
console.log(`description: ${tool.description?.slice(0, 400) ?? "(no description)"}${tool.description && tool.description.length > 400 ? "…" : ""}`);
53+
const schema = tool.inputSchema as { properties?: Record<string, { type?: string; description?: string }>; required?: string[] };
54+
if (schema?.properties) {
55+
const params = Object.entries(schema.properties);
56+
console.log(`params (${params.length}):`);
57+
for (const [pname, pschema] of params.slice(0, 8)) {
58+
console.log(` ${pname}${schema.required?.includes(pname) ? "*" : ""}: ${pschema.type ?? "?"}${pschema.description?.slice(0, 100) ?? ""}`);
59+
}
60+
if (params.length > 8) console.log(` … and ${params.length - 8} more`);
61+
}
62+
}
63+
await client.close();
64+
void McpTransport; // keep import for future use
65+
}
66+
67+
main().catch((e) => {
68+
console.error(e);
69+
process.exit(1);
70+
});

scripts/probe-retry-doublewrite.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Does retryOnBackpressure double-write?
2+
//
3+
// Failure mode under test: the server COMMITS the write, then the response
4+
// is lost (timeout / dropped connection / queue-full raised after commit).
5+
// Our retry fires again → two memories for one logical fact.
6+
//
7+
// Simulated with a transport that commits on every call but reports
8+
// queue_full for the first N — exactly the "succeeded but you didn't hear
9+
// about it" shape.
10+
11+
import { YantrikClient, type YantrikDBTransport } from "../src/lib/yantrikdb/client";
12+
13+
async function main(): Promise<void> {
14+
const committed: string[] = [];
15+
let call_n = 0;
16+
17+
const transport: YantrikDBTransport = {
18+
async call(_tool, args) {
19+
call_n++;
20+
// The server always durably commits...
21+
const text = (args as { text?: string }).text ?? "(batch)";
22+
committed.push(text);
23+
// ...but the first two calls report queue_full to the client.
24+
if (call_n <= 2) {
25+
return { result: "Error executing tool remember: ingest queue full (256 pending ops, max=256); retry after 5ms" };
26+
}
27+
return { result: `{"rid":"rid-${call_n}","status":"recorded"}` };
28+
},
29+
};
30+
31+
const client = new YantrikClient(transport);
32+
const { rid } = await client.remember({
33+
text: "Ren promised to meet Pranab at the lighthouse Saturday at dusk.",
34+
metadata: {},
35+
});
36+
37+
console.log(`client saw ONE success: rid=${rid}`);
38+
console.log(`server actually committed ${committed.length} copies:`);
39+
committed.forEach((c, i) => console.log(` ${i + 1}. ${c.slice(0, 60)}…`));
40+
41+
if (committed.length > 1) {
42+
console.log(`\n✗ CONFIRMED BUG — ${committed.length} durable writes for 1 logical fact.`);
43+
console.log(` An idempotency key (v0.10 engine) makes the retry return the ORIGINAL rid`);
44+
console.log(` with zero additional writes. We are on 0.9.4, which has no such key.`);
45+
} else {
46+
console.log(`\n✓ no duplication`);
47+
}
48+
}
49+
50+
main().catch((e) => { console.error(e); process.exit(1); });

scripts/probe-v09-features.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Exercise every v0.9.0 YantrikDB capability Chronicler does NOT yet use,
2+
// against the live server, so we recommend from evidence not from docs.
3+
4+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6+
7+
const STACK = process.env.CHRONICLER_URL ?? "http://127.0.0.1:3001/api/mcp";
8+
const NS = `v09probe-${Date.now().toString(36)}`;
9+
10+
let client: Client;
11+
12+
async function call(tool: string, args: Record<string, unknown>): Promise<unknown> {
13+
const res = await client.callTool({ name: tool, arguments: args });
14+
const content = (res as { content?: Array<{ type?: string; text?: string }> }).content ?? [];
15+
const text = content.filter((c) => c.type === "text").map((c) => c.text ?? "").join("");
16+
if (text.startsWith("Error executing tool")) throw new Error(text);
17+
try { return JSON.parse(text); } catch { return text; }
18+
}
19+
20+
function show(label: string, v: unknown, chars = 500): void {
21+
const s = typeof v === "string" ? v : JSON.stringify(v, null, 2);
22+
console.log(`${label}:\n${s.slice(0, chars)}${s.length > chars ? "\n …(truncated)" : ""}\n`);
23+
}
24+
25+
async function section(name: string, fn: () => Promise<void>): Promise<boolean> {
26+
console.log(`\n${"█".repeat(70)}\n██ ${name}\n${"█".repeat(70)}`);
27+
try { await fn(); console.log(`✓ ${name} WORKS`); return true; }
28+
catch (e) { console.log(`✗ ${name} FAILED: ${e instanceof Error ? e.message.slice(0, 300) : String(e)}`); return false; }
29+
}
30+
31+
async function main(): Promise<void> {
32+
client = new Client({ name: "v09probe", version: "0.1.0" }, { capabilities: {} });
33+
await client.connect(new StreamableHTTPClientTransport(new URL(STACK)));
34+
console.log(`namespace: ${NS}\n`);
35+
const results: Record<string, boolean> = {};
36+
37+
// ── 1. session digest — one-call boot briefing ────────────────────
38+
results["session digest"] = await section("session digest (boot-time briefing)", async () => {
39+
const d = await call("session", { action: "digest", namespace: NS, max_decisions: 3, max_conflicts: 3, max_triggers: 3, snippet_chars: 120 });
40+
show("digest", d, 900);
41+
});
42+
43+
// ── 2. knowledge gaps — the substrate's known unknowns ────────────
44+
results["gaps"] = await section("gaps (known unknowns / demand log)", async () => {
45+
// Ask the same unanswerable question repeatedly to create a gap.
46+
for (let i = 0; i < 4; i++) {
47+
await call("recall", { query: "what is Ren's mother's name", top_k: 3, namespace: NS });
48+
}
49+
const g = await call("gaps", { min_count: 1, max_avg_top_score: 0.9, limit: 10 });
50+
show("gaps", g, 900);
51+
});
52+
53+
// ── 3. conversation ring buffer ───────────────────────────────────
54+
results["conversation"] = await section("conversation (verbatim working-memory ring)", async () => {
55+
await call("conversation", { action: "record", namespace: NS, role: "user", content: "Do you remember the lighthouse?", max_turns: 6 });
56+
await call("conversation", { action: "record", namespace: NS, role: "assistant", content: "Ren nods. 'Saturday, at dusk.'", max_turns: 6 });
57+
const recent = await call("conversation", { action: "recent", namespace: NS, limit: 5 });
58+
show("recent turns", recent, 600);
59+
});
60+
61+
// ── 4. task store — deferred follow-through ───────────────────────
62+
results["task"] = await section("task (substrate-backed commitments)", async () => {
63+
const added = await call("task", { action: "add", namespace: NS, title: "Meet Pranab at the lighthouse Saturday at dusk", priority: "high" });
64+
show("added", added, 300);
65+
const list = await call("task", { action: "list", namespace: NS, status: "open" });
66+
show("open tasks", list, 600);
67+
});
68+
69+
// ── 5. record-to-record links + link-expanded recall ──────────────
70+
results["record links"] = await section("graph record_link + recall_with_links", async () => {
71+
const a = (await call("remember", { text: "Ren promised to meet Pranab at the lighthouse on Saturday at dusk.", namespace: NS, importance: 0.8 })) as { rid: string };
72+
const b = (await call("remember", { text: "The lighthouse at Port Lyra has been unmanned since the storm.", namespace: NS, importance: 0.6 })) as { rid: string };
73+
show("rids", { a: a.rid, b: b.rid }, 200);
74+
const linked = await call("graph", { action: "record_link", source_rid: a.rid, target_rid: b.rid, link_type: "concerns" });
75+
show("record_link", linked, 300);
76+
const traversed = await call("graph", { action: "linked_records", rid: a.rid, direction: "both" });
77+
show("linked_records", traversed, 600);
78+
const expanded = await call("graph", { action: "recall_with_links", query: "lighthouse meeting", top_k: 3, expand_links: 2, namespace: NS });
79+
show("recall_with_links", expanded, 800);
80+
});
81+
82+
// ── 6. autonomous maintenance cycle (dry run) ─────────────────────
83+
results["maintenance_cycle"] = await section("think maintenance_cycle (dry run)", async () => {
84+
const m = await call("think", { maintenance_cycle: true, dry_run: true });
85+
show("cycle preview", m, 900);
86+
});
87+
88+
// ── 7. skill outcomes telemetry ───────────────────────────────────
89+
results["stats skill_outcomes"] = await section("stats skill_outcomes", async () => {
90+
const s = await call("stats", { action: "skill_outcomes" });
91+
show("skill outcomes", s, 400);
92+
});
93+
94+
console.log(`\n${"═".repeat(70)}\nSUMMARY\n${"═".repeat(70)}`);
95+
for (const [k, v] of Object.entries(results)) console.log(` ${v ? "✓" : "✗"} ${k}`);
96+
const working = Object.values(results).filter(Boolean).length;
97+
console.log(`\n${working}/${Object.keys(results).length} v0.9.0 capabilities verified working`);
98+
await client.close();
99+
}
100+
101+
main().catch((e) => { console.error(e); process.exit(1); });

yantrikdb.Dockerfile

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,36 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1414
ca-certificates \
1515
&& rm -rf /var/lib/apt/lists/*
1616

17-
# Install CPU-only torch first (much smaller), then yantrikdb-mcp WITH the
18-
# [onnx] extra — required for the 384-dim sentence-transformers embedder
19-
# that existing chronicler DBs were created against. Without it, recall +
20-
# skill calls error: "ONNX embedder requested but optional deps not
21-
# installed." The slim install would default to a 64-dim bundled embedder
22-
# and silently fail to recall any 384-dim memories already in the volume.
17+
# Pinned deliberately. An unpinned `pip install yantrikdb-mcp[onnx]` silently
18+
# drifts on every rebuild — and on 2026-08-04 that drift took the stack down:
19+
# the `mcp` SDK released 2.0.0, which removed `mcp.server.fastmcp`, while
20+
# yantrikdb-mcp still imports that path. Result: ModuleNotFoundError in a
21+
# crash-loop, from a Dockerfile nobody had edited.
22+
#
23+
# yantrikdb's own v0.10.0 release notes name this exact trap:
24+
# "version pins fail (the version doesn't move), signature checks fail
25+
# (behavior moves without them)... Pin immutable refs, probe features
26+
# not versions, and declare unsupported capabilities at runtime instead
27+
# of silently degrading."
28+
#
29+
# So: pin the floor AND the ceiling on both packages, and let
30+
# scripts/probe-mcp-tools.ts assert capabilities at runtime rather than
31+
# trusting a version string.
32+
#
33+
# MCP_VERSION — must stay <2 until yantrikdb-mcp migrates off
34+
# mcp.server.fastmcp (removed in the 2.0.0 SDK).
35+
# YANTRIKDB_MCP_VERSION — the [onnx] extra is required for the 384-dim
36+
# sentence-transformers embedder that existing chronicler DBs were created
37+
# against. Without it, recall + skill calls error with "ONNX embedder
38+
# requested but optional deps not installed", and the slim install falls
39+
# back to a 64-dim bundled embedder that silently recalls nothing from a
40+
# 384-dim volume.
41+
ARG MCP_VERSION=">=1.9,<2"
42+
ARG YANTRIKDB_MCP_VERSION="==0.10.0"
43+
2344
RUN pip install --index-url https://download.pytorch.org/whl/cpu torch \
24-
&& pip install 'yantrikdb-mcp[onnx]'
45+
&& pip install "mcp${MCP_VERSION}" "yantrikdb-mcp[onnx]${YANTRIKDB_MCP_VERSION}" \
46+
&& python -c "from mcp.server.fastmcp import FastMCP; import yantrikdb_mcp; print('import gate OK')"
2547

2648
RUN mkdir -p /data
2749
VOLUME ["/data"]

0 commit comments

Comments
 (0)