Skip to content
~/dipjyoti
Go back

From OpenAPI to MCP Server: Bridging REST APIs and AI Agents with Go

· 10 min read

Earlier this year I wired an LLM agent into five internal microservices. Each one had an OpenAPI spec. Each one needed an MCP server so the agent could call it as a tool. By the third service I was convinced someone had already written the generator I needed. No-one had, so I built it: openapi-go-mcp is the OpenAPI counterpart to the gRPC-to-MCP generator Redpanda shipped at the end of 2025. It takes any OpenAPI 3.x or Swagger 2.0 spec and emits a Go-based MCP server where every operation becomes an MCP tool.

This post is the guide I wish I had: what the generator gives you, how it works under the hood, and two ways to run it — one for a standalone proxy and one for embedding MCP into a service you already own.

The boilerplate nobody wants to write

MCP solves the N×M integration problem by collapsing N models and M tools into N+M standard connections. But that only works if the M tools actually exist. In a typical enterprise, the M tools are decade-old REST APIs with hundreds of operations and schemas already specified in OpenAPI. Turning each of those into an MCP server by hand means writing Go structs for every request and response, hand-crafting JSON Schema for every tool definition, and maintaining a translation layer that forwards tool calls to HTTP — all for code the spec already describes.

The uncomfortable truth is that an OpenAPI specification contains almost exactly what an MCP tool needs:

The gap is mechanical translation, not domain logic. openapi-go-mcp bridges that gap.

How the generator works

The CLI reads your OpenAPI spec, walks every operation that is not excluded, and emits a single *.mcp.go file. In companion mode, that file imports your existing oapi-codegen client and registers one MCP tool per operation. In proxy mode, the generator produces a complete runnable module with the HTTP client included. When the MCP host (Claude Desktop, an IDE, or your own agent) calls a tool, the generated code maps the tool arguments back to the right HTTP path, method, query string, headers, and body, then forwards the request through the typed oapi-codegen client.

flowchart LR
    S[OpenAPI spec] --> G[openapi-go-mcp]
    C[oapi-codegen client] --> G
    G --> M[*.mcp.go]
    M --> R[MCP runtime adapter]
    Host[MCP Host / LLM] -->|"tools/list + tools/call"| R

Key design decisions keep it composable rather than magic:

Mode one: proxy (a runnable server in four commands)

If you just want an MCP server now, proxy mode is a zero-boilerplate buildable module. One command produces main.go, go.mod, the generated *.mcp.go, and a README.md that lists the required environment variables for auth.

openapi-go-mcp \
    -mode=proxy \
    -spec petstore.yaml \
    -out gen/petstore-mcp \
    -module github.com/me/petstore-mcp

cd gen/petstore-mcp
go mod tidy
go build

# The exact env var name is derived from your spec's securitySchemes and listed in the generated README.md.
BEARER_TOKEN_BEARERAUTH=xxx ./petstore-mcp

The generated server reads API_BASE_URL to know where to proxy requests, uses the spec’s securitySchemes to load credentials from derived env vars, validates incoming tool arguments against the JSON Schema built from the spec, and surfaces HTTP responses — status codes, headers, and body — as MCP tool results. It speaks MCP over stdio by default, which is what Claude Desktop and most IDE integrations expect. No other wiring is required.

This is the fastest way to give an existing public or internal REST API an MCP face without touching the service itself.

Proxy mode data flow

sequenceDiagram
    participant Host as MCP Host (Claude Desktop / IDE)
    participant PS as Proxy Server (generated *.mcp.go + main.go)
    participant Up as Upstream REST API

    Host->>PS: tools/list (stdio)
    PS->>Up: GET /pets (HTTP)
    Up-->>PS: [{"id":1,"name":"Fluffy"}]
    PS-->>Host: tool schemas + descriptions
    Host->>PS: tools/call: listPets (stdio)
    PS->>PS: validate args against JSON Schema
    PS->>Up: GET /pets?status=available (HTTP)
    Up-->>PS: 200 OK + [{"id":1,"name":"Fluffy"}]
    PS-->>Host: structured MCP result (text + metadata)

The proxy is a thin translation layer. Every tool call becomes one HTTP request. Auth credentials and base URL come from env vars, so the proxy needs no code changes when the upstream changes.

Mode two: companion (embed MCP into your service)

Most real services already have a Go binary: an API gateway, a BFF, a sidecar, or the service itself. Companion mode generates only the MCP layer and expects you to write main.go, own the transport, and inject the oapi-codegen client yourself. That means custom retries, distributed tracing, mTLS, and auth middleware ride on the same client your service already uses.

# 1. Generate the typed HTTP client with oapi-codegen.
oapi-codegen -generate types,client -package pet -o gen/pet/pet.gen.go petstore.yaml

# 2. Generate the MCP companion.
openapi-go-mcp \
    -spec petstore.yaml \
    -out gen/petmcp \
    -package petmcp \
    -client-import github.com/me/myrepo/gen/pet

The generated companion exposes a single registration function. You instantiate your client, create an MCP server with whichever adapter you prefer, and call Register:

package main

import (
    "context"

    "github.com/modelcontextprotocol/go-sdk/mcp"

    "github.com/me/myrepo/gen/pet"
    "github.com/me/myrepo/gen/petmcp"
    "github.com/dipjyotimetia/openapi-go-mcp/pkg/runtime/gosdk"
)

func main() {
    client, _ := pet.NewClientWithResponses("https://api.example.com")
    raw, s := gosdk.NewServer("petstore-mcp", "1.0.0")
    petmcp.RegisterSwaggerPetstoreClient(s, client)
    _ = raw.Run(context.Background(), &mcp.StdioTransport{})
}

Here the HTTP client belongs to you. The MCP layer is a thin side-effect of a service you already ship. Companion mode is what I reach for when the MCP server is one feature inside a larger binary rather than a standalone process.

Companion mode data flow

sequenceDiagram
    participant Host as MCP Host (Claude Desktop / IDE)
    participant MB as MyService binary (main.go I wrote)
    participant MCP as MCP layer (*.mcp.go generated)
    participant OC as oapi-codegen client (my code)
    participant Up as Upstream REST API

    Host->>MB: tools/list (stdio)
    MB->>MCP: register tools from spec
    MCP-->>MB: tool schemas
    MB-->>Host: available tools + descriptions
    Host->>MB: tools/call: createPet (stdio)
    MB->>MCP: forward tool call with args
    MCP->>OC: typed HTTP call (CreatePetWithResponse)
    OC->>Up: POST /pets (HTTP with my retry/mTLS/tracing)
    Up-->>OC: 201 Created + {"id":2}
    OC-->>MCP: typed response struct
    MCP-->>MB: structured MCP result
    MB-->>Host: tool result + any custom enrichment

Because you own main.go and the oapi-codegen client, you can inject your existing middleware — distributed tracing spans, circuit breakers, custom auth refresh logic — into the same path the MCP tools use. The generated layer only handles schema translation; the transport is yours.

Filtering what the agent can see

Not every operation should be an agent tool. Admin endpoints, destructive batch jobs, and internal health checks have no place in an LLM’s tool list. The generator respects x-mcp at three levels, with the most specific winning:

paths:
  /admin:
    x-mcp: false             # exclude every operation under /admin …
    delete:
      operationId: purgeAll
    get:
      operationId: listAdmins
      x-mcp: true            # … except this one

Add -exclude-by-default to the CLI and nothing becomes an MCP tool unless it carries x-mcp: true explicitly. That is the right posture for a large third-party spec where you want to whitelist a handful of safe operations rather than blacklist the risky ones.

Batch generation: one tool for a fleet

Most organisations do not have one REST API. They have dozens, and each one ships its own spec in a repository or a directory. The -spec flag accepts a directory, a glob, or a comma-separated list. Every match is rendered into its own subdirectory.

# Recursive directory: every spec under apis/ becomes its own tool set
openapi-go-mcp \
    -spec apis/ \
    -out gen \
    -client-import github.com/acme/apis/gen \
    -force

The filename stem becomes the package slug and the package name. You can register them all against a single MCP server with WithNamePrefix to namespace the tools (petstore_listPets, billing_getInvoice) so an agent that loads your whole fleet never sees colliding tool names.

Comparing the two bridges: REST vs. gRPC

Redpanda’s protoc-gen-go-mcp works from Protobuf, which is structurally similar: a service definition already carries method names, request types, and response types. The difference is where the types live.

ConcerngRPC / ProtobufREST / OpenAPI
Schema source.proto file with service and message definitionsOpenAPI 3.x / Swagger 2.0 with operations and component schemas
Codegen stepprotoc-gen-go-mcp + protocoapi-codegen for types/client, then openapi-go-mcp for the MCP layer
TransportgRPC / HTTP/2HTTP/1.1 or HTTP/2 via the oapi-codegen client
AuthUsually mTLS or per-RPC metadataSpec-driven: securitySchemes mapped to env vars (proxy) or your middleware (companion)
FilteringProtobuf options / custom annotationsx-mcp in the spec or -exclude-by-default
Ecosystem fitgRPC-first microservices, internal RPCPublic REST APIs, legacy services, anything already documented in Swagger

Both generators solve the same problem: the spec already knows what the MCP server should look like, so stop writing it by hand. If your organisation is gRPC-native, use Redpanda’s toolchain. If your APIs are REST — which is most of them — openapi-go-mcp fills the same gap.

What I would do differently next time

Building this taught me two things about spec-driven generators that I did not fully appreciate going in.

Recursion is harder than it looks. OpenAPI specs reuse components via $ref, and those refs can be mutually recursive. JSON Schema for MCP tools does not allow infinite recursion, so the generator has to track visited refs and break cycles at a safe depth. The first naive implementation panicked on Kubernetes’ own OpenAPI spec. Now it handles arbitrarily deep shared schemas and emits diagnostics for cycles instead of failing.

Proxy auth is a UX problem, not a technical one. Deriving an env var name from a securitySchemes key sounds trivial, but OAuth2 implicit with spaces and BearerAuth_V2 produce different conventions. The fix was strict sanitisation and a clear diagnostic: the generated README.md lists every required env var, and missing credentials produce an explicit error before the server ever starts.

Where this fits in the agent stack

The agentic ecosystem is sorting itself into predictable layers. At the bottom is JSON-RPC, the wire format. On top sits MCP, the semantic protocol for tool discovery and invocation. Above that are the agents themselves. The missing layer — and the one openapi-go-mcp targets — is the bridge between the world of existing REST APIs and the world of MCP-native tools. You should not have to rewrite a working service to make it agent-accessible. The OpenAPI spec is the contract; the generator is the adapter.

Getting started

Install the binary from Homebrew, a release archive, a container image, or go install:

brew install dipjyotimetia/tap/openapi-go-mcp
# or
go install github.com/dipjyotimetia/openapi-go-mcp/cmd/openapi-go-mcp@latest

Point it at a spec, choose proxy or companion mode, and you have an MCP server. The repository has a working Petstore example that runs through both modes end to end.

If you have a REST API that an LLM should be able to call, and that API already has an OpenAPI spec, the right question is not “how do I write an MCP server?” It is “why haven’t I generated one yet?”


Share this post:

Related Posts


Previous Post
Evaluating an LLM Agent Like Real Software: Observability and Evals with Langfuse
Next Post
Fine-tuning Generative AI Models: A Practical Guide with LlamaIndex