ALL WRITING
Article · The Email Orchestrator · Part 1

One Inbox, Three Mailboxes, Zero New APIs

A stakeholder wanted Gmail, Zoho and Outlook merged into one AI-summarised inbox. The suggested fix — "just add the three MCP connectors to Claude" — gets you a demo. Here's the missing half: one MCP server that is also an MCP client.

Model Context ProtocolAI AgentsTypeScriptFan-outEmail

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, another list_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.

Claude Desktop MCP client email-orchestrator server AND client normalize · fan-out · enrich Gmail MCP server Zoho MCP server IMAP server (Yahoo/Outlook) MCP
The hub is a server to Claude and a client to every provider. One custom component; the provider servers are reused as-is.

That single architectural decision buys everything the connectors-only approach couldn't:

  1. A normalization layer. The hub fetches from each provider and coerces every wire format into one NormalizedEmail shape — same field names, same date format, a single global id — so downstream code never branches on provider again.
  2. 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.
  3. 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.
  4. 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.

orchestrator/server.ts — the server side
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.

providers/provider-adapter.ts — the client side
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."

providers/provider-manager.ts — the fan-out
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.

GroupToolsWhat the hub does underneath
Inboxinbox_summary, daily_digest, search_all, prioritize_inboxfan-out → normalize → enrich → rank
Per-emailsummarize_email, categorize_email, detect_urgency, suggest_actions, smart_reply, explain_email, extract_tasksresolve global id → one provider → LLM
Batchbatch_categorize, batch_summarize, filter_by_categoryconcurrency-limited enrichment
Status / Scheduleaccount_status, configure_schedule, trigger_digest_nowconnection health; cron digests
1
custom component — the provider servers are reused as-is
4
providers behind one surface: Gmail, Zoho, Yahoo, Outlook
17
unified tools (asserted in the e2e test)
49
tests across 8 files, all passing

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 architectural cost of consolidationThe moment you make your orchestrator an MCP client, you inherit the full lifecycle of every connection you open. The connectors-only approach never has to think about this because Claude Desktop owns those connections. Move consolidation into your own server and you've adopted them all. (The self-healing fix is Part 2.)

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, not all — 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:

Part 2 · Keeping it alive Part 3 · The token bug
Nirmit K. Tripathii
Nirmit K. Tripathii — AI/ML Engineer & Football Data Scientist
Three years building NLP, agentic AI and multimodal systems in production. I write up real projects — the architecture, the numbers, and the parts that failed. Available for part-time, consulting and research collaboration (remote).