Build a Two-Server MCP Investigation, Step by Step

Run a pinned SDK lab, inspect a modern protocol exchange, consume two servers, and test tenant checks and write replay.

The example uses two real MCP servers and one deterministic client. It answers a small question: which deployment preceded a synthetic checkout incident? It then records an internal note and repeats the write with the same operation key.

Using a deterministic client first makes the protocol and application boundaries observable. Adding an LLM before the integration works introduces another source of uncertainty. This lab does not need a model account or provider credentials.

Step 1: know exactly what will run

The companion source is in the public mcp-engineering-examples repository. Use Node.js 22.13 or newer. The lab uses JavaScript ESM, MCP server/client packages 2.3.1, and Zod 4.6.7, with a package lock. JavaScript keeps compilation out of the exercise; the APIs are from the official TypeScript SDK.

mcp-engineering-examples/
  domain.mjs          fixture data, permissions, replay rules
  server-factory.mjs  MCP schemas and domain adapters
  server.mjs          modern stdio entry point
  connect.mjs         pinned client and result helper
  client.mjs          two-server investigation
  test/lab.test.mjs   real subprocess and domain tests
  test/http.test.mjs  modern HTTP framing, in-process
  package.json
  package-lock.json

Clone the companion repository:

git clone https://github.com/ketankhairnar/mcp-engineering-examples.git
cd mcp-engineering-examples
npm ci --ignore-scripts
npm test
npm run demo

The commands install public dependencies and operate only on synthetic data. No external note, ticket, or message is created. The package is separate from the site’s dependency graph.

Step 2: implement the domain before the adapter

Read domain.mjs. The store contains one incident and one deployment for each of Acme and Beacon. A trusted fixture principal carries tenant, subject, and scopes. Every read filters by that tenant; a missing object and an inaccessible object produce the same public error.

This is the essential ownership check:

const incident = store.incidents.find(
  row => row.id === id && row.tenant === principal.tenant,
);
if (!incident) throw new DomainError("NOT_FOUND_OR_NOT_ACCESSIBLE");

The domain functions know nothing about JSON-RPC or model prompts. An API adapter or CLI can call the same functions. That separation lets you compare interfaces without quietly changing business semantics.

The local launcher selects acme-reader, acme-writer, or beacon-writer. These are fixture profiles, not credentials. Anyone controlling the launcher can choose them. They demonstrate policy checks under supplied identities; they do not authenticate a remote user.

Step 3: expose a small, constrained contract

Read server-factory.mjs. It uses registerTool with a schema and handler. For list operations, service is bounded and limit is an integer from 1 to 20. Strict schemas reject extra fields, including an attempted model-supplied tenant.

const listInput = z.object({
  service: z.string().min(1).max(80),
  limit: z.number().int().min(1).max(20).default(5),
}).strict();

The incidents server exposes list_incidents, get_incident, and add_note; the deployment server exposes list_deployments. Results contain both text and structuredContent. Expected domain errors return isError: true with a bounded error code. The SDK supplies protocol framing.

Incident responses include at most the latest ten notes and a notesTruncated flag. Bounding the number of incidents alone would not bound the response if each incident could accumulate an unlimited returned history.

For a public production contract, add explicit output schemas and client-side validation appropriate to each tool. The lab instead uses fixture shapes and assertions in the integration tests; that is a narrower guarantee.

The official server tutorial explains the schema registration API. Our domain, fixture data, and negative tests are specific to this exercise.

Step 4: select the protocol era deliberately

server.mjs uses the modern serving entry point and rejects legacy openings:

serveStdio(() => createServer(kind, store, principal), {
  legacy: "reject",
});

connect.mjs constructs a client with an explicit revision:

const client = new Client(
  { name: "incident-lab-consumer", version: "1.0.0" },
  { versionNegotiation: { mode: { pin: "2026-07-28" } } },
);

The stdio transport launches the server subprocess. The host owns cleanup, so the example closes clients in finally blocks. Server stdout contains protocol messages only.

The important verification is client.getProtocolEra() === "modern", not merely a dependency version. The SDK protocol migration guide documents why an explicit choice is needed.

Step 5: consume two catalogs and join evidence

The client lists each server’s tools, then independently requests incident and deployment records. It joins by service and temporal order. The fixture has one relevant deployment; a production implementation needs an explicit time window, environment, complete pagination, and ambiguity handling.

The resulting note says the deployment preceded the incident and calls for investigation. It does not claim causation. The data model and message are deliberately simple enough to inspect.

The demo prints two modern protocol eras, the two tool lists, the incident and deployment identifiers, and retryReturnedSameNote: true. The random note identifier changes between process runs. If the protocol era is wrong, treat that as a configuration or compatibility failure before investigating model behavior.

Step 6: make a write safe to repeat within its stated lifetime

The write checks notes:write and object ownership before consulting its replay map. Its map key includes tenant, subject, tool, and operation key. The payload fingerprint includes incident ID and note text.

Identical input with the same key returns the original result. Changed input conflicts. Another tenant’s identical key is independent. Authorization is rechecked on replay, so removing write scope does not leave a cached-success bypass.

There is no await between checking the key and recording the effect, so this toy implementation’s critical section is synchronous within one process. It is not a distributed transaction. A restart or another worker has a different map. Production must replace this with durable atomic storage and a documented retention period.

Step 7: inspect modern HTTP without deploying anything

test/http.test.mjs drives createMcpHandler through an SDK HTTP transport whose fetch function calls the handler in-process. The URL is a test identifier; no network listener is opened.

The test captures actual request and response bodies. It asserts discovery, absence of initialize, per-request version metadata, matching method/name headers, resultType, and catalog cache hints. A second test intentionally mismatches the method header and body, expects HTTP 400 with HeaderMismatch, and proves no note was added.

This verifies framing through the pinned SDK. It does not test TLS, proxies, Host/Origin guards, or OAuth. The HTTP serving guide explicitly distinguishes a handler from the middleware that protects a deployed endpoint.

Step 8: use the failures as the learning exercise

Before running each test, predict both the response and whether state changes.

AttemptExpected observation
Acme reads its incidentRecord returned
Acme reads Beacon’s incident or a nonexistent IDSame public inaccessible/not-found outcome
Caller supplies an extra tenant fieldSchema rejection
Read-only principal adds a noteDenial and zero notes
Writer adds a note, then repeats identical key/inputSame result, one note
Writer reuses key with different textConflict, original note retained
Two tenants use the same key in shared fixture storageIndependent results
Write scope is removed before replayDenial, existing state unchanged
HTTP method header disagrees with bodyProtocol rejection before effect

The baseline groups these assertions into six tests. The durable extension below adds five, so npm test runs eleven tests. Passing them establishes the tested properties only. To add an LLM later, expose a selected subset of discovered schemas through the host’s model interface, enforce policy before dispatch, and evaluate task correctness separately from these deterministic tests.

Step 9: preserve a write across worker replacement

The default demo deliberately uses a process-local map. Now keep the MCP contract unchanged and replace the storage implementation:

npm run demo:durable
npm run test:durable

The durable demo writes a note through one MCP server, closes it, starts a replacement server, and retries the operation. It prints workerReplaced: true, sameResult: true, and noteCount: 1. Both workers use the same temporary SQLite database. The demo removes the database after closing its clients.

Read the durability walkthrough alongside durable-store.mjs. A unique operation identity includes tenant, actor, tool and operation key. The payload hash covers the incident ID and exact text. The note and operation result commit in one transaction; current scope and object ownership are checked before replay.

The tests include two real MCP workers, replacement after shutdown, and disposable workers killed with SIGKILL at transaction boundaries. A kill between note insertion and ledger insertion leaves neither committed. A kill after commit but before response leaves one note and a replayable result. Racing different payloads under one key yields one success and one conflict.

This closes the local restart gap. It does not establish a multi-host service or atomic effects in another provider. SQLite’s synchronous driver can block a worker; ledger entries have no automatic expiry; fixture principals still do not authenticate anyone. Read the walkthrough’s limits before adapting it to production.

The next chapter identifies the production work that remains. Do not convert fixture identities into bearer tokens or publish this subprocess demo as a customer-facing service.

Mcp-engineering Ch 7/8
  1. 1 MCP in 2026: Understand the Protocol, Then Engineer the System 10m
  2. 2 Why MCP Exists When APIs and CLIs Already Work 25m
  3. 3 Follow an MCP Request: Discovery, Capabilities, State, and Failure 30m
  4. 4 Consuming Several MCP Servers Without Losing Control 25m
  5. 5 What Established MCP Operators Actually Teach Us 20m
  6. 6 Design an MCP Provider for Hundreds of Customer Organizations 35m
  7. 7 Build a Two-Server MCP Investigation, Step by Step 45m
  8. 8 From a Working MCP Lab to a Service Customers Can Rely On 35m