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:
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
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) orContent-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:
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:
| Detail | Why |
|---|---|
{ stream: true } on decode | a multi-byte UTF-8 character can be split across two chunks |
| split on a blank line | that 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 says | Claude API says |
|---|---|
{ name, description, inputSchema } | { name, description, input_schema } |
| camelCase | snake_case |
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:
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
npm run mcp:listThree 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
fetchplus an SSE reader. Two hundred lines, and worth writing once. Acceptmust 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.