We use AskElephant to record client calls, and we work in claude.ai. Those two things could not talk to each other, and the reason is structural rather than a missing feature.
AskElephant ships an MCP server. It runs locally over stdio, which serves Claude Desktop, Cursor, VS Code and Windsurf. A browser cannot spawn a process on your laptop, so claude.ai needs an MCP server at an HTTPS address. That is a different program.
Building it took about a day. Working out what it should refuse to do took considerably longer, and that part generalises to any MCP server sitting in front of a large corpus. The code is on GitHub.
The arithmetic that determines the design
Before writing tools, measure your payload. We measured every transcript in our account: 3,706 engagements, 2,230 of which carry one.
| Characters | Approx tokens | |
|---|---|---|
| Average transcript-bearing call | 32,485 | 8,100 |
| Median | 28,240 | 7,060 |
| p95 | 75,543 | 18,885 |
| Largest | 175,643 | 43,900 |
The largest single call is more than a fifth of a 200,000-token context window on its own.
Now consider the actual user request: "find where we discussed pricing with this client this year." That touches maybe forty calls. The naive tool returns forty transcripts, which is roughly 325,000 tokens. It does not fit. Not slow, not costly: impossible.
So the design constraint is not the API. It is arithmetic, and it arrives before you write a single tool definition.
Do the reading on the server
The whole design collapses to one line:
The Worker does the reading. Claude does the thinking.
search_transcripts fetches the candidate transcripts, scans them inside the Worker, and returns only matching passages with speaker and timestamp attached. A real forty-meeting search, run through claude.ai against the live archive, returned 76,757 characters of excerpt: about 19,200 tokens rather than 325,000.
The scanner is a pure function with no IO, which makes it trivial to test:
export function scanTranscript(
text: string,
queries: string[],
opts: ScanOptions = {},
): Hit[]
Transcript lines arrive as [0:03:15] Speaker Name: utterance, so matching per line rather than per character means every hit carries its own timestamp and speaker for free. Excerpt windows snap outward to line boundaries, so an excerpt never begins mid-sentence.
Two details that matter more than they look:
Match literally, never by regex. Query terms come from a language model, and a model will happily emit 5.00 or C++. Building a RegExp from that input is both a correctness bug and an injection hazard. A test pins it: 5.00 must not match 5x00.
Keep offsets against the raw string. The chunked transcript reader slices the cached text by the offsets the scanner produced. We later stripped carriage returns from excerpt output, and the only safe way to do that was in the returned text, never by normalising the source, because normalising shifts every index. The test asserts both that the excerpt is clean and that the offset still points into the unmodified string.
The bug worth stealing: limits multiply
We had three limits, each defensible on its own:
-
max_meetings: 40 -
max_hits_per_meeting: 10 -
context_chars: 2000
Each is validated independently. Nothing validated their product, which measured at about 229,000 tokens. The caps existed to protect a 200,000-token window and their worst case overran it.
Two things compounded it. Excerpts snap outward to line boundaries, so a request for 2,000 characters yields about 2,297. And there is no overlap dedup, so ten hits inside one dense passage return substantially the same text ten times.
The fix is a single budget across the whole run, in config rather than scattered through the code:
searchOutputCharBudget: 120000, // about 30,000 tokens
Enforced with one accumulator, admitting hits one at a time, and stopping the fetch loop entirely once spent. Critically, the response says which happened:
Scanned 12 meeting(s). Results were CUT FOR SIZE at the output budget.
A caller must be able to tell a cut for size from a cut for relevance. They mean opposite things and look identical if you do not say.
The bug worth stealing more: silence is not absence
Originally, a failed transcript fetch was logged and skipped. Nothing about the failure reached the return value.
Play that forward. The API key gets rotated. All forty fetches 401. The tool answers:
Scanned 40 meeting(s). No matches. Try different phrasings in queries, or widen the date range.
A person reading that concludes the topic was never discussed. That is worse than an error, because an error is obviously an error. This is a confident wrong answer with a suggestion attached.
The fix is counting, and reporting:
{ scanned: 40, failed: 40, no_transcript: 0, results: [] }
and rendering "40 could not be read" instead of "No matches". If your tool can return an empty result for more than one reason, it has to say which reason.
Five API behaviours that each cost us a bug
AskElephant's v2 API is not publicly documented. Every one of these was found by probing, and most were found only because something already broke.
0. Some transcripts contain the same call twice. Listed first because it is the one that survived everything else. The transcript field can hold the whole call, an attachment marker, then a character-for-character second copy:
[0:00:25] Alice: Hi, Bob.
... the entire call ...
[0:29:08] Bob: See ya.
[Attachment eatt_01KYAH5JQ4C31VVE2PE797AP69: Bot Recording]
[0:00:25] Alice: Hi, Bob.
... the entire call again, identical ...
Measured on the call that exposed it: 59,088 characters, opening line recurring at offset 29,575, second segment identical across all 29,513 of its characters. The reliable signature is a backward jump in timestamps — the transcript runs to 0:29:08 and the next line is 0:00:25.
Search was therefore finding every match twice, paying the output budget for each passage twice, and filling the per-meeting hit cap with duplicates. The fix drops a segment that exactly repeats one already kept, and preserves near-duplicates and genuinely different second recordings, because losing a real utterance is far worse than wasting characters.
There is a second, sharper lesson in how we sized it. The first measurement said half the corpus was affected. It was taken over the transcripts the Worker had cached — which are the meetings our searches happened to touch, skewing recent, internal and bot-recorded, exactly the population that duplicates. Measuring your cache measures your query history. Re-measuring all 2,230 transcripts put it at 5.8%, about a million wasted tokens archive-wide.
1. The auth header takes the raw key, no Bearer prefix. Cheap to fix, easy to lose an hour to.
2. processing_status is PENDING on every record ever created, including calls from 2023. We had built the initial filter around COMPLETED, which returns zero rows for everything. Transcript availability actually tracks engagement_type: calendar entries never carry one.
3. search matches titles only, never transcript bodies. This is the reason the excerpt scanner exists at all. We got this wrong twice. First we assumed it searched bodies. Then we "verified" titles-only using the words pricing, leadership and onboarding, which are exactly the words that also appear in meeting titles, so the evidence was consistent with both behaviours and distinguished neither. The proof that worked was a phrase spoken mid-call that appears nowhere in the title, run against a 2023 call so that index lag could not explain a miss.
4. Filters need operators, and dates need full UTC. filter[company_ids]=<id> returns 400; it needs filter[company_ids][in]. And filter[start_at][gte]=2026-01-01 returns 400 too:
must be an ISO8601 UTC date (e.g. 2025-01-01T00:00:00.000Z)
Both shapes came from our own earlier notes and neither had been sent to the live API. The tool now widens a bare YYYY-MM-DD itself, to start of day for a lower bound and end of day for an upper bound, because a model asked for "this year" will emit the plain form every time.
The pattern across all five: the plan described the API, and nobody had asked the API. If you take one process change from this post, make it a step that probes every request shape you actually send, once, before you trust it — and then a second step that runs the finished thing against real data, because number 0 passed every review gate and fell out of the first real question a human asked.
What Cloudflare gave us
@cloudflare/workers-oauth-provider handles the part everyone gets stuck on. Claude.ai initiates OAuth 2.1 with Dynamic Client Registration, and this library makes the Worker its own OAuth server while delegating the actual login upstream, in our case to Cloudflare Access over OIDC with PKCE.
export default new OAuthProvider({
apiRoute: "/mcp",
apiHandler: McpAskElephant.serve("/mcp"),
defaultHandler: { fetch: accessHandler },
authorizeEndpoint: "/authorize",
tokenEndpoint: "/token",
clientRegistrationEndpoint: "/register",
});
McpAgent handles Streamable HTTP and session state on a Durable Object. KV caches transcripts, which are immutable once processed, with a TTL, because a cache without one is a permanent second copy of client data living outside the vendor's retention controls.
One operational trap worth knowing. Each scanned meeting costs about three subrequests, so the default of 25 meetings costs roughly 76. The free plan caps at 50, so a free-tier deployment breaks at about 15 meetings, not at the 40 the config implies. The paid plan allows 1,000.
The security thing I got wrong in public
We first restricted access by email domain, reasoning that revocation could be delegated upstream: everyone uses a company-controlled Claude account, so deprovisioning that account takes the connector with it.
That reasoning is wrong, and we proved it by accident. The connector can be attached to any Claude account, including a personal one, because the only identity the Worker ever sees is the Cloudflare Access identity. Our own testing left two separate Claude clients registered against one address.
Access is now an explicit list of named individuals. And because removing someone from that list only stops them getting a new grant, there is a script that deletes an existing one, since a grant in KV outlives any config change until it expires.
If you are building anything similar: work out what your revocation story actually is, then test it, rather than reasoning about it.
Worth saying about the process
This was built with Claude Code, task by task, with a separate review pass on each one that had no attachment to the code it was reviewing.
The interesting part is where the defects came from. Almost all of the real ones originated in the plan, not the implementation: the filter shape, the date format, a row limit applied per search term so a two-term query could return double what was asked for, and a hand-written type for a contact's email field that disagreed with the API while agreeing perfectly with the test fixture invented alongside it.
The implementations were mostly faithful. The specification was confidently wrong in four places. That ratio is the argument for adversarial review, and it is not the argument the tooling vendors usually make.
Repo, with setup instructions for your own deployment: github.com/meticulosity-dward/askelephant-mcp
Top comments (0)