Skip to content
MCP Five

The HostProject #215 / 51

Writing the client by hand

MCP is JSON over HTTP. Two things bite: the Accept header, and the reply that might be SSE.

There is an official SDK. Project #2 does not use it for the client half, on purpose.

lib/mcp-client.ts is about two hundred lines of fetch. The point of the series is that the protocol is just JSON, and you do not really believe that until you have written the thing that speaks it.

Part 1 Β· The client, in outline

Strip out the era handling and error mapping and it is this:

lib/mcp-client.ts β€” the shape
ts
async function call(server: Server, method: string, params?: unknown) {
  const res = await fetch(server.url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",   // ← both. always.
      ...(server.token ? { Authorization: `Bearer ${server.token}` } : {}),
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: nextId(),
      method,
      params,
    }),
  });
 
  if (!res.ok) throw new Error(`${server.name}: HTTP ${res.status}`);
 
  const type = res.headers.get("content-type") ?? "";
  const payload = type.includes("text/event-stream")
    ? await readSse(res)          // ← the other half
    : await res.json();
 
  if (payload.error) throw new Error(`${server.name}: ${payload.error.message}`);
  return payload.result;
}

That is genuinely most of it. tools/list is call(server, "tools/list"). tools/call is call(server, "tools/call", { name, arguments }).

Part 2 Β· Bite #1 β€” the Accept header

ts
Accept: "application/json, text/event-stream"

List only application/json and you get a bare 406 Not Acceptable. No useful body, no hint about which header it disliked, and a status code that suggests you asked for something unreasonable rather than that you forgot to mention a second content type you were not planning to use.

Why the rule exists

Because the server chooses the response format, per request:

If the body is a JSON-RPC request, the server MUST return either Content-Type: application/json (a single JSON object) or Content-Type: text/event-stream (an SSE response stream). The client MUST support both.

A fast tool answers with plain JSON. A slow one opens a stream so it can send progress notifications before the result. You do not get to pick, so you have to declare you can handle either.

Part 3 Β· Bite #2 β€” the reply might be SSE

Same request, two legal shapes:

# sometimes this
{"jsonrpc":"2.0","id":1,"result":{...}}
 
# and sometimes this
event: message
data: {"jsonrpc":"2.0","id":1,"result":{...}}

A client that assumes the first gets SyntaxError: Unexpected token 'e' in JSON at position 0 β€” which tells you nothing whatsoever about content negotiation, and sends you looking at your JSON serialisation.

So you need a small SSE reader:

ts
async function readSse(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let last: unknown;
 
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
 
    // events are separated by a blank line
    const events = buffer.split(/\r?\n\r?\n/);
    buffer = events.pop() ?? "";
 
    for (const event of events) {
      for (const line of event.split(/\r?\n/)) {
        if (line.startsWith(":")) continue;              // comment / keep-alive
        if (!line.startsWith("data:")) continue;         // event:, id:, retry:
        last = JSON.parse(line.slice(5).trim());
      }
    }
  }
  return last;
}

Four details in there that each cost something to learn:

DetailWhy
{ stream: true } on decodea multi-byte UTF-8 character can be split across two chunks
split on a blank linethat is the SSE event separator, not the newline
buffer = events.pop()the last piece may be a partial event; keep it for the next chunk
skip lines starting with :those are comments β€” servers send them as keep-alives on long streams

Part 4 Β· The translation layer everybody warns you about

MCP and the Claude API both describe tools with JSON Schema. They are almost the same, which is worse than being different, because the gap is invisible until it isn't.

MCP saysClaude API says
{ name, description, inputSchema }{ name, description, input_schema }
camelCasesnake_case
One rename is the whole translation layer. Get it wrong and the API returns a 400 about a missing field while you stare at two schemas that look identical.

Two more things that file quietly handles, both one-liners you would otherwise find the hard way:

It strips $schema. Noise you pay tokens for on every request, on every tool, forever. Given what a growing conversation costs, removing bytes from the cached prefix is worth doing once.

It substitutes {type:"object", properties:{}} for a missing schema. A no-argument tool is perfectly legal in MCP and a 400 in the Claude API.

Part 5 Β· Check the transport before you check anything else

The best habit in project #2, and it costs nothing:

bash
npm run mcp:list        # 1. can we reach the servers at all?  (no AI)
npm run mcp:translate   # 2. do the schemas convert correctly? (no AI)
npm run agent           # 3. does the loop chain its own output?

And note which ones cost money: the first two do not touch a model at all. That convention survives the whole series and reaches its logical end in project #5, with a full regression suite that costs $0.00.

Checkpoint

bash
npm run mcp:list

Three servers, eleven tools, and no authentication errors β€” against the live deployments, not local ones. If a server is unreachable this is where you find out, before any of it is your loop's fault.

What you now know

  • An MCP client is fetch plus an SSE reader. Two hundred lines, and worth writing once.
  • Accept must list both content types, because the server picks the response format per request.
  • Handle both reply shapes; the SSE reader needs streaming decode, blank-line splitting, partial-buffer carry-over, and comment skipping.
  • inputSchema β†’ input_schema, strip $schema, substitute an empty object for a missing one β€” and keep the output stable, because it lives in the cached prefix.
  • Prove the transport, then the schemas, then the loop. In that order, and the first two are free.