The Assumption Worth Questioning #
A vendor you depend on ships an MCP server. The announcement is all about connecting Claude, ChatGPT and Cursor. Every tutorial shows a chat window. So when you need that data inside your own backend, the natural plan is: run a model on the server, give it the MCP connection, ask it to fetch the thing.
Stop there for a second.
In one line
MCP is a JSON-RPC protocol for listing and calling tools. Nothing in it requires a language model. If you already know which tool you want and what arguments it takes, call it directly — no model, no tokens, no non-determinism.
Models are the reason the standard exists — they need a uniform way to discover tools nobody hard-coded for them. But "designed for models" is not the same as "requires a model", and conflating those two costs you money and latency on every single request.
What an MCP Server Actually Exposes #
Strip away the framing and an MCP server offers a very small surface:
| Method | What it does | Analogy |
|---|---|---|
initialize | Version handshake, once per session | Opening a connection |
tools/list | Returns every tool: name, description, JSON schema | An OpenAPI document |
tools/call | Runs one tool with arguments, returns content | Calling an endpoint |
Read tools/list as an endpoint list and tools/call as the request. That is genuinely all it is — a self-describing RPC API where the schema ships with the server instead of in a docs site that drifts out of date.
Which, incidentally, is a nice property to have even if you never involve a model: the tool schema is generated by the server, so it cannot go stale.
The Decision, In One Diagram #
The question is not "MCP or API". It is whether the choice of which call to make is known ahead of time.
Nearly every scheduled job, sync and import falls on the left. Nearly every open-ended question falls on the right. The mistake is putting the left-hand cases through the right-hand machinery because the vendor's marketing implied you had to.
What It Costs You to Get This Wrong #
Concretely, for a data pull where the tool and arguments are fixed:
| Direct call | Through a model | |
|---|---|---|
| Latency | One HTTP round trip | Model inference, plus the same round trip, plus a second inference to summarise |
| Cost | Zero beyond the API itself | Tokens in and out, on every single pull |
| Determinism | Same input, same call, always | May pick different arguments, or a different tool, or paraphrase the result |
| Debuggability | Your code chose the arguments | You are debugging a decision you did not make |
| Failure mode | An exception | A plausible-looking answer that is subtly wrong |
That last row is the one that matters. A direct call that fails throws. A model in the middle that misreads a schema can return something that looks entirely reasonable and is not, and you will not notice until the downstream data is wrong.
Non-determinism is not a small tax on a data pipeline. If the same ASIN can produce slightly different keyword sets on two runs because the model chose different arguments, you have made your own data unreproducible for no benefit.
The Whole Client #
Here is a complete, working MCP client. There is no SDK, because there does not need to be one.
import json, httpx
class MCPClient:
def __init__(self, url: str, token: str):
self.url = url
self.headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
# servers may answer either way; accept both
"Accept": "application/json, text/event-stream",
"User-Agent": "my-app/1.0",
}
self._id = 0
def _rpc(self, method: str, params: dict | None = None) -> dict:
self._id += 1
r = httpx.post(self.url, headers=self.headers, timeout=60, json={
"jsonrpc": "2.0", "id": self._id,
"method": method, "params": params or {},
})
r.raise_for_status()
data = self._parse(r.text)
if "error" in data:
raise RuntimeError(data["error"])
return data.get("result", {})
@staticmethod
def _parse(body: str) -> dict:
# Streamable HTTP returns SSE frames even for a single reply
if body.lstrip().startswith("{"):
return json.loads(body)
for line in body.splitlines():
if line.startswith("data:"):
return json.loads(line[5:].strip())
raise ValueError(f"unparseable MCP response: {body[:200]!r}")
def initialize(self):
return self._rpc("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "my-app", "version": "1.0"},
})
def list_tools(self):
return self._rpc("tools/list").get("tools", [])
def call(self, name: str, **arguments):
return self._rpc("tools/call", {"name": name, "arguments": arguments})
Roughly forty lines, one dependency, and it works against any MCP server over Streamable HTTP.
Three Things That Will Trip You Up #
- The response is an SSE frame, not JSON
Even for a single reply, Streamable HTTP servers commonly return
event: messagefollowed by adata:line. Calling.json()on that fails. Parse the data line — the client above handles both shapes. - The auth is ordinary OAuth, discovered from well-known URLs
An MCP server that needs authentication publishes
/.well-known/oauth-protected-resource, which points at an authorisation server, which publishes its own metadata. Two unauthenticated GETs give you the endpoints and scopes. Many servers also support Dynamic Client Registration, meaning no application form at all. tools/listis your documentationCall it once and print it. The names, descriptions and argument schemas are all there, generated by the server. This is often more accurate than whatever docs page exists.
When a Model Genuinely Belongs There #
This is not an argument against agents. It is an argument against using one as an expensive HTTP client.
A model earns its place when the sequence of calls cannot be written in advance:
- Open-ended questions. "Which of my products lost organic rank this month, and does the ad spend explain it?" needs several tools, chosen based on what the earlier ones returned.
- Unfamiliar servers. Pointing an agent at a server nobody has integrated yet, to explore what is possible, is exactly what the discovery step is for.
- Turning structured data into prose. Fetch deterministically, then let the model write the summary, the listing copy, the report.
The clean architecture is usually both: deterministic code fetches, the model reasons over what came back. Fetching is not the interesting part of what a model does, and it is the part it does worst.
A worked example: Helium 10 has no REST API — its MCP server is the only way in walks through discovery, OAuth, quotas and the SSE parsing against a real server, end to end.
FAQ #
Can you use an MCP server without an LLM?
Yes. MCP is a JSON-RPC protocol with three methods you need — initialize, tools/list and tools/call. Any HTTP client can speak it. A language model is only useful when the choice of which tool to call has to be made at runtime.
Is MCP just an API?
Effectively yes, with a built-in discovery step. tools/list returns each tool's name, description and JSON schema from the server itself, so the schema cannot drift from the implementation the way a separate docs site can.
Do I need an MCP SDK?
No. A complete client for Streamable HTTP is around forty lines: POST JSON-RPC, handle the SSE-framed response, and send a bearer token. An SDK is convenience, not a requirement.
Why does my MCP response fail to parse as JSON?
Because Streamable HTTP servers often return server-sent-event frames even for a single response — event: message followed by a data: line containing the JSON. Extract the data line before parsing, and send Accept: application/json, text/event-stream.
How does authentication work for a remote MCP server?
Standard OAuth 2.1. The server publishes /.well-known/oauth-protected-resource naming its authorisation server, which in turn publishes its endpoints, supported grants and scopes. Many also support Dynamic Client Registration, so you can obtain a client ID programmatically without an application process.
When should I put a model in front of an MCP server?
When the sequence of tool calls depends on intermediate results, when you are exploring an unfamiliar server, or when you want prose generated from the data. For a scheduled pull with known arguments, calling directly is faster, cheaper and reproducible.
Related guides
Engineering
It Works on SQLite and Breaks on Postgres: The GROUP BY Trap
Your aggregate query passes every local test, you deploy, and Postgres rejects it with "column must appear in the GROUP BY clause" — pointing at a column that is very obviously in the GROUP BY clause. This is a real difference between the two engines, and with an ORM there's a specific way to trigger it that looks completely correct. Here's the mechanism and the one-line fix.
Read guide →Amazon Ads API
Amazon Ads API Rate Limits Explained (And How to Stop Hitting Them)
A 429 storm from the Amazon Ads API almost always means your retry logic is wrong, not that your limits are too low. Here's how the per-endpoint token buckets actually work, what the response headers tell you, and the request patterns that keep you comfortably under the limit.
Read guide →Amazon SP-API
The SP-API Reports Pattern Everyone Gets Wrong (Async, Step by Step)
The SP-API Reports API is asynchronous — request, poll, download, decompress — and most broken integrations either treat it like a synchronous call or poll it straight into a throttle. Here's the correct four-step flow, the status states that trip people up, and the gzip gotcha at the end.
Read guide →Don't want to build this yourself?
I set this up for sellers and agencies every week. Book a free 30-minute audit of your Amazon data & PPC setup — you'll leave with a plan either way, whether we work together or not.