DEV Community

Cover image for Virtual MCP Servers: How Config-Only API Exposure Turns Internal Services Into Agent Tools Without Writing Code
mech.app
mech.app

Posted on Originally published at mech.app

Virtual MCP Servers: How Config-Only API Exposure Turns Internal Services Into Agent Tools Without Writing Code

Most teams building agentic systems hit the same wall: every internal API needs a custom MCP server. You write TypeScript, define tools, handle auth, deploy, and repeat for the next service. Virtual MCP servers flip this pattern. They treat API specs (OpenAPI, GraphQL schemas) as MCP tool definitions, turning a config change into an agent-callable endpoint.

The pattern comes from Uber's MCP Gateway and mirrors API gateway behavior. One process serves multiple virtual servers, each fronting a backend service. The agent sees a standard MCP server. The backend sees a standard HTTP request. No custom server code required.

The Adapter Pattern

A virtual MCP server is a runtime adapter. It reads an API definition, generates MCP tool schemas, and proxies requests. The flow:

  1. Agent calls MCP gateway at /users/mcp
  2. Gateway looks up the virtual server config for users
  3. Gateway reads the OpenAPI spec from the backend or a local file
  4. Gateway transforms OpenAPI paths into MCP tool definitions
  5. Agent invokes a tool like getUser(id: string)
  6. Gateway translates the tool call into GET /users/{id}
  7. Gateway forwards the request to the backend API
  8. Gateway returns the response to the agent

The gateway is stateless. It does not cache tool definitions unless you configure it to. Each MCP session can trigger a fresh schema read, or you can pin schemas at startup.

Schema Inference Mechanics

OpenAPI and GraphQL schemas provide machine-readable contracts. The gateway parses these into MCP tool definitions.

OpenAPI to MCP:

  • Each path + method becomes a tool
  • Path parameters and query parameters become tool input properties
  • Request body schemas map to input objects
  • Response schemas define output types
  • Operation IDs become tool names

GraphQL to MCP:

  • Each query and mutation becomes a tool
  • Field arguments map to input properties
  • Return types define output schemas
  • Introspection queries provide the schema at runtime

The gateway does not infer intent. If your OpenAPI spec has 200 endpoints, you get 200 tools. You filter by tagging paths or using a whitelist in the config.

virtual_servers:
  - name: users
    backend: https://api.internal/users
    spec: /openapi.json
    tools:
      include_tags: [public, agent-safe]
      exclude_paths: [/admin/*, /internal/*]
Enter fullscreen mode Exit fullscreen mode

Authentication Passthrough

Agents need scoped credentials. The gateway cannot use a single root API key for all backend calls. Three patterns emerge:

Pattern How It Works Trade-off
Token forwarding Agent passes a user token; gateway forwards it in the Authorization header Backend must trust the agent's token issuer
Credential injection Gateway config maps virtual servers to backend credentials; agent does not see them Agent has full access to the backend; no per-user scoping
Token exchange Gateway exchanges the agent's token for a backend token using OAuth or a custom flow Adds latency and a dependency on the token exchange service

Most teams start with credential injection for internal agents and move to token exchange when agents operate on behalf of end users.

Deployment Shape

A virtual MCP gateway is a single process that serves multiple MCP endpoints. Each endpoint corresponds to a virtual server. The process:

  • Reads a config file at startup
  • Loads API specs (local files or fetched from backends)
  • Starts an HTTP server
  • Registers routes like /users/mcp, /products/mcp, /orders/mcp
  • Handles MCP protocol messages (initialize, tools/list, tools/call)

You deploy it like any HTTP service. Docker, Kubernetes, or a VM. The gateway does not need to scale horizontally unless you have thousands of agents. Most bottlenecks live in the backend APIs, not the gateway.

Where Virtual Servers Fall Short

Virtual servers trade flexibility for convenience. Hand-written MCP servers handle cases that config-driven adapters cannot:

Error handling: OpenAPI specs do not describe retry logic, circuit breakers, or fallback behavior. A virtual server returns the backend's error as-is. A custom server can catch a 503 and retry with exponential backoff.

Streaming: MCP supports streaming tool responses. Virtual servers can proxy SSE or WebSocket streams if the backend supports them, but they cannot synthesize streaming from a batch API.

Stateful sessions: Some APIs require multi-step flows (OAuth, pagination cursors, file uploads). Virtual servers treat each tool call as independent. A custom server can hold session state.

Complex transformations: If the backend returns XML or a nested JSON structure that does not map cleanly to MCP tool output, you need transformation logic. Virtual servers apply simple JSONPath filters at best.

Observability: Virtual servers log requests and responses. Custom servers can emit structured traces, correlate tool calls across sessions, and report agent-specific metrics.

Implementation Example

Here is a minimal virtual MCP gateway in Python using FastAPI. It reads OpenAPI specs and exposes them as MCP tools.

from fastapi import FastAPI, Request
from pydantic import BaseModel
import httpx
import yaml

app = FastAPI()

# Load config
with open("gateway.yaml") as f:
    config = yaml.safe_load(f)

# Parse OpenAPI specs into tool definitions
def parse_openapi(spec_url):
    spec = httpx.get(spec_url).json()
    tools = []
    for path, methods in spec["paths"].items():
        for method, details in methods.items():
            tools.append({
                "name": details.get("operationId", f"{method}_{path}"),
                "description": details.get("summary", ""),
                "inputSchema": details.get("parameters", []),
            })
    return tools

# MCP protocol handlers
@app.post("/{server_name}/mcp")
async def mcp_handler(server_name: str, request: Request):
    body = await request.json()
    method = body.get("method")

    if method == "initialize":
        return {"protocolVersion": "2024-11-05", "serverInfo": {"name": server_name}}

    if method == "tools/list":
        server_config = next(s for s in config["virtual_servers"] if s["name"] == server_name)
        tools = parse_openapi(server_config["spec"])
        return {"tools": tools}

    if method == "tools/call":
        # Proxy to backend
        server_config = next(s for s in config["virtual_servers"] if s["name"] == server_name)
        backend_url = server_config["backend"]
        tool_name = body["params"]["name"]
        tool_args = body["params"]["arguments"]

        # Map tool call to HTTP request (simplified)
        response = httpx.post(f"{backend_url}/{tool_name}", json=tool_args)
        return {"content": [{"type": "text", "text": response.text}]}
Enter fullscreen mode Exit fullscreen mode

This is a proof of concept. Production gateways add schema caching, auth middleware, rate limiting, and structured logging.

Observability Gaps

Virtual servers expose backend APIs without adding instrumentation. You lose visibility into:

  • Which tools agents call most often
  • How long backend APIs take to respond
  • Which tool calls fail and why
  • Whether agents retry failed calls

You can add middleware to log every tool call, but you still need to correlate logs across the gateway and backend services. Distributed tracing (OpenTelemetry) helps, but you must configure it manually.

Security Boundaries

A virtual MCP server is a proxy. It does not validate tool inputs beyond what the OpenAPI spec defines. If the backend API has a SQL injection vulnerability, the gateway forwards the malicious input.

You can add input validation middleware, but this duplicates logic that should live in the backend. The better pattern: treat the gateway as untrusted and enforce validation at the API layer.

Rate limiting is another concern. If an agent calls a tool 1,000 times per second, the gateway forwards all 1,000 requests to the backend. You need rate limiting at both layers: the gateway (per agent) and the backend (per API key).

When to Use Virtual Servers vs. Custom Servers

Use Virtual Servers When Use Custom Servers When
You have many internal APIs with OpenAPI specs You need complex request transformations
Backends are stable and well-documented You need stateful multi-step flows
You want to expose APIs to agents quickly You need custom error handling or retries
Your team does not want to maintain MCP server code You need fine-grained observability
Authentication is simple (token forwarding or injection) You need to aggregate data from multiple backends

Technical Verdict

Virtual MCP servers are a forcing function for API hygiene. If your OpenAPI specs are incomplete or wrong, the gateway exposes that immediately. If your backends return inconsistent error codes, agents will surface the chaos.

Use virtual servers when you need to expose 5+ internal APIs to agents and your team already maintains OpenAPI specs. The config-driven approach scales horizontally (add a YAML block) and vertically (one gateway process handles dozens of virtual servers).

Avoid virtual servers when you need request orchestration, stateful sessions, or custom error recovery. In those cases, write a hand-rolled MCP server and treat the gateway as a fallback for simpler APIs.

The pattern works best in environments where backend APIs are already designed for programmatic access. If your APIs were built for human-driven UIs, you will spend more time writing transformation logic than you save by skipping custom server code.

Source Links

Top comments (0)