Client ↔ server round-trip
# grade this stage (from your course directory)
npx theinference test --stage round-trip --watchYou built both halves. In Chapter 9 your client learned to frame a tools/call, send it, and parse { content, isError } back out. In this chapter your server learned to receive that call, run the tool, and answer. This step closes the loop: your client talks to your server, no real subprocess in between, and a result comes back the way it would over a live stdio pipe.
Nothing new gets implemented here — that's the point. If both sides speak the protocol correctly, wiring them together is just a function call: the client builds a tools/call request, the server's handle returns a response, and the client's parser pulls the text out the far end. A green here is proof your two chapters agree on the same wire format down to the byte.
client.buildCall("echo", { a: 1 }) → { jsonrpc:"2.0", id, method:"tools/call", params:{ name, arguments } }
│
server.handle(req)
│
▼
{ result:{ content:[{type:"text", text:"echo({\"a\":1})"}], isError:false } }
│
client.parseResult(resp) → "echo({\"a\":1})"
What to build
Wire the Ch9 client to your Ch10 server
Implement roundTrip(tools, tool, args) in src/mcp.ts. Stand up your MCPServer from the provided tools, then drive it through your Chapter 9 client code — don't shortcut by calling handle with a hand-built object.
- Construct the
MCPServerwithtools. - Use the client to build a
tools/callrequest fortoolwitharguments: args— the same request shape your Ch9 client sent over stdio. - Pass that request to
server.handle(...)(this stands in for the stdio transport). - Use the client's result parser to extract the text from
{ content, isError }, and return it asresult.
For the fixture (echo, { a: 1 }) that text is echo({"a":1}). Graded by program.ts mcp-roundtrip.
import { MCPClient } from './client.ts' // your Chapter 9 client
import type { ToolDef } from './types.ts'
export function roundTrip(
tools: ToolDef[],
tool: string,
args: Record<string, unknown>,
): { result: string } {
const server = new MCPServer(tools)
const client = new MCPClient()
// 1. req = client.buildCall(tool, args) // Ch9: builds the tools/call frame
// 2. resp = server.handle(req) // Ch10: runs it, returns { content, isError }
// 3. text = client.parseResult(resp) // Ch9: pulls content[0].text out
// return { result: text }
}
Over a live connection, your client's request would be JSON-encoded, line-framed, written to the server's stdin, read back off its stdout, and parsed. Here you collapse all of that into one server.handle(req) call. The framing and subprocess plumbing are already proven in Chapter 9 — this step isolates the semantic round-trip: does the request the client builds match the request the server expects, and does the response the server emits match what the client knows how to read?
It's tempting to skip the client and call server.handle({ method: 'tools/call', params: { name: tool, arguments: args } }) directly. Don't. The whole value of this step is that the same client code that drove a real server in Chapter 9 now drives yours unchanged. If your client's request shape and your server's expectations have quietly diverged, hand-rolling the request papers over exactly the bug this step exists to surface.
Process mode. program.ts mcp-roundtrip reads case.json — serverTools (one echo tool), tool: 'echo', args: { a: 1 } — calls your roundTrip, and prints what comes back. The output is compared to fixtures/expected.json exactly: {"result":"echo({\"a\":1})"}. A correct round-trip threads the client-built request through handle and parses the text back out; any divergence between the two halves shows up as a mismatched (or empty) result.