test(mcp): add cross-transport parity harness for remote vs stdio MCP - #230
Conversation
Records where the streamable-HTTP/SSE bridge and the stdio package had drifted apart, and pins the short list of differences that stay intentional. Follow-up to #224, which fixed one symptom of the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Connects one MCP client to each transport — the bridge in-process over streamable HTTP, the published stdio package as a child process — and diffs tools/list and the initialize result. Both point at the same live AutoMem, so any difference is the transports, not the data. Red as of this commit: remote omits _meta, title, outputSchema, two annotation hints, and server instructions, and its descriptions are one-liners. Pins @modelcontextprotocol/sdk to 1.20.0 so the new devDependency's newer SDK gets nested rather than hoisted over the deployed bridge's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nineteen scenarios covering every mode on both transports — batch and supersede store, ranked/detailed/items/json/empty recall, id fetch, tag enumeration, relation-prop and batch association, update, single and bulk delete, health, and three error paths. Each transport writes under its own uuid4 tag namespace so neither sees the other's memories; UUIDs, timestamps, scores and query_time_ms are redacted before comparison. Cleanup deletes both namespaces by tag. Red as of this commit, first failure on `store single`: remote Memory stored: <UUID> stdio Memory stored successfully!\n\nMemory ID: <UUID> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
make test now chains the MCP bridge's node suite, which was previously only reachable in CI and invisible locally. make test-parity brings the stack up and diffs the two transports in one command. The workflow runs on PRs touching the bridge or the HTTP API, plus weekly as a drift alarm against newly published mcp-automem versions. It is continue-on-error until the bridge moves onto the shared surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found by local review before this ever reached GitHub: - The weekly drift alarm could never alarm. npm ci reinstalls exactly what package-lock.json pins, so the scheduled run re-tested the same version forever. It now resolves the latest published package on the schedule path only; PR runs stay reproducible. - The health scenario compared global memory/vector/enrichment counters. The two transports run their batches sequentially against one AutoMem, so the second batch always sees the first's fixtures — a guaranteed false failure no ordering could fix. Those counters are now redacted. - structuredContent was read for chaining and then discarded, so a mismatch in memory_ids, recall count, or health statistics would pass whenever the text matched. It is now normalized, redacted and compared. Also report every differing scenario in one run rather than throwing on the first, so a multi-scenario gap is not a fix-one-rerun loop at ~90s a cycle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second local review pass, three more defects in the harness itself: - redact() stripped only the UUID inside the remote's " (request_id: ...)" error suffix, but the contract allowlists the whole suffix. The three error scenarios would have stayed red forever once everything else aligned. - The initialize check asserted both server names and neither version, so the remote's hardcoded 0.1.0 disagreeing with its own package.json — drift the audit explicitly records — could pass undetected. Each side now has to report its own package version; cross-transport equality is not required because the two packages version independently. - make test-parity fell through its readiness loop and ran the diff against a dead stack, reporting transport mismatches that were really a missing API. It now exits non-zero with service logs, matching the workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ac5b4eeb6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
connectBothTransports opens the HTTP listener before connecting the stdio child. If that child failed to start, the rejection escaped before close() was returned, leaving the listener and remote client alive — and node --test waits on open handles, so the scheduled job would hang until GitHub's timeout instead of reporting the startup failure. The weekly @latest install added in this branch is exactly what makes that reachable: an incompatible entry point resolves to a child that cannot start. Setup is now wrapped, and the accumulated closers run before rethrowing. Verified by removing the package and re-running: the error surfaces in 82ms and the process exits on its own instead of being killed by a watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 89346e9c87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Round 2 of Codex review, three findings plus one caught locally. - Register transport/client closers *before* awaiting the handshake. The previous fix only covered a child that failed to spawn; if the process started but the MCP handshake rejected, the closer was never registered and the live child kept node --test open. - Give every scenario its own <tag>-sN namespace and seed its own fixtures. Sharing one namespace meant a write-capability gap corrupted every later comparison: the remote rejects `store batch` while stdio inserts three records, so subsequent recalls compared two different datasets and reported mismatches even where recall rendering was already in parity. That hides which fixes actually worked, precisely when landing them one at a time. Seeding uses single-store only, never a mode one transport lacks. - Remove the weekly cron and the @latest install. While the harness is intentionally red, continue-on-error leaves the workflow successful either way, so the scheduled run had the same external result before and after a regression — an alarm that could not alarm. It returns with the change that turns the harness green. Also, found while verifying: structuredContent was re-parsed after redaction, but redaction substitutes <MS>/<COUNT> placeholders for numbers, so the redacted form is deliberately not valid JSON and JSON.parse threw. It is now compared as a normalized, redacted string. test-parity now uses port 8011. A developer running a local AutoMem install holds 8001, and this harness writes fixtures and bulk-deletes by tag — it must never point at a real memory store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Babysit — ready. Verified green at 👍 Codex all clear on the current head. 2 review rounds, 4 findings, all fixed and resolved; 0 unresolved threads. One check is red on purpose: Round-by-round:
Two more were caught locally before this ever reached GitHub (the weekly job reinstalling the pinned version;
Agent did not merge. |
Problem
#224 fixed one symptom: the remote bridge's compact recall block dropped the memory's stored date, so an agent replaying that text read a two-week-old itinerary as today's plan. AutoHub's parser already looked for a
Created:line — the remote transport simply never emitted one, while the stdio package always had.That single missing line is one instance of a much wider split.
mcp-automem'sserver.jsonpublishes stdio, streamable-HTTP, and SSE as one server with one shared 6-tool array. A client picking "AutoMem" out of the registry should get the same tools and the same output regardless of transport. It currently does not.A manual schema sync was already attempted once —
d99b86d fix(mcp-sse): sync tool schemas for SSE/MCP parity (#104)— and has since drifted again.What this PR adds
It does not close the gap. It documents the gap and makes it machine-checkable, so the fix (collapsing both transports onto one shared implementation) lands against a red-to-green target instead of a hand-maintained checklist.
docs/MCP_TRANSPORT_PARITY.md— the full divergence audit, plus an accepted transport-level differences table that is the live contract the harness allowlists against.mcp-sse-server/parity/— connects one MCP client to each transport (bridge in-process over streamable HTTP, published stdio package as a child process), both pointed at the same live AutoMem, so any difference is the transports and not the data.make test-parity— brings the stack up and diffs them in one command.make testnow also chains the bridge's node suite, which was previously only reachable in CI and invisible locally..github/workflows/mcp-parity.yml— runs on PRs touching the bridge or the HTTP API, plus weekly as a drift alarm against newly publishedmcp-automemversions.What the harness asserts
tools/listdeep-equal after key-order normalization.capabilitiesandinstructionsmatch;serverInfo.nameis allowlisted to differ, but each side must report its own package version.tools/callscenarios render identically — covering batch and supersede store, ranked/detailed/items/json/empty recall, id fetch, tag enumeration, relation-prop and batch association, update, single and bulk delete, health, and three error paths.Each transport writes under its own
uuid4tag namespace so neither sees the other's memories. UUIDs, timestamps, scores,query_time_ms, global service counters and the allowlistedrequest_idsuffix are redacted before comparison. Cleanup deletes both namespaces by tag.Current result: intentionally red
All 19 scenarios differ, which is the finding, not a failure of the harness:
tools/listdiffers on_meta,title,outputSchema, two annotation hints and all six descriptions;getInstructions()isundefinedon the remote side. The remote is also missingrecall_memory's id-fetch andexhaustivemodes,store_memory's batch and supersede modes,delete_memory's bulk-by-tag, and all nine ofassociate_memories' relation-specific properties.The workflow is
continue-on-error: truefor exactly this reason and flips to blocking in the change that closes the gap.Test plan
AUTOMEM_RUN_PARITY_TESTS=1the parity tests skip, which is what CI's existingnode-testjob sees — verified.memory_countreturned to exactly 6000 after repeated runs.Notes
package-lock.json— 89 transitive dependency entries from adding the stdio package as a devDependency. 630 lines are reviewable code. The lockfile has to ship with the harness because CI runsnpm ci, so splitting would not reduce the reviewable surface.@modelcontextprotocol/sdkis pinned to1.20.0exact. Adding the devDependency otherwise hoists the newer SDK over the deployed bridge's — a production runtime bump riding in on a test-only change. Pinning makes the devDependency nest its own copy instead.