# LLM API tutorial, MCP protocol guide

> Source: <https://promptcube3.com/en/threads/7048/>
> Published: 2026-08-20 13:48:17+00:00

# LLM API tutorial, MCP protocol guide

[MCP](/en/tags/mcp/)spec looked clean on paper. Three days later I was staring at a

`400 Bad Request`

from [Claude](/en/tags/claude/)'s API that made zero sense.

## The setup that should have worked

Friday afternoon. Fresh repo. Node 20.11.0, `@modelcontextprotocol/`

, Anthropic SDK [[email protected]](/cdn-cgi/l/email-protection)`0.24.0`

. Standard SSE transport. I'd read the spec twice. Resources, tools, prompts — all defined in a single `server.ts`

file, 140 lines including imports.

``` js
const server = new Server(
  { name: "demo-server", version: "1.0.0" },
  { capabilities: { resources: {}, tools: {}, prompts: {} } }
);

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [{ uri: "file:///tmp/test.txt", name: "Test File", mimeType: "text/plain" }]
}));

server.setRequestHandler(ReadResourceRequestSchema, async (req) => ({
  contents: [{ uri: req.params.uri, mimeType: "text/plain", text: "hello from mcp" }]
}));
```

Textbook. The inspector connected. `tools/list`

returned my three functions. `resources/list`

showed the test file. Everything green.

Then I tried calling a tool from Claude Desktop.

## The error that shouldn't exist

```
Error: Request failed with status code 400
    at AxiosError.from (axios/dist/node/axios.cjs:872:14)
    at settle (axios/dist/node/axios.cjs:1676:12)
    at IncomingMessage.handleStreamEnd (axios/dist/node/axios.cjs:3110:11)
```

No response body. Just a 400. The Anthropic SDK wraps axios, so the error bubbles up stripped of context. I added request logging middleware — 47 lines of hacky interceptors — and finally saw what was actually going over the wire:

```
{
  "jsonrpc": "2.0",
  "id": "call-1",
  "method": "tools/call",
  "params": {
    "name": "read_file",
    "arguments": { "path": "/tmp/test.txt" }
  }
}
```

Valid JSON-RPC 2.0. Valid method. Valid params schema. The server handler never even fired.

## The rabbit hole

Spent Saturday morning comparing raw HTTP dumps between the inspector (works) and Claude Desktop (fails). Difference: the `Content-Type`

header.

Inspector sends: `application/json`

Claude Desktop sends: `application/json; charset=utf-8`

That's it. That's the whole bug.

The MCP SDK's SSE transport uses Express's `express.json()`

middleware with default options. Express 4.18+ rejects requests where `Content-Type`

includes charset unless you explicitly configure `type: "application/json"`

. The inspector happened to omit charset. Claude Desktop includes it. Spec says nothing about this.

```
// Broken - default express.json() behavior
app.use(express.json());

// Fixed
app.use(express.json({ type: ["application/json", "application/json; charset=utf-8"] }));
```

Two hours of debugging for one line. The wild part? This exact issue was reported in the SDK repo **nine months ago** and closed as "wontfix — not our problem." The maintainer argued Express should handle it. Express maintainers say it's intentional. Nobody owns the integration.

## What the spec gets wrong

MCP 1.0 defines the wire protocol beautifully. Resources, tools, prompts — clean abstractions. But it says **nothing** about transport-layer quirks. No required headers. No charset handling. No guidance on SSE vs stdio vs WebSocket edge cases.

So every implementation reinvents the same broken wheels.

| Transport | Charset handling | Reconnection | Max message size |

|-----------|------------------|--------------|------------------|

| SSE (TypeScript SDK) | Broken by default | Manual | 1MB (hardcoded) |

| SSE (Python SDK) | Works | Built-in | Configurable |

| stdio | N/A | Process restart | OS pipe buffer |

| WebSocket (community) | Works | Built-in | Configurable |

The Python SDK just works. The TypeScript one — the reference implementation — doesn't. That's embarrassing.

## The fix that actually sticks

Patched the middleware. Added request ID logging. Wrapped every handler in try-catch with structured error responses. Then hit the next wall: **tool result size limits**.

Claude Desktop silently truncates tool results over ~32KB. No error. No warning. The model just gets truncated output and hallucinates the rest. I discovered this when a `grep -r`

across a 200-file codebase returned 47KB. The assistant confidently summarized files that didn't exist in the result.

Workaround: paginate everything. Every tool that can return more than 20KB needs `offset`

/`limit`

params and a `has_more`

flag. The spec doesn't require this. The spec doesn't mention it. But if you don't implement it, your tools are broken in production.

``` js
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;
  const offset = args.offset ?? 0;
  const limit = Math.min(args.limit ?? 1000, 5000);
  
  if (name === "grep") {
    const results = await ripgrep(args.pattern, { offset, limit });
    return {
      content: [{ type: "text", text: JSON.stringify(results) }],
      isError: false,
      _meta: { has_more: results.length === limit }
    };
  }
  // ...
});
```

The `_meta`

field isn't in the spec either. But it's the only way to signal pagination without breaking clients that ignore unknown fields.

## Why this matters for anyone building on LLMs

You're not just calling an API. You're building on a stack where:

1. The protocol spec is incomplete

2. The reference implementation has known bugs marked "wontfix"

3. Client behavior varies wildly (Desktop vs API vs inspector)

4. Undocumented limits bite you in production

I've now built **four** MCP servers. Two for internal tooling, one for a client, one open source. Every single one hit transport issues the spec doesn't cover. Charset. Truncation. Reconnection. Heartbeat timeouts. The list grows.

The [Workflows](/en/category/workflows/) section on PromptCube has a thread where three of us independently discovered the same charset bug within two weeks. None of us found it in docs. We found it by comparing packet captures.

## The model comparison that actually helps

Stop benchmarking on MMLU. Test your actual workflow.

| Model | Tool call success rate | Avg latency (tools/call) | Context handling | Cost per 1M tokens |

|-------|------------------------|--------------------------|------------------|-------------------|

| Claude 3.5 Sonnet | 94% | 1.2s | Excellent | $3/$15 |

| GPT-4o | 87% | 2.1s | Good | $5/$15 |

| Claude 3 Opus | 91% | 2.8s | Excellent | $15/$75 |

| GPT-4o-mini | 79% | 0.9s | Fragile | $0.15/$0.60 |

These numbers come from **my** logging across 12,000 tool calls last month. Not vendor claims. Sonnet wins on reliability. Opus isn't worth 5x for tool use. GPT-4o-mini fails silently on complex schemas — it invents parameters that don't exist.

The [AI Models](/en/category/ai-models/) comparisons on PromptCube track this stuff with real user data. Vendor benchmarks lie. Community benchmarks don't.

## What I'd tell my past self

Don't trust the inspector. It lies. Test against **every** client you support — Claude Desktop, the API, Continue, Cline, your own custom client. They all speak slightly different dialects.

Don't assume the SDK handles transport. It doesn't. Read the middleware source. Patch it before you write a single tool.

Don't believe the spec is complete. It's a starting point. The real protocol lives in GitHub issues, Discord arguments, and packet captures.

And for the love of everything, **log the raw HTTP**. Every request. Every response. Every header. You will need it. The debugging story above? Took two hours because I didn't have logging from day one. The next bug took fifteen minutes because I did.

## The server that's been running for three weeks

Current uptime: 21 days. 847 tool calls. Zero crashes. The fixes:

1. Explicit charset handling in Express

2. Pagination on every list/read tool

3. Structured error responses with `isError: true`

4. Request ID correlation across client → gateway → server

5. Health endpoint that actually exercises the tool chain

6. Graceful shutdown that drains in-flight requests (30s timeout)

```
// Health check that proves the whole stack works
app.get("/health", async (req, res) => {
  try {
    const result = await server.request({
      method: "tools/call",
      params: { name: "echo", arguments: { text: "health-check" } }
    });
    res.json({ status: "ok", tool_test: "passed" });
  } catch (e) {
    res.status(503).json({ status: "degraded", error: e.message });
  }
});
```

The health endpoint caught a memory leak in the ripgrep wrapper on day 12. Would have OOM'd the container by day 14.

## One thing I still don't understand

Why does the MCP spec mandate `initialize`

handshake but leave capability negotiation **optional**? Every client sends different capability sets. Every server handles missing capabilities differently. Some fail fast. Some degrade silently. Some ignore unknown fields.

The result: you can't write a server that works generically. You write a server for **your** client, then hack in conditionals for the others.

``` js
// Gross but necessary
const supportsPagination = req.clientInfo?.name?.includes("Claude") ?? false;
const supportsProgress = req.clientInfo?.version && semver.gte(req.clientInfo.version, "1.2.0");
```

This is protocol design failure. Not implementation failure. Protocol.

## Where this goes next

I'm migrating the TypeScript server to Python. The SDK there handles charset, reconnection, heartbeats, and backpressure correctly out of the box. Same protocol. Different implementation quality.

The community knows this. Nobody says it loudly because everyone's busy building workarounds. But if you're starting fresh — use Python. Or the community WebSocket transport. The reference SSE implementation is technically non-compliant with its own spec's spirit.

Three days of debugging. One line fix. Seventeen workarounds and counting.

That's MCP in 20TOPIC_OUT_OF_SCOPE

[Next Adam's L2 penalty scales inversely with gradient magnitude →](/en/threads/6990/)

## All Replies （0）

No replies yet — be the first!
