cd /news/developer-tools/llm-api-tutorial-mcp-protocol-guide Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-104552] src=promptcube3.com β†— pub= topic=developer-tools verified=true sentiment=↓ negative

LLM API tutorial, MCP protocol guide

A developer debugging an MCP (Model Context Protocol) integration with Claude's API found that a 400 Bad Request error was caused by the Content-Type header 'application/json; charset=utf-8' sent by Claude Desktop, which Express's default json middleware rejects. The issue, reported nine months prior and closed as 'wontfix', highlights a gap in the MCP spec regarding transport-layer details, leading to inconsistent behavior between the TypeScript and Python SDKs.

read7 min views5 publishedAug 20, 2026
LLM API tutorial, MCP protocol guide
Image: Promptcube3 (auto-discovered)

MCPspec looked clean on paper. Three days later I was staring at a

400 Bad Request

from 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]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.

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.

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 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 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.

// 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 β†’

All Replies (0οΌ‰ #

No replies yet β€” be the first!

── more in #developer-tools 4 stories Β· sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/llm-api-tutorial-mcp…] indexed:0 read:7min 2026-08-20 Β· β€”