Wiring up hundreds of tools with MCP: the connector-hub design
An agent is only useful if it can touch external tools. Here's the structure of connector-hub — how vooy standardizes dozens of SaaS integrations behind MCP — and the traps we hit along the way.
On this page
An agent's value comes down to what it can actually do: book a calendar slot, read a Notion doc, open a GitHub issue. But the moment you start wiring tools in one by one, the number of integrations grows multiplicatively. This is the story of how vooy squeezed that explosion into a single layer called connector-hub.
The problem: tools explode
At first we thought we only needed three: Google, Slack, Notion. Six months later we were managing 30+ integrations, each with its own auth scheme, rate limits, error formats, and pagination rules.
Had we baked each integration directly into the agent code, the model loop would have drowned in per-service branching. So we slipped a layer in between.
MCP as the common denominator
The Model Context Protocol standardizes how tools are exposed to a model. We decided to implement every connector as an MCP server. Internal tools, external SaaS wrappers — they all speak the same protocol.
const client = await mcp.connect({
transport: "streamable-http",
url: connector.endpoint,
auth: await vault.tokenFor(session.user, connector.id),
});
const tools = await client.listTools();
// Every connector returns a tool list of the same shape.The key payoff: the agent runtime never has to know about connectors. The runtime sees only an MCP tool list; it doesn't care whether a tool is Notion or GitHub.
The structure of connector-hub
connector-hub does three things:
- track which connectors are active in a session
- pool the MCP server connection for each connector
- inject per-user auth tokens safely
Auth isolation
This is the riskiest part. If user A's token leaks into user B's tool call, it's game over. So tokens are never exposed to connector code directly — they're pulled from the vault at call time and injected request-scoped only.
async function invoke(call: ToolCall, session: Session) {
const connector = registry.resolve(call.toolName);
const token = await vault.tokenFor(session.user, connector.id);
// The token exists only for the duration of this call, then is discarded.
return withScopedAuth(token, () => connector.client.call(call));
}Schema normalization
Every SaaS responds differently. The same "user" concept comes back as user.real_name in Slack and names[0].displayName in Google. We put a thin normalization adapter behind each connector so that output returning to the model always has a consistent shape. Letting the model learn each service's response structure is wasted tokens and a source of errors.
The cost of exposing tools to the model
Here's the counterintuitive lesson: the more tools you expose, the dumber the agent gets. Throwing every active connector's tools at the model at once burned half the context on tool definitions alone, and selection accuracy dropped.
So we split it into two stages.
- Narrow to relevant connectors by intent first (retrieval + heuristics)
- Expose only the narrowed connectors' tools to the model
| Strategy | Avg. tools | Selection accuracy |
|---|---|---|
| Expose everything | 140+ | 71% |
| Pre-narrow connectors | 12 | 94% |
What we learned
Adding an integration is easy. Making integrations consistent is hard.
MCP wasn't a silver bullet, but enforcing the single invariant "every tool speaks the same protocol" was worth it on its own. On top of that, auth isolation, schema normalization, and tool pre-narrowing all became problems you solve once, independent of any connector.
Adding the next tool now takes under a day. That's the whole reason this layer exists.