Skip to content

test(mcp): add cross-transport parity harness for remote vs stdio MCP - #230

Merged
jack-arturo merged 8 commits into
developfrom
feat/mcp-transport-parity
Aug 23, 2026
Merged

test(mcp): add cross-transport parity harness for remote vs stdio MCP#230
jack-arturo merged 8 commits into
developfrom
feat/mcp-transport-parity

Conversation

@jack-arturo

Copy link
Copy Markdown
Member

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's server.json publishes 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 test now 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 published mcp-automem versions.

What the harness asserts

  1. tools/list deep-equal after key-order normalization.
  2. Server capabilities and instructions match; serverInfo.name is allowlisted to differ, but each side must report its own package version.
  3. 19 tools/call scenarios 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 uuid4 tag namespace so neither sees the other's memories. UUIDs, timestamps, scores, query_time_ms, global service counters and the allowlisted request_id suffix 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:

19/19 scenarios differ: store single, store batch, store supersede,
recall ranked text, recall detailed, recall items, recall json, recall empty,
recall id fetch, recall exhaustive, associate single,
associate batch partial failure, update, delete single, delete by tag, health,
error: exhaustive without tags, error: store content over hard limit,
error: unknown tool

tools/list differs on _meta, title, outputSchema, two annotation hints and all six descriptions; getInstructions() is undefined on the remote side. The remote is also missing recall_memory's id-fetch and exhaustive modes, store_memory's batch and supersede modes, delete_memory's bulk-by-tag, and all nine of associate_memories' relation-specific properties.

The workflow is continue-on-error: true for exactly this reason and flips to blocking in the change that closes the gap.

Test plan

make test          # 656 python unit + 22 node; 3 parity tests skip cleanly
make test-parity   # brings the stack up, reproduces the red baseline
  • Without AUTOMEM_RUN_PARITY_TESTS=1 the parity tests skip, which is what CI's existing node-test job sees — verified.
  • The harness leaves no residue: memory_count returned to exactly 6000 after repeated runs.

Notes

  • Size: 2132 changed lines, but 1502 of those are 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 runs npm ci, so splitting would not reduce the reviewable surface.
  • @modelcontextprotocol/sdk is pinned to 1.20.0 exact. 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.
  • No behavior change to the bridge itself: this PR adds tests, docs and CI only.

jack-arturo and others added 6 commits August 14, 2026 13:40
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>
@jack-arturo jack-arturo added babysit:active Babysit agent is actively working this PR babysit:waiting-codex 👀 Codex reviewing current head and removed babysit:active Babysit agent is actively working this PR labels Aug 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread mcp-sse-server/parity/clients.js Outdated
@jack-arturo jack-arturo added babysit:active Babysit agent is actively working this PR and removed babysit:waiting-codex 👀 Codex reviewing current head labels Aug 23, 2026
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>
@jack-arturo jack-arturo added babysit:waiting-ci Babysit waiting for CI checks on current head and removed babysit:active Babysit agent is actively working this PR labels Aug 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread mcp-sse-server/parity/scenarios.js Outdated
Comment thread .github/workflows/mcp-parity.yml
Comment thread mcp-sse-server/parity/clients.js Outdated
@jack-arturo jack-arturo added babysit:active Babysit agent is actively working this PR and removed babysit:waiting-ci Babysit waiting for CI checks on current head labels Aug 23, 2026
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>
@jack-arturo jack-arturo added babysit:waiting-ci Babysit waiting for CI checks on current head and removed babysit:active Babysit agent is actively working this PR labels Aug 23, 2026
@jack-arturo

Copy link
Copy Markdown
Member Author

Babysit — ready. Verified green at fa458b0004.

👍 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: parity. That job is the deliverable — it diffs the two MCP transports and they currently differ in all 19 scenarios. Its workflow run concludes success via job-level continue-on-error, develop has no required-check protection, and mergeStateStatus is UNSTABLE, not BLOCKED. Nothing about it blocks merge. It flips to blocking in the change that collapses the transports onto one shared implementation.

Round-by-round:

Round Finding Outcome
1 clients.js leaked the HTTP listener when the stdio child failed to spawn fixed 89346e9
2 closer registered after the handshake await, so a child that started then failed the handshake still leaked fixed fa458b0
2 scenarios shared one tag namespace, so a write-capability gap corrupted every later recall comparison fixed fa458b0
2 weekly cron could not distinguish "same known gaps" from new drift — an alarm that cannot alarm removed fa458b0

Two more were caught locally before this ever reached GitHub (the weekly job reinstalling the pinned version; /health comparing global counters), plus one found while verifying (structuredContent re-parsed after redaction, which is deliberately not valid JSON).

make test-parity now runs on port 8011, not 8001 — the harness writes fixtures and bulk-deletes by tag, and a developer running a local AutoMem install holds 8001. That collision happened during this run.

Agent did not merge.

@jack-arturo jack-arturo added babysit:ready 👍 Codex all clear + gates pass; human may merge and removed babysit:waiting-ci Babysit waiting for CI checks on current head labels Aug 23, 2026
@jack-arturo
jack-arturo merged commit 969755d into develop Aug 23, 2026
7 of 8 checks passed
@jack-arturo
jack-arturo deleted the feat/mcp-transport-parity branch August 23, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

babysit:ready 👍 Codex all clear + gates pass; human may merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant