Tutorial
How to hand an existing REST API to an AI agent: declare MCP tools in OpenAPI 3.x on Google API Gateway
The API is already behind the gateway. Before you stand up another MCP server, check whether this OpenAPI document can become the tool list.
On 24 September 2026 Google published a concrete on-ramp: Cloud API Gateway's public preview reads the OpenAPI spec you already deploy, accepts MCP JSON-RPC on that same gateway, and transcodes each call back to REST. The announcement is Turn your REST APIs into MCP tools. Fields, validation, and error codes follow Configure Model Context Protocol, updated the same day and still under Pre-GA terms. Keep the spec as the source for docs and types with generating docs, types, and a client from OpenAPI. If a preview limit forces you to run your own remote server, the deploy shape is in putting a remote MCP server into production.
When an annotation is enough
Most of what an agent should call is already a REST operation. The usual patch is a second MCP server that rewrites paths, auth, and quotas, then calls the backend over HTTP. The gateway still has the JWT, the API key, the quota, and the logs. The agent simply cannot reach them. This preview removes that extra process. You deploy the same API config, and MCP shows up at the gateway's /mcp path. tools/call becomes the matching REST request, on the same policy path, drawing on the same quota for that operation. After transcoding, the backend cannot tell the call from a direct REST request.
The preview covers REST, OpenAPI 3.x, and the auth you already configured. Resources, prompts, response streaming, and Model Armor are on the roadmap, not in this release. Operations that return an empty body, such as HTTP 204, are not exposed as tools. Deeply nested objects may show up incomplete in tools/list. One gateway serves at most 1,000 tools. MCP and model routing cannot be enabled in the same API config. A tool that is not an HTTP operation cannot be expressed with the annotation. Build your own server for that, and see MCP and JSON Schema for the input shape.
API Gateway's MCP switch is not Apigee's. Google positions Gateway as the light on-ramp: the service is already on Cloud Run, and you want management plus an agent entry without a new stack. Lifecycle, heavier traffic policy, and monetization belong on MCP in Apigee. Governing which external MCP servers an agent may call on the way out is Agent Gateway, not this OpenAPI extension. The right annotation on the wrong product never reaches the gateway you actually run.
| What you have | Annotate the gateway | Write an MCP server |
|---|---|---|
| The operation is already REST, with auth and quota on the gateway | Start with this preview | Only after you hit a preview limit |
| You need resources, prompts, or streamed results | Not available yet | Implement it yourself |
| The tool is not an HTTP operation | The annotation cannot express it | Write the inputSchema yourself |
Turn MCP on, then opt out the operations the model should not see
MCP accepts OpenAPI 3.0.x or 3.1.x only. A Swagger 2.0 document does not become a tool list; migrate it first. The document switch is x-google-api-management.mcp. Set it to true and every eligible operation is exposed. Eligible means GET, POST, PUT, PATCH, or DELETE, a resolvable backend, and a non-empty description. The default tool name is the operationId. The description comes from the operation description, then from summary.
Per operation, x-google-mcp-tool is a boolean or an object. false opts that operation out. The object overrides the name and the description. Names must match [A-Za-z0-9_.-]{1,128} and stay unique across the spec. getOrderStatus passes the pattern; get_order_status is the name you want the model to read. Write the description as the situation in which the tool should be called, not as a restatement of the response fields. That sentence is the main signal the model uses to pick a tool.
The moment mcp is an object, so you can attach security to tools/list, MCP is on for every eligible operation. It does not mean "lock discovery and expose nothing." Opt out one by one with x-google-mcp-tool: false. The extension is legal only on an operation. On a path item or at the document root, upload fails. Every exposed operation needs a backend, either x-google-backend on the operation or a document-level default. Keep the JWT scheme you already use for this API. The sample only names orderServiceJwt; it does not invent a new issuer block.
This JSON is one contract: MCP on, one JWT named for tools/list, custom names for create and lookup, and delete opted out.
{
"openapi": "3.0.4",
"info": {
"title": "Order Service",
"version": "1.0.0"
},
"x-google-api-management": {
"mcp": {
"tools-list": {
"security": {
"orderServiceJwt": []
}
}
},
"backends": {
"orders-backend": {
"address": "https://orders.example.run.app"
}
}
},
"paths": {
"/orders": {
"post": {
"operationId": "createOrder",
"description": "Creates an order for a known SKU and quantity.",
"x-google-backend": "orders-backend",
"x-google-mcp-tool": {
"name": "create_order",
"description": "Create an order when the user gives a SKU and a quantity. Do not use this to check delivery status."
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer" }
}
}
}
}
},
"responses": {
"201": { "description": "Created" }
}
}
},
"/orders/{orderId}": {
"get": {
"operationId": "getOrderStatus",
"description": "Returns status, carrier, and ETA for one order.",
"x-google-backend": "orders-backend",
"x-google-mcp-tool": {
"name": "get_order_status",
"description": "Look up delivery status and ETA when the user asks where an order is or when it will arrive."
},
"parameters": [
{
"name": "orderId",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": { "description": "Order status" }
}
},
"delete": {
"operationId": "deleteOrder",
"summary": "Cancels an order that has not shipped.",
"x-google-backend": "orders-backend",
"x-google-mcp-tool": false,
"parameters": [
{
"name": "orderId",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": { "description": "Cancelled" }
}
}
}
}
}
Arguments are not a flat copy of the REST call
The gateway maps tool arguments back onto HTTP from the OpenAPI document. Path and query parameters become top-level fields of arguments, keyed by parameter name. Header parameters are top-level fields too; the gateway copies them onto the backend request. You cannot bind reserved system headers, or headers whose names start with x-google-. The request body is not flattened. The whole JSON value sits under a property named body. A status lookup is {"orderId":"A-1042"}. Creating an order is {"body":{"sku":"A-1042","qty":1}}.
When creating an order, the REST JSON body goes under arguments.body.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "create_order",
"arguments": {
"body": {
"sku": "A-1042",
"qty": 1
}
}
}
}
Models miss this layer more often than any other. Invalid arguments come back as HTTP 200 with JSON-RPC code -32602. Many clients treat any non-200 as a transport failure, so the gateway keeps protocol errors on 200. A backend application failure is still a successful JSON-RPC response, with result.isError set to true and the backend body inside. Split incidents into three layers before you change the spec: transport (401, 403, 405, 413), protocol (200 plus error.code), and application (200 plus isError).
This call drops the body wrapper. sku and qty sit at the top level, so the gateway rejects the arguments.
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "create_order",
"arguments": {
"sku": "A-1042",
"qty": 1
}
}
}
A deeply nested object may be incomplete in tools/list. When the model cannot see the full body, it invents fields. Keep the layer you show the model short: required fields in required, closed sets in enum. The handshake is initialize, with protocolVersion set to the documented string 2025-11-25. After that, send MCP-Protocol-Version on every request. Without the header, the gateway falls back to 2025-03-26. A successful notifications/initialized is HTTP 202 with no JSON-RPC result body.
Discovering a tool and calling it are different locks
initialize and notifications/initialized are unauthenticated. tools/list is unauthenticated by default. That is convenient in development and, in production, publishes tool names and input shapes to anyone who can reach /mcp. The docs tell you to point mcp.tools-list.security at exactly one JWT scheme under components.securitySchemes. An API key cannot protect tools/list. Naming several schemes, or naming an API key, fails at upload.
tools/call ignores the discovery lock. It reuses whatever the REST operation already requires. If the operation wants an API key, the call still wants an API key. If it wants a JWT, the call wants a JWT. Locking discovery with a JWT does not authorize the call. An x-api-key header on the connection only serves operations that already use a key. Once list is locked, the list request also needs a Bearer token. Store the two credentials apart.
Logs stay on API Gateway metrics. Tell MCP traffic from ordinary REST by the path ending in /mcp, or by a custom metric you add. The backend does not receive a magic header that says "this came from an agent." Per-caller quota belongs in gateway policy, before transcoding. Once the request is inside the service, it is mixed with direct REST.
| Method | Who can call it by default | Credential the preview accepts |
|---|---|---|
| initialize, notifications/initialized | Anyone | None |
| tools/list | Anyone, unless you lock it | Exactly one JWT if you lock it |
| tools/call | Same as that REST operation | API key or JWT, as the operation says |
Failures that show up when you upload the spec
Validation runs when you create the API config, not on the first tools/call. An operation with no resolvable description is rejected. That text can come from description, summary, or x-google-mcp-tool.description. Duplicate names, a name that misses the pattern, an extension in the wrong place, and a method outside the five verbs all fail upload. HTTP 204 never becomes a tool. Write x-google-mcp-tool: false yourself so "not exposed" is a decision in the spec, not a surprise in the tool list.
Put the protocol codes in the runbook. -32700 with HTTP 400 means the body is not JSON. -32600 with HTTP 200 means it is JSON but not a valid JSON-RPC request: missing jsonrpc, method, or a required id. -32601 means the method is outside the supported set, such as ping, resources, or prompts. -32602 means a bad protocol version, initialize without a string protocolVersion, an unknown tool name, or invalid arguments. Check the body wrapper first. -32000 means the response is too large or the backend body could not be parsed. An oversized raw HTTP body is 413. Anything other than POST to /mcp is 405.
401 and 403 stay HTTP statuses and carry WWW-Authenticate, pointing at protected-resource metadata. That is a different incident from a bad argument object. When a client cached an old tool name, -32602 Unknown tool means check the deployment first, then clear the cache. The preview is offered as-is. Before you treat gateway MCP as the entry point, run initialize, tools/list, one path-parameter read, and one write that sends body, all against the same spec.
Treat the OpenAPI JSON as the contract you review
Tool names, descriptions, the shape under body, and which operations are set to false all live in one JSON document. Review the formatted spec. Confirm openapi is 3.0 or 3.1, search for empty descriptions, then compare the x-google-mcp-tool: false list with the operations the product actually wants exposed. Between two deploys, diff the spec to see who turned the delete operation back on.
When the model picks the wrong tool, change the tool description before you change the session prompt. The description is the sentence inside tools/list. Put "when to call" in that sentence, and express "do not call delete" as an explicit opt-out. A system prompt that is always present does not cancel a tool that is already listed.
Three checks are enough before deploy. Use the JSON formatter to spread the spec out, the JSON Schema validator to check a body sample against arguments.body, and JSON diff to see who changed an opt-out.
FAQ
Can OpenAPI 2.0 turn MCP on directly?
No. The preview accepts OpenAPI 3.0.x and 3.1.x only. Migrate Swagger 2.0 first, then add a backend, a non-empty description, and the MCP extension. Converters often leave the old extension placement behind. Move backends up to document-level x-google-api-management.backends and reference them.
Can an API key protect tools/list?
No. Locking discovery requires exactly one JWT scheme you have already defined. An API key can still protect a specific tools/call when that REST operation already requires the key. Keep the discovery credential and the call credential separate.
Can the backend tell that a request came from MCP?
No. The docs say a transcoded request is indistinguishable from direct REST. Do per-caller accounting in gateway policy. A header you add inside the service can also collide with your own REST clients.
When should this replace a remote MCP server you run yourself?
If the operation is already behind API Gateway and inside the preview's limits, annotate the spec. Build your own server when you need resources, prompts, streamed results, more than 1,000 tools, an empty body, or a tool that is not an HTTP operation. If the same API config also needs model routing, MCP and routing cannot be on together; split the config.
Takeaways and next steps
The 24 September preview makes "write another MCP server" optional. The contract is still OpenAPI 3.x: a document switch, per-operation opt-out, a name and description written for the model, path and query fields at the top of arguments, and the request body under body.
Before you ship, lock tools/list to a JWT, confirm that 204s and delete-style operations stayed out of the list, then run handshake, list, read, and write against that same JSON. The preview terms still apply. Recheck the limits against the doc you deploy with.