Skip to content

Commit 0c5ee3e

Browse files
jack-arturoclaude
andcommitted
fix(mcp): surface stored metadata and updated_at in detailed recall format (#111)
The REST /recall API already returns parsed memory.metadata plus updated_at/last_accessed; the gap was the MCP server's detailed formatter, which omitted them entirely. The detailed format now renders: - an Updated: line when updated_at is present (parallel to the existing Last accessed handling), and - a size-capped Metadata: line — single-line JSON truncated to 300 chars with a trailing ellipsis, omitted when metadata is missing or empty — so provenance fields surface without dumping raw metadata verbosely. The json format remains a raw passthrough (already exposed metadata) and is now locked by a transport-level test. text/items formats are unchanged. Adds a REST contract test (test_recall_metadata_roundtrip) locking the store -> recall metadata/timestamp round-trip, and fixes docs/METADATA_BEHAVIOR.md, which over-claimed that the detailed format already exposed metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 193b730 commit 0c5ee3e

4 files changed

Lines changed: 182 additions & 5 deletions

File tree

docs/METADATA_BEHAVIOR.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ on memories. It is intentionally a product/runtime spec, not an experiment note.
1818
## Recall Response Shape
1919

2020
- Recall results include parsed `memory.metadata` when the graph or Qdrant
21-
payload provides it.
22-
- `json` and detailed recall formats expose the same memory metadata object in
23-
result payloads; malformed graph metadata is treated as an empty or raw parsed
24-
value depending on the caller path.
21+
payload provides it, along with `updated_at` and `last_accessed` timestamps;
22+
malformed graph metadata is treated as an empty or raw parsed value depending
23+
on the caller path.
24+
- The MCP server's `json` recall format passes the raw response through, so it
25+
exposes the full metadata object. The MCP `detailed` format renders a
26+
size-capped `Metadata:` line (single-line JSON truncated to 300 characters
27+
with a trailing ellipsis) plus an `Updated:` line when present, and omits the
28+
metadata line entirely for empty or missing metadata. The `text` and `items`
29+
formats do not include metadata.
2530
- Final scoring can use metadata terms as weak evidence for candidates that are
2631
already present from another channel.
2732

mcp-sse-server/server.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const DEFAULT_UPSTREAM_MAX_RETRIES = 2;
1919
const DEFAULT_HEALTH_TIMEOUT_MS = 5000;
2020
const DEFAULT_HEALTH_PROBE_INTERVAL_MS = 30000;
2121
const TRANSIENT_STATUS_CODES = new Set([408, 429, 502, 503, 504]);
22+
const DETAILED_METADATA_MAX_CHARS = 300;
2223

2324
function readIntEnv(name, fallback) {
2425
const raw = process.env[name];
@@ -369,6 +370,7 @@ export function formatRecallAsItems(results, { detailed = false } = {}) {
369370
if (id) lines.push(`ID: ${id}`);
370371
if (mem.type) lines.push(`Type: ${String(mem.type)}`);
371372
if (mem.timestamp) lines.push(`Timestamp: ${String(mem.timestamp)}`);
373+
if (mem.updated_at) lines.push(`Updated: ${String(mem.updated_at)}`);
372374
if (mem.last_accessed) lines.push(`Last accessed: ${String(mem.last_accessed)}`);
373375
if (mem.importance !== undefined) {
374376
const imp = Number(mem.importance);
@@ -379,6 +381,20 @@ export function formatRecallAsItems(results, { detailed = false } = {}) {
379381
lines.push(`Confidence: ${Number.isFinite(conf) ? conf.toFixed(3) : String(mem.confidence)}`);
380382
}
381383
if (tags.length) lines.push(`Tags: ${tags.join(', ')}`);
384+
if (mem.metadata && typeof mem.metadata === 'object' && Object.keys(mem.metadata).length) {
385+
let metaJson = '';
386+
try {
387+
metaJson = JSON.stringify(mem.metadata);
388+
} catch (_) {
389+
metaJson = '';
390+
}
391+
if (metaJson && metaJson !== '{}') {
392+
const capped = metaJson.length > DETAILED_METADATA_MAX_CHARS
393+
? `${metaJson.slice(0, DETAILED_METADATA_MAX_CHARS)}…`
394+
: metaJson;
395+
lines.push(`Metadata: ${capped}`);
396+
}
397+
}
382398
if (score !== undefined) lines.push(`Score: ${score.toFixed(3)}`);
383399
if (it?.match_type) lines.push(`Match: ${String(it.match_type)}`);
384400
if (it?.source) lines.push(`Source: ${String(it.source)}`);
@@ -480,7 +496,7 @@ export function buildMcpServer(client) {
480496
type: 'string',
481497
enum: ['text', 'items', 'detailed', 'json'],
482498
default: 'text',
483-
description: 'Output formatting: text (single block), items (one memory per content item), detailed (per-item with timestamps/relations), json (raw response JSON as text)',
499+
description: 'Output formatting: text (single block), items (one memory per content item), detailed (per-item with timestamps/metadata/relations), json (raw response JSON as text)',
484500
}
485501
}
486502
}

mcp-sse-server/test/server.test.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,12 @@ test("formatRecallAsItems supports detailed output including relations", () => {
9292
content: "Hello world",
9393
tags: ["automem", "cursor"],
9494
timestamp: "2025-12-14T00:00:00Z",
95+
updated_at: "2025-12-14T02:00:00Z",
9596
last_accessed: "2025-12-14T01:00:00Z",
9697
importance: 0.95,
9798
confidence: 0.88,
9899
type: "Insight",
100+
metadata: { created_by: "test-agent", task: "synthetic-task" },
99101
},
100102
},
101103
];
@@ -104,10 +106,12 @@ test("formatRecallAsItems supports detailed output including relations", () => {
104106
assert.ok(detailed.includes("ID: mem-1"));
105107
assert.ok(detailed.includes("Type: Insight"));
106108
assert.ok(detailed.includes("Timestamp: 2025-12-14T00:00:00Z"));
109+
assert.ok(detailed.includes("Updated: 2025-12-14T02:00:00Z"));
107110
assert.ok(detailed.includes("Last accessed: 2025-12-14T01:00:00Z"));
108111
assert.ok(detailed.includes("Importance: 0.950"));
109112
assert.ok(detailed.includes("Confidence: 0.880"));
110113
assert.ok(detailed.includes("Tags: automem, cursor"));
114+
assert.ok(detailed.includes('Metadata: {"created_by":"test-agent","task":"synthetic-task"}'));
111115
assert.ok(detailed.includes("Score: 0.123"));
112116
assert.ok(detailed.includes("Match: relation"));
113117
assert.ok(detailed.includes("Source: graph"));
@@ -116,6 +120,109 @@ test("formatRecallAsItems supports detailed output including relations", () => {
116120
const compact = formatRecallAsItems(results, { detailed: false })[0].text;
117121
assert.ok(compact.includes("score=0.123"));
118122
assert.ok(compact.includes("ID: mem-1"));
123+
assert.ok(!compact.includes("Metadata:"));
124+
});
125+
126+
test("formatRecallAsItems detailed output caps metadata size and omits empty metadata", () => {
127+
const bigMetadata = { notes: "x".repeat(400) };
128+
const results = [
129+
{
130+
memory: { id: "mem-big", content: "Big metadata", metadata: bigMetadata },
131+
},
132+
{
133+
memory: { id: "mem-empty", content: "Empty metadata", metadata: {} },
134+
},
135+
{
136+
memory: { id: "mem-none", content: "No metadata" },
137+
},
138+
];
139+
140+
const [big, empty, none] = formatRecallAsItems(results, { detailed: true }).map(x => x.text);
141+
142+
const metadataLine = big.split("\n").find(line => line.startsWith("Metadata: "));
143+
assert.ok(metadataLine, "expected a Metadata line for oversized metadata");
144+
const rendered = metadataLine.slice("Metadata: ".length);
145+
assert.ok(rendered.endsWith("…"));
146+
assert.equal(rendered.length, 301); // 300 chars + ellipsis
147+
assert.equal(rendered.slice(0, 300), JSON.stringify(bigMetadata).slice(0, 300));
148+
149+
assert.ok(!empty.includes("Metadata:"));
150+
assert.ok(!none.includes("Metadata:"));
151+
assert.ok(!big.includes("Updated:"));
152+
});
153+
154+
test("recall_memory json format passes through metadata from the API response", async () => {
155+
const prevToken = process.env.AUTOMEM_API_TOKEN;
156+
const prevEndpoint = process.env.AUTOMEM_API_URL;
157+
process.env.AUTOMEM_API_TOKEN = "test-token";
158+
process.env.AUTOMEM_API_URL = "http://upstream.test";
159+
160+
const originalFetch = globalThis.fetch;
161+
const upstreamResponse = {
162+
status: "success",
163+
results: [
164+
{
165+
id: "mem-json",
166+
final_score: 0.9,
167+
memory: {
168+
id: "mem-json",
169+
content: "JSON passthrough",
170+
metadata: { created_by: "test-agent", task: "synthetic-task" },
171+
updated_at: "2025-12-14T02:00:00Z",
172+
last_accessed: "2025-12-14T01:00:00Z",
173+
},
174+
},
175+
],
176+
count: 1,
177+
};
178+
179+
globalThis.fetch = async (url, options) => {
180+
if (String(url).startsWith("http://upstream.test/")) {
181+
return new Response(JSON.stringify(upstreamResponse), {
182+
status: 200,
183+
headers: { "content-type": "application/json" },
184+
});
185+
}
186+
return originalFetch(url, options);
187+
};
188+
189+
try {
190+
const app = createApp();
191+
await withServer(app, async (port) => {
192+
const res = await originalFetch(`http://127.0.0.1:${port}/mcp`, {
193+
method: "POST",
194+
headers: {
195+
"Content-Type": "application/json",
196+
Accept: "application/json, text/event-stream",
197+
Authorization: "Bearer test-token",
198+
},
199+
body: JSON.stringify({
200+
jsonrpc: "2.0",
201+
id: 1,
202+
method: "tools/call",
203+
params: {
204+
name: "recall_memory",
205+
arguments: { query: "passthrough", format: "json" },
206+
},
207+
}),
208+
});
209+
210+
assert.equal(res.status, 200);
211+
const body = await res.json();
212+
const text = body.result.content[0].text;
213+
const parsed = JSON.parse(text);
214+
assert.deepEqual(parsed.results[0].memory.metadata, {
215+
created_by: "test-agent",
216+
task: "synthetic-task",
217+
});
218+
assert.equal(parsed.results[0].memory.updated_at, "2025-12-14T02:00:00Z");
219+
assert.equal(parsed.results[0].memory.last_accessed, "2025-12-14T01:00:00Z");
220+
});
221+
} finally {
222+
globalThis.fetch = originalFetch;
223+
process.env.AUTOMEM_API_TOKEN = prevToken;
224+
process.env.AUTOMEM_API_URL = prevEndpoint;
225+
}
119226
});
120227

121228
test("AutoMemClient._request retries transient upstream errors", async () => {

tests/test_api_endpoints.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,55 @@ def test_recall_with_explicit_timestamps(client, mock_state, auth_headers):
556556
assert "time_window" in data
557557

558558

559+
def test_recall_metadata_roundtrip(client, mock_state, auth_headers):
560+
"""Custom metadata and timestamps stored via POST /memory surface in /recall (#111)."""
561+
with_metadata = {
562+
"content": "Memory with provenance metadata",
563+
"tags": ["metadata-roundtrip", "with-metadata"],
564+
"importance": 0.8,
565+
"metadata": {"created_by": "test-agent", "task": "synthetic-task"},
566+
}
567+
without_metadata = {
568+
"content": "Memory without metadata",
569+
"tags": ["metadata-roundtrip", "no-metadata"],
570+
"importance": 0.7,
571+
}
572+
573+
memory_ids = {}
574+
for key, payload in (("with", with_metadata), ("without", without_metadata)):
575+
store_response = client.post("/memory", json=payload, headers=auth_headers)
576+
assert store_response.status_code == 201
577+
store_data = store_response.get_json()
578+
assert store_data["status"] == "success"
579+
memory_ids[key] = store_data["memory_id"]
580+
581+
response = client.get("/recall?tags=metadata-roundtrip&limit=10", headers=auth_headers)
582+
assert response.status_code == 200
583+
data = response.get_json()
584+
assert data["status"] == "success"
585+
586+
results = {result["id"]: result["memory"] for result in data.get("results", [])}
587+
assert set(results) == set(memory_ids.values())
588+
589+
enriched = results[memory_ids["with"]]
590+
assert isinstance(enriched["metadata"], dict)
591+
assert enriched["metadata"]["created_by"] == "test-agent"
592+
assert enriched["metadata"]["task"] == "synthetic-task"
593+
# POST /memory defaults updated_at to created_at and last_accessed to updated_at
594+
assert enriched["updated_at"]
595+
assert enriched["last_accessed"]
596+
597+
# Backward compat: memories stored without metadata round-trip without user
598+
# metadata. JIT enrichment may add server-side bookkeeping keys only
599+
# (written by jit_enrich_lightweight in automem/enrichment/runtime_orchestration.py).
600+
plain = results[memory_ids["without"]]
601+
plain_metadata = plain.get("metadata") or {}
602+
assert isinstance(plain_metadata, dict)
603+
assert set(plain_metadata) <= {"enrichment", "entities"}
604+
assert plain["updated_at"]
605+
assert plain["last_accessed"]
606+
607+
559608
def test_recall_with_high_limit(client, mock_state, auth_headers):
560609
"""Test recall with limit exceeding max - should clamp to 50."""
561610
response = client.get("/recall?limit=100", headers=auth_headers)

0 commit comments

Comments
 (0)