Background
On our team, we have been building skills for very specific tasks that call various endpoints. Some skills call the same endpoints, and many people maintained these endpoints. Some people pulled a skill to their local machine, made updates, and never pushed them back to our skills repo.
I was looking into building an MCP server for these skills, but then I read about Uber's MCP Gateway and its virtual MCP servers. It behaves like an API gateway, and it lets us add a new endpoint for the rest of the team with a short config change. No need to hand-build an MCP server for each set of functions.
High Level Overview
The virtual MCP servers follow the same pattern as an API gateway. With an API gateway, clients call the gateway, and the gateway routes the request to the right backend service. The client doesn't need to know where the service lives or how to call it.
An MCP gateway behaves the same way. Your local AI agent calls the MCP gateway, and the gateway makes the call to the right backend service.
The servers are "virtual" because none of them is a program. Normally every system an agent talks to gets its own MCP server, written, deployed and kept in sync with the service it fronts. In the gateway, a virtual server is a config entry: a name, a backend URL and a list of tools. One process serves all of them, each at its own URL, and to the agent each one looks like any other MCP server.
I built a small version as a proof of concept. It runs on my laptop against our QA environment and fronts seven internal services, such as users, products and orders. None of the backend services had to change.
one gateway process +-------------------------------+ | | Claude Code ----->| /shop/mcp (reads) |----> users, products, orders, ... | /shop-write/mcp (writes) |----> users, orders | | | /users/mcp |----> users | /products/mcp |----> products | /orders/mcp |----> orders | ...one URL per service | | | | registry/*.yaml defines all | +-------------------------------+ Technical
The registry
Uber splits its gateway into a registry, which is the control plane, and a proxy that does the runtime work. Mine keeps the same split inside one process. The registry is a folder of YAML files in a git repo:
- One file per backend service. Each file becomes a virtual MCP server at
/<service>/mcp. - Composite files. It lists tools from the service files by reference, like
orders.get_order, so a developer registers one URL and gets everything it includes. Each tool is still defined in exactly one place.
Every file is checked against a strict schema when it loads. A file watcher reloads the folder whenever a YAML file changes. If the new file is invalid, the gateway logs the error and keeps serving the previous registry.
What a tool looks like
A tool maps one MCP tool onto one existing REST endpoint. This is a complete definition:
name: orders title: Orders (QA) enabled: true instructions: Order data from the orders service. userId must be the order's owner. backends: orders: http://orders.qa.internal tools: - name: get_order enabled: true title: Order details description: Order details for an order id. input: orderId: { type: string } userId: { type: string, description: "The user who owns the order" } region: { type: string, default: us-qa } request: backend: orders method: GET path: /order/details/{orderId} headers: x-identity: '{"userId":"{userId}","region":"{region}"}' response: error: { path: status.code, okValue: 0, message: status.message } unwrap: result -
inputbecomes the schema the agent sees, with types, defaults and descriptions. -
{orderId}in the path is filled from the argument and URI-encoded. - The identity header is a template too. Values are JSON-escaped when they go in, so an argument can't break out of the header's JSON and add fields the registry never granted.
-
errorandunwraphandle services that return errors as HTTP 200 inside an envelope. Theerrorrule says how to spot one (here,status.codemust be 0), andunwrappulls the payload out.
What happens on a call
- Route. The agent POSTs an MCP request to
/<server>/mcp. An unknown or disabled server gets a 404. - Build. The gateway builds an MCP server in memory from that server's enabled tools, for this one request. It runs stateless, so nothing is kept between requests and a YAML edit applies on the next call. Building the 46-tool composite takes about a millisecond.
- Validate. The MCP SDK checks the arguments against the tool's schema and fills in defaults before anything leaves the gateway.
- Call. The handler builds the backend request from the tool's
requestblock, adds a request id header so the call can be traced in the logs, and makes one HTTP call with a 15-second timeout. - Interpret. It checks the status and the error rule, applies
unwrap, and trims the result to the fields the agent asked for. - Log. It writes one JSON log line: server, tool, backend, status, duration, request id, and whether the call failed.
Keeping responses small
Everything a tool returns lands in the agent's context, and some of our responses are large. Every read tool takes an optional fields argument: dotted paths, with [] for arrays. Without it, the agent gets the whole response.
A response over 23,000 characters is never handed to the agent, and it isn't truncated either. The agent gets an error listing the response's field paths. Shortened, it looks like this:
Result is 24310 chars, over the 23000 limit. Call again with "fields" set to the paths you need. Available fields: items[].id, items[].status, items[].total, items[].createdAt, items[].lines[].productId, ... The agent picks the paths it needs and calls again. That costs a second backend call, but only for large responses.
Disabled by default
I borrowed Uber's rule that nothing is exposed until someone decides to expose it. enabled is a required key on every server and every tool, so there's no implicit "on":
- A disabled tool doesn't show up in the agent's tool list.
- Disabling a tool in its own file removes it from every composite that includes it.
- Disabled entries are still validated, so a broken definition is caught when it lands, not on the day someone turns it on.
Turning a tool on is meant to happen in a reviewed merge request.
Reads and writes
The first version was read-only. Later I added three write tools that set up test data: create a test user, create a draft order, and confirm it. Before, that setup took a curl, a script and a service running locally.
Writes are kept apart:
- The
writeflag. A tool that writes is markedwrite: trueand is advertised to the agent as not read-only. Every other tool is advertised as read-only. - Separate composites. A composite is all reads or all writes, and a file that mixes them fails to load. The read composite stays read-only, and a developer only gets the write tools by registering the write composite on purpose.
- No
fieldson writes. Write tools always return the full result, so a filter can't hide the id of something that was just created. The one exception is a result over the size cap: the agent then gets an error saying the write already happened and not to call the tool again. - Retries. Confirming an order isn't idempotent, so that tool gets a longer timeout, and its description tells the agent to check whether the order exists before retrying.
Automatic API Route retrieval
The registry describes endpoints that live in other repositories, so it drifts.
Uber handles this with AutoCrawler. It scans their IDL registry (Protobuf and Thrift) on a schedule, generates tool definitions and schemas, has an LLM write the descriptions, and adds everything to the registry disabled.
We don't have a central IDL registry. For two services I wrote a generator that drafts the YAML from their live OpenAPI spec: one tool per GET all disabled except a few I enabled by name. I wrote the rest by hand.
I also looked at using the specs to detect drift, but they weren't complete. One service's spec left out four routes, another's spec URL returned a 400, and one service has no spec at all. So the drift check reads source code instead. pnpm scan does this for each service:
- Check the commit. Ask the GitLab API for the commit at the tip of the default branch. If it matches the commit in the service's baseline file, print
unchangedand move on without downloading anything. - Download the controllers. Otherwise, download an archive of just the controller directories at that commit. It's read as text and never executed.
- Extract the routes. There's one extractor per framework. We have Express, NestJS, Spring and JAX-RS services, so there are four. Each route comes out as method, path, declared query parameters, and the file and line that declare it.
- Normalize and diff. Paths are reduced to their shape, so
/order/details/:orderIdand/order/details/{orderId}both become/order/details/{}. The result is diffed against the baseline. - Check the registry. Every tool in that service's YAML is matched against the live routes, so a tool pointing at a route that no longer exists gets flagged.
An illustrative report:
products master a1b2c3d → 4e2a9c1 baseline accepted 2026-10-02 + GET /products/{}/reviews src/main/java/.../ProductResource.java:812 - POST /products/lookup tools: find_products (enabled) ~ GET /products/search query: q, category → q, category, brand tools with no matching route: find_products products: 1 added, 1 removed, 1 changed + is a new route with the line that declares it, - is a route that's gone with the tools that point at it, and ~ is a change to declared query parameters. Exit code 0 means there's nothing to look at, 1 means there are findings, and 2 means a service couldn't be scanned. Once the YAML is fixed, --accept records the new baseline, and it's committed together with the YAML change.
There are two things it can't see:
- Query parameters in Express services. They read query parameters inside the handlers instead of declaring them, so a query change there doesn't show up.
- Request and response shapes. It only knows routes, so a renamed field in a response stays invisible until a tool is actually called.
For now I run it by hand. The plan is a nightly pipeline that runs the scan and opens a merge request:
- new routes get draft tool entries, disabled;
- tools whose route disappeared get switched off.
The team reviews and merges it. That's the same gate as any other tool change, with a machine preparing the diff.
Differences between my version and Uber's
| Uber | Mine | |
|---|---|---|
| Registry | A service that acts as the control plane | A folder of YAML files in git |
| Scale | 800+ servers, 5,000+ tools | 7 services, 282 tools, 49 enabled |
| Runtime | A gateway that refreshes its config from the registry on a fixed cadence | One local process; a file watcher, and a fresh MCP server per request |
| Backends | HTTP, gRPC and TChannel | HTTP only |
| Discovery | AutoCrawler scans the IDL registry on a schedule | OpenAPI generator for two services, hand-written for the rest, plus a route scan for drift |
| Descriptions | Written by an LLM, reviewed by the owning team | Written by hand |
| Approval | The owning team approves a config diff, with rollback | A merge request, by convention for now |
| Reaching many services | Omni MCP, one server with tools to discover, inspect and invoke other servers' tools | Composites, a fixed list of tools under one URL |
| Choosing fields | Response Projection: the LLM picks fields on each call | Same idea, and an oversized response lists the paths it has |
| Auth and PII | The gateway enforces authorization and redacts PII | Identity is built from tool arguments, no redaction, QA only |
The biggest design difference is composites versus Omni. With 5,000 tools, no agent can hold the whole list, so Uber gives it a tool to search for tools at runtime. With 49, a fixed list fits in the agent's tool list and is simpler to reason about. That stops working as the registry grows.
Example
A typical question used to mean finding the right service, building the identity header by hand and running curl. Now it's a sentence in Claude Code, for example:
Why is order 1234567 for user 98765 still pending?
Claude Code calls get_order with the order and user ids, then get_user for the same user to check the account, and compares the two. Each call is logged with a request id that can be followed into the backend's logs.
Large responses take one extra step. Say list_orders for a busy user comes back at about 24,000 characters. The first call returns the error with the response's field paths, and a second call with four of them (items[].id, items[].status, items[].total and items[].createdAt) returns a short list of that user's orders.
Setting up test data works the same way with the write server registered:
Create a test user, create an order for one unit of product SKU-123, confirm it, then get the order.
The agent chains five tools across both servers: create the user, create the draft order, read the draft in full, confirm it, and read the confirmed order back. That one sentence replaces the curl, the script and the local service.
What I learned
My first version had a hand-picked default field list per tool to keep responses small. Nothing kept those lists current. When a service renamed a field, it quietly disappeared from results, with no error. I dropped them. Now the agent asks for what it needs, and when a response is too big, the error lists the fields that actually exist. That list comes from the live response, so it can't go out of date.
The HTTP method doesn't tell you what a call does. A GET that consumes a sequence number, a GET that creates a user, a POST that only reads. Whether a tool is a read or a write has to be a reviewed decision, stored as a flag.
Conclusion
The part worth copying from Uber is that exposing an endpoint to an agent is a config change with a review, not a new service. That's what our skills were missing. Each endpoint is now described once, in one file, and every skill and every developer gets the same definition. When the endpoint changes, the scan points at the file to fix.
