.mcp.json + framing
# grade this stage (from your course directory)
npx theinference test --stage config-and-framing --watchBefore any MCP message means anything, it has to get across the wire intact. The wire is a child process's stdout — a raw byte stream with no built-in message boundaries. So MCP frames each JSON-RPC message as one line: the compact JSON, then a single \n. The reader splits on newlines, parses each line, and never has to guess where one message ends and the next begins.
This step is the foundation the rest of the chapter stands on. You implement the two halves of the framing — write a message, read a message — and prove they round-trip.
{ jsonrpc:"2.0", id:1, method:"tools/list", params:{} }
│ frame() ▲ parseFrame()
▼ │
'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n' ──── one line over stdio
What to build
Implement frame and parseFrame
In src/mcp.ts, implement the line-delimited framing both directions:
frame(msg)— serialize the message to compact JSON (no extra whitespace) and append exactly one\n. This is the string you'd write to the server's stdin.parseFrame(line)— take a single line off the stream, trim the trailing newline, andJSON.parseit back into an object.
They must be exact inverses: parseFrame(frame(msg)) deep-equals msg. Keep frame output canonical — one line, no pretty-printing — because the grader compares the literal string.
Graded by program.ts mcp-frame, which loads the messages from the case fixture, runs each through frame, then runs each framed string back through parseFrame, and prints { frames, parsed }.
/** Line-delimited framing: one JSON message per line, terminated by '\n'. */
export function frame(msg: object): string {
// -> compact JSON + a single trailing newline
}
export function parseFrame(line: string): unknown {
// -> JSON.parse the line back into an object (trim the newline first)
}
A host learns which servers to talk to from .mcp.json — mcpServers[name] = { type: 'stdio', command, args }. To open a stdio connection it spawns command args as a child process and treats that child's stdin/stdout as the channel: frame() what you write to its stdin, parseFrame() each line you read off its stdout. You won't spawn a real process for grading — the cassette plays the server's side — but frame/parseFrame are exactly the encode/decode you'd wrap around child.stdin.write(...) and the stdout reader.
Two easy ways to fail the byte-compare: pretty-printing (JSON.stringify(msg, null, 2) injects spaces and newlines inside the object — never use the indent argument here), and emitting more than one \n (no trailing blank line, no \r\n). One message, one compact line, one newline. parseFrame should .trim() before parsing so a stray newline never breaks it.
Process mode: your_program.sh mcp-frame runs program.ts mcp-frame on the case fixture and prints { frames, parsed } as JSON. The grader matches it against fixtures/expected.json exactly — frames must be the literal newline-terminated strings (e.g. "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n") and parsed must be the messages round-tripped back. Pretty-printed output diffs immediately.