The first version of our MCP server had one tool per endpoint. It is the obvious design — the API already has a shape, so mirror it — and it works fine until it doesn't. Ours stopped working somewhere around twenty-five tools, well before we ran out of endpoints to add. This is a writeup of what broke, what we replaced it with, and which of those decisions we are least sure about.
The problem with one tool per endpoint
Three things go wrong at once, and they compound.
- 1.Every tool definition is resident context. Name, description, and full parameter schema for each tool sit in the model's window for the entire conversation, before the user has asked anything. Fifty of them is a meaningful tax on every single turn, paid whether or not any get called.
- 2.Selection accuracy degrades. A long flat list of similarly-named tools is a harder discrimination problem than a short one, and the failure is quiet — the model picks a plausible neighbor rather than saying it is unsure.
- 3.A missing tool reads as missing data. This was the expensive one. If an agent sees no tool for port throughput, the honest conclusion available to it is that we do not have port throughput. It tells the user that, confidently, and the user believes it.
That third failure is what forced the rewrite. We were not losing to a competitor's data — we were losing to our own agent's belief about our data. And the fix could not be "add more tools," because more tools is what caused it.
Decision 1: make discovery a first-class step
Our API already publishes a catalog: a machine-readable directory of every endpoint with its path, summary, description, parameters, the trading signal it carries, and a worked example. It existed for humans reading the docs. It turns out to be exactly what an agent needs, and turning it into the backbone of the MCP server meant the server no longer has to enumerate anything.
Three tools sit on top of it. find_data takes a plain-English question and returns ranked candidate endpoints. describe_data takes one path and returns its parameters and an example. get_catalog dumps the directory when the agent wants to browse rather than search. The flow we teach in the server instructions is find, then describe, then call.
The property that matters most here is not ergonomic, it is operational: the catalog is the single source of truth, so shipping a new data source means adding a catalog entry upstream and nothing else. The MCP server picks it up on its next cache refresh. No release, no new tool, no drift between what the API serves and what agents believe it serves. Before this, every new source needed a matching tool written here, and the two lists disagreed roughly all the time.
Decision 2: keyword ranking, not embeddings
find_data has to turn "which senators traded defense stocks last quarter" into a ranked list of endpoints. The default 2026 answer is to embed the catalog, embed the question, and take a cosine similarity. We ranked with weighted keyword overlap instead, and we would make the same call again for this size of problem.
- Fifty-eight entries is not a retrieval problem. It is a scoring problem over a list that fits in memory, and the ceiling on quality is the catalog copy, not the similarity metric.
- Embeddings mean an inference dependency in the hot path of a tool that is supposed to be instant and free. That is a vendor, a latency budget, a failure mode, and a bill.
- Deterministic scoring is debuggable. When a query ranks the wrong endpoint first, we can see exactly which token did it and fix the catalog copy — which improves the human docs at the same time.
The scoring has two details worth stealing. First, fields are weighted rather than concatenated into one bag of words: a match in the summary counts four times a match in the body text, because a word in a one-line summary is far more indicative than a word buried in a paragraph. Second, we weight each term by inverse document frequency. "Filings" appears in the SEC, lobbying, and 13D entries alike, so on its own it should barely move a ranking; "13D" appears in one, so it should dominate. Without IDF, the common domain vocabulary drowns out the one word in the question that actually identifies the endpoint.
Tokenization also earns its keep in finance, where the most identifying strings are the ones a naive tokenizer destroys. We keep internal hyphens and dots together so 10-K, 13D, H-1B, and days-to-cover survive, and we additionally index a collapsed variant so "h1b" and "H-1B" both hit. The minimum token length is two, not the usual three, for the same reason: "10k" is short and enormously informative.
We deliberately did not build a domain stoplist. Dropping words like "ticker" and "company" is tempting, and it silently breaks the queries where those words are the entire question — "what ticker is nvidia" being the obvious one. IDF already suppresses common terms in proportion to how common they actually are, which is the same thing a stoplist does, except it cannot be wrong about it.
Decision 3: discovery is free
find_data, describe_data, and get_catalog consume no quota. They answer out of a process-local cache of the catalog, refreshed every fifteen minutes, so they cost us one upstream request per interval no matter how many agents are searching.
This is the incentive design, not a giveaway. If searching costs the same as querying, a rate-limited agent should guess instead of searching — and a guessed path is a failed call, a confused retry, and quite often a wrong answer to the user. Making discovery free means the cheapest strategy and the correct strategy are the same one, and we say so explicitly in the tool descriptions so the model can reason about it.
Decision 4: one envelope, always
Every tool returns the same two-key envelope: the raw API JSON under data, and the caller's rate-limit state under rate_limit.
{
"data": { "symbol": "NVDA", "trades": [ ... ] },
"rate_limit": { "limit": 500, "remaining": 417, "reset": "2026-09-14T00:00:00Z" }
}Handing the agent its own remaining budget on every call lets it self-throttle across a long research task instead of discovering the limit as a 429 halfway through. For the free discovery tools rate_limit is null rather than absent — same shape, explicit signal, nothing for the client to special-case.
Decision 5: keep a few named tools anyway
The purist version of this design has exactly one execution tool. We kept six named shortcuts — symbol search, SEC filings, insider trades, financials, congressional trades, news sentiment — and each is literally a call_api with the path pre-filled. Three reasons, none of them about the model's capability:
- Clients render tools in a picker. A user scrolling a list of one generic tool learns nothing about what they just connected.
- Enterprise callers allowlist by tool name. A single generic tool is all-or-nothing to a security reviewer.
- The common path should cost one round-trip, not three. Nobody needs to search for how to look up a ticker.
The rule we wrote down to stop this list growing back: a new data source adds a catalog entry upstream and never a tool here. Shortcuts are for paths that a research task almost always starts from, and that set is small and stable.
Decision 6: validate the path, because the token rides along
A generic call_api takes a path from the model, and the caller's bearer token is attached to every upstream request. If a path can escape our host, the key escapes with it — and a prompt-injected page telling an agent to "check this URL" is a realistic way for that to happen, not a hypothetical one.
The sharp edge is that HTTP clients generally ignore their configured base URL when handed an absolute one. A path of "https://elsewhere.example/collect" is not a relative path that fails to resolve; it is a working request to someone else's server carrying the user's credentials. So call_api rejects anything containing a scheme or starting with a double slash, rejects traversal segments, and requires the result to start with the API's version prefix. Batched sub-requests go through the same check — a validation applied at one entry point and not the other is the same as no validation.
Decision 7: let the agent file bugs
There is a submit_feedback tool, and the server instructions tell the model to call it whenever a response looks stale, missing, wrong, or malformed compared to the primary source. It takes a category, a severity, and what the caller expected versus what it got.
An agent cross-checking our data against a filing it is also reading is a better data-quality monitor than our own validation suite, because it is looking at the specific rows a human actually cared about today. The reports land in our triage queue. A surprising number are real.
The unglamorous one: pin your SDK
Our requirements file said the MCP SDK at version 1.2 or greater. The 2.x release renamed the server class and moved two module paths, so the next clean build — which for us meant the next deploy, since the platform builds from scratch — installed a major version we had never imported against, and the server failed at import time. The dependency range was written when 2.x did not exist, which is exactly when every unbounded range is written. Upper-bound anything whose import surface you depend on.
What we would still change
Ranking quality is bounded by catalog copy, which means the catalog is now load-bearing prose and has to be maintained as such — we have already gone back and rewritten entries in the vocabulary people search with rather than the vocabulary the upstream publisher uses. Long-tail queries that share no words with any entry still return nothing useful, and that is the case where embeddings would genuinely help; if the catalog reaches a few hundred entries we will revisit it. And the three-step flow costs two extra round-trips on a cold question, which is cheap in quota and not free in latency.
The headline result is still the one that made this worth doing: the agent stopped telling users we don't have data we have.
{
"mcpServers": {
"hedgefriend": {
"url": "https://mcp.hedgefriend.dev/mcp/",
"headers": { "Authorization": "Bearer hf_..." }
}
}
}The free tier covers 500 requests a day — enough to reproduce anything in this post.
Get a free key