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.
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 |
+-------------------------------+
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:
/<service>/mcp. 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.
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
input``{orderId}`` error and unwrap`` status.code must be 0), and /<server>/mcp. An unknown or disabled server gets a 404. request block, adds a request id header so the call can be traced in the logs, and makes one HTTP call with a 15-second timeout.
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.
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":
Turning a tool on is meant to happen in a reviewed merge request.
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:
write flag.write: true and is advertised to the agent as not read-only. Every other tool is advertised as read-only.fields on writes.
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:
unchanged and move on without down anything./order/details/:orderId and /order/details/{orderId} both become /order/details/{}. The result is diffed against the baseline.
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:
For now I run it by hand. The plan is a nightly pipeline that runs the scan and opens a merge request:
The team reviews and merges it. That's the same gate as any other tool change, with a machine preparing the diff.
| 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.
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.
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.
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.