The request arrived as a stack of voice notes. A non-technical stakeholder had three work inboxes — Gmail for one company, Outlook for another, a Zoho account for a third — and wanted them merged into a single view with an AI summary sitting on top, the way Gmail now shows a one-line "here's what this thread is about" before you open it.
Then, in the same thread, came the proposed solution: "This will just happen through MCP, no? Add the three connectors — Gmail, Zoho, Outlook — to Claude, and prompt it to consolidate. It'll figure it out internally."
That is half right, and the half that's missing is the entire engineering problem. You can absolutely add three mail connectors to Claude Desktop today. What you get is an assistant that can reach three inboxes. What you don't get is a consolidated inbox — because "consolidated" is an object that has to be built, and nobody built it. This article is about the thing that has to exist in the gap: one more MCP server, whose clients are the other MCP servers.
01Why "just add the connectors" gives you a demo, not a product
The connectors-only idea is reasonable, and it demos beautifully. You wire up the Gmail, Zoho and Outlook MCP servers, you ask "what's urgent across all my mail?", and the model dutifully calls each one, reads the results, and writes you a paragraph. Everyone nods. Then you use it for a week, and the cracks are all in the same place: there is no consolidated object, so the model rebuilds one from scratch every single turn.
- The three servers don't speak the same dialect. Gmail's MCP returns messages one way, Zoho's another, the IMAP server a third. One calls the operation
search_emails, anotherlist_messages. The model papers over this live, differently each time, and you feel it as inconsistency. - "All my mail" is never a thing that exists. Each turn, the assistant re-fans-out, re-reads, re-sorts, re-decides what "urgent" means. There is no ranked, de-duplicated, cross-account list to point at, cache, or schedule against.
- One flaky account poisons the whole answer. If the Zoho token expired this morning, the naive flow either errors out or, worse, quietly answers as if Zoho didn't exist — and doesn't tell you which.
- There's nowhere to hang the actual product. The summary, the categorisation, the urgency score, the draft replies — all of that needs a place to live, a cache so it isn't recomputed on every glance, and a scheduler so a digest can fire at 9am whether or not you're chatting. A prompt has none of those.
The tell is that every one of these is a state problem, and a prompt is stateless. The fix isn't a better prompt. It's to give the consolidation a home.
02An MCP server that is also an MCP client
Here is the whole idea in one sentence: build one MCP server that Claude connects to, and have that server be a client to the three provider MCP servers underneath it.
The Model Context Protocol — the open standard that lets an assistant like Claude call external tools — is usually drawn as a straight line: the app is a client, your server is the server, tools flow across. The move here is to sit a component in the middle that is both at once. To Claude Desktop it is a server exposing tools like inbox_summary. To Gmail, Zoho and the IMAP server it is a client, calling their tools. It is a hub.
That single architectural decision buys everything the connectors-only approach couldn't:
- A normalization layer. The hub fetches from each provider and coerces every wire format into one
NormalizedEmailshape — same field names, same date format, a single global id — so downstream code never branches on provider again. - A real fan-out. "List all urgent mail" becomes one call to the hub, which queries every account in parallel, isolates failures per account, and returns one sorted list.
- A place for the AI layer. Summarisation, categorisation, urgency, draft replies — all run in the hub, against normalized mail, with a cache in front so a second glance is free.
- One clean tool surface. Claude doesn't see thirty provider-specific tools of varying quality. It sees 17 unified tools — each of which works identically no matter which mailbox the email came from.
Crucially, this adds exactly one custom component. The provider MCP servers already exist and are maintained by other people; the hub reuses them as libraries-over-a-pipe rather than reimplementing IMAP or the Gmail API. The stakeholder's instinct — "don't build a mail backend" — was correct. They just stopped one server too early.
03The implementation
Start with the side facing Claude. The hub is a normal MCP server: it advertises its tools, and dispatches each CallTool request to a handler in a registry. This is the entire dispatch core — notice what's not here: any mention of Gmail, Zoho, or email at all.
server.setRequestHandler(CallToolRequestSchema, async request => {
const { name, arguments: rawArgs } = request.params;
const tool = toolsByName.get(name);
if (!tool) return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
const output = await tool.handler((rawArgs ?? {}) as Record<string, unknown>, ctx);
return { content: [{ type: 'text', text: output.text }], ...structured(output.data) };
});
The server side is a thin router. All the interesting work is behind ctx — and the most interesting part of ctx is that the same process is also running MCP clients. Each provider is wrapped in an adapter that opens a client to that provider's server, completes the handshake, and — this is the load-bearing line — asks the server what tools it has instead of assuming.
const transport = this.createTransport(); // stdio child, or an SSE/HTTP URL
const client = new Client(ORCHESTRATOR_CLIENT_INFO, { capabilities: {} });
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `${this.provider} connect`);
const { tools } = await withTimeout(client.listTools(), CONNECT_TIMEOUT_MS, `${this.provider} listTools`);
this.discoveredTools = tools.map(t => t.name); // e.g. ['search_emails', 'read_email', ...]
discoveredTools is the seam that makes the hub provider-agnostic — the adapter later maps a logical operation like "list emails" onto whatever this particular server happens to call it. (That mapping is its own rabbit hole.) The connect timeout is a generous 60 seconds on purpose: the Gmail server validates and refreshes its OAuth token on startup, which has been observed to take ~30 seconds.
The manager owns every adapter and does the fan-out. The one rule it never breaks: one account failing must never take down a whole-inbox query. That's Promise.allSettled, not Promise.all — the difference between "collect whatever succeeded" and "reject the moment anything does."
const results = await Promise.allSettled(
adapters.map(async adapter => {
await adapter.ensureConnected(); // self-heal a dropped account before using it
return op(adapter); // e.g. listEmails / searchEmails
}),
);
const all: NormalizedEmail[] = [];
results.forEach((r, i) => {
if (r.status === 'fulfilled') all.push(...r.value);
else mgrLogger.warn(`account ${adapters[i]!.accountId} failed`, { error: getErrorMessage(r.reason) });
});
return sortNewestFirst(all);
The last piece is the glue that makes cross-account addressing work at all: every email gets a global id of the form accountId:messageId. A message id is only unique within one mailbox; prefix it with the account and it's unique across all of them, and — because it's just a string with a colon — any tool can round-trip it back to "which account, which message" without a lookup table.
globalId: `${accountId}:${id}`, // "zoho-primary:18f2c…" → parseGlobalId() splits it back
04The surface it produces
The proof that the abstraction holds is the tool surface. Seventeen tools, and not one of them takes a "which provider" argument — the provider is an implementation detail the global id already carries.
| Group | Tools | What the hub does underneath |
|---|---|---|
| Inbox | inbox_summary, daily_digest, search_all, prioritize_inbox | fan-out → normalize → enrich → rank |
| Per-email | summarize_email, categorize_email, detect_urgency, suggest_actions, smart_reply, explain_email, extract_tasks | resolve global id → one provider → LLM |
| Batch | batch_categorize, batch_summarize, filter_by_category | concurrency-limited enrichment |
| Status / Schedule | account_status, configure_schedule, trigger_digest_now | connection health; cron digests |
The "1 custom component" figure is the one I'd defend hardest. The measure of this design isn't that it does a lot; it's that it does a lot by adding almost nothing — one hub, and four mail integrations someone else already wrote and tests.
05What broke
The representative one — the bug that first taught me the hub couldn't be naïve — was a connection that stayed "connected" forever after its child had died. The stdio transport to a provider is a child process, and child processes exit: an IMAP idle-timeout, a server crash, a laptop sleeping. The first version set connected = true on handshake and never revisited it. So the child would quietly die, the adapter would still report itself healthy, and every subsequent call threw Unexpected close — which the fan-out then dutifully logged as "this account failed," on a loop, until restart. From the user's seat, one inbox just silently vanished from every answer.
The other two — a thinking model that spent its entire token budget before writing any JSON (Part 3), and the fact that no two mail MCP servers name their tools the same — are the same shape of surprise: a thing that "just works" in the demo because the demo only does it once, cleanly, on a good day.
06What I'd do differently
Design the NormalizedEmail shape on day one, from three real payloads. I started from Gmail's format and bolted on the others, which is why the normalizer has grown a long list of fallback field names (from or sender or fromAddress…). Diffing three real responses first would have produced the same union with less archaeology.
Treat the global id as public API immediately. It leaks into tool arguments, cache keys, logs, and the notifier's dedup set. Pinning accountId:messageId as a contract early — including "account ids never contain a colon" — would have saved a round of "wait, which half is which."
The hub pattern itself I'd reach for again without hesitation. Any time you're tempted to ask one assistant to juggle N similar tools by prompt, the question to ask is whether the consolidation deserves to be an object. For anything you'll use more than once, it does.
The transferable lesson
- To put N services under one assistant, build an MCP server that is itself an MCP client — a hub that normalizes, fans out, and re-exposes one tool surface.
- "Consolidated" is an object with state (cache, ranking, schedule); a stateless prompt can't be that object.
- Fan out with
allSettled, notall— one dead account must not zero the whole answer. - A composite global id (
accountId:messageId) makes cross-account addressing a string operation, not a lookup table.
07The series
This is Part 1 of a build log on a multi-account email agent. The connection-lifecycle and model-budget problems each earned their own part: