Openapi2Tools is a small Go library that turns OpenAPI operations into Model Context Protocol (MCP) tools at runtime
- Load OpenAPI 3.x specs from a URL, a file, or raw bytes (YAML or JSON).
- Inline
$refs, strip examples, and make integer/boolean parameters tolerant of string values (handy when LLMs serialise5as"5"). - Filter the routes you expose with regex and HTTP method rules.
- Generate snake*case tool names from
operationId, or fall back to<method>*<path>. - Build a JSON Schema input from path, query, header and body parameters, preserving nested fields, enums, formats, patterns,
minItems,oneOf, etc. - HTTP executor that substitutes path params, encodes query params (including repeated array values), forwards headers and sends the rest as JSON.
- Per-tool description overrides and response formatters when you need to tweak the output.
go get github.com/achetronic/openapi2toolsPick the SDK your project uses and expand the section below. The only difference between the two is the adapter import and the server type at the end.
Using mark3labs/mcp-go
package main
import (
"log"
"github.com/mark3labs/mcp-go/server"
"github.com/achetronic/openapi2tools/adapters/mcpgo"
"github.com/achetronic/openapi2tools/mcptools"
"github.com/achetronic/openapi2tools/openapi"
)
func main() {
// 1. Load the OpenAPI spec.
loader := openapi.NewLoader(openapi.LoadOptions{
ResolveRefs: true,
RemoveExamples: true,
FlexibleParameters: true,
})
spec, err := loader.LoadURL("https://example.com/openapi.yaml")
if err != nil {
log.Fatal(err)
}
// 2. Pick which routes you want to expose.
filters, _ := openapi.CompileRouteFilters([]openapi.RouteFilterConfig{
{Methods: []string{"GET", "POST"}, Paths: `^/v1/.*`},
})
routes := openapi.FilterRoutes(spec, filters)
// 3. Configure an HTTP executor that calls the underlying API.
exec := &mcptools.HTTPExecutor{
BaseURL: "https://example.com",
DefaultHeaders: map[string]string{
"User-Agent": "my-mcp/1.0",
},
}
// 4. Describe the routes as ToolDescriptors.
tools := mcptools.Describe(routes, mcptools.DescribeOptions{
CustomHandlerFactory: mcptools.HTTPHandlerFactory(exec),
})
// 5. Register them on a mark3labs/mcp-go server.
mcp := server.NewMCPServer("my-mcp", "1.0.0")
if _, err := mcpgo.Register(mcp, tools); err != nil {
log.Fatal(err)
}
log.Fatal(server.ServeStdio(mcp))
}Using modelcontextprotocol/go-sdk (official)
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/achetronic/openapi2tools/adapters/mcpsdk"
"github.com/achetronic/openapi2tools/mcptools"
"github.com/achetronic/openapi2tools/openapi"
)
func main() {
loader := openapi.NewLoader(openapi.LoadOptions{ResolveRefs: true})
spec, err := loader.LoadURL("https://example.com/openapi.yaml")
if err != nil {
log.Fatal(err)
}
routes := openapi.FilterRoutes(spec, nil) // nil = include all routes
exec := &mcptools.HTTPExecutor{BaseURL: "https://example.com"}
tools := mcptools.Describe(routes, mcptools.DescribeOptions{
CustomHandlerFactory: mcptools.HTTPHandlerFactory(exec),
})
server := mcp.NewServer(&mcp.Implementation{
Name: "my-mcp",
Version: "1.0.0",
}, nil)
if _, err := mcpsdk.Register(server, tools); err != nil {
log.Fatal(err)
}
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}openapi.NewLoader takes a LoadOptions struct that controls how the spec is fetched, parsed, transformed and cached. Every field is optional; the zero value gives you a plain parse with no transformations.
type LoadOptions struct {
ResolveRefs bool
RemoveExamples bool
FlexibleParameters bool
HTTPClient *http.Client
CachePath string
CacheDuration time.Duration
}| Field | Type | Default | What it does |
|---|---|---|---|
ResolveRefs |
bool |
false |
Inline every internal $ref so each operation has a self-contained schema. |
RemoveExamples |
bool |
false |
Strip example / examples keys to shrink the schemas you give an LLM. |
FlexibleParameters |
bool |
false |
Accept "5" / "true" in addition to 5 / true for numeric/bool args. |
HTTPClient |
*http.Client |
&http.Client{Timeout: 30s} |
HTTP client used by LoadURL (proxies, custom transports, retries…). |
CachePath |
string |
"" (disabled) |
On-disk cache for the post-processed spec when loading from a URL. |
CacheDuration |
time.Duration |
1 * time.Hour |
TTL for the on-disk cache. Ignored when CachePath is empty. |
A few cross-cutting notes:
ResolveRefsruns beforeRemoveExamplesandFlexibleParameters, so both transformations also apply to the inlined material.- The cache stores the transformed YAML. If you change the options, bump
CachePathor delete the file so the next load regenerates it. LoadFileandLoadBytesignoreHTTPClient,CachePathandCacheDuration; the rest of the options still apply.$refcycles are guarded by an internal max-depth of 20.
// 1) Just load a spec, faithfully:
loader := openapi.NewLoader(openapi.LoadOptions{})
// 2) Recommended when feeding tools to an LLM:
loader := openapi.NewLoader(openapi.LoadOptions{
ResolveRefs: true, // self-contained schemas
RemoveExamples: true, // smaller payloads
FlexibleParameters: true, // resilient to "5" vs 5
})
// 3) Same as above, but cache the processed spec on disk:
loader := openapi.NewLoader(openapi.LoadOptions{
ResolveRefs: true,
RemoveExamples: true,
CachePath: filepath.Join(os.TempDir(), "myapi-"+openapi.CacheKey(specURL)+".yaml"),
CacheDuration: 6 * time.Hour,
})After loading a spec you usually want to expose only a subset of its operations. openapi.FilterRoutes is a regex-based filter that turns a *Spec into a list of RouteMatch (one per path + HTTP method to expose).
func FilterRoutes(spec *Spec, filters []RouteFilter) []RouteMatchspec: the parsed spec returned by the loader.filters: an ordered list of rules. Passnil(or an empty slice) to include every operation in the spec.
A RouteFilter (or its YAML/JSON-friendly twin RouteFilterConfig) has three fields:
type RouteFilter struct {
Methods []string // HTTP methods this rule applies to ([] = all)
Paths *regexp.Regexp // regex matched against the route path
Exclude bool // true = drop matching routes
}For every (path, method) combination in the spec, filters are evaluated in order:
- The first filter whose
Pathsmatches the path and whoseMethodscontains the method (or whoseMethodsis empty) wins. - If that filter has
Exclude: false, the route is kept. IfExclude: true, it is dropped. - Routes that match no filter are dropped.
That last point is important: filters work as an allow-list by default. If you want a deny-list style ("everything except…"), add a final catch-all rule with Paths: .*.
If your filters come from YAML or JSON, use CompileRouteFilters to turn them into regex-backed RouteFilters:
type RouteFilterConfig struct {
Methods []string `yaml:"methods,omitempty"`
Paths string `yaml:"paths"`
Exclude bool `yaml:"exclude,omitempty"`
}Expose everything in the spec:
routes := openapi.FilterRoutes(spec, nil)Expose only read-only endpoints under /v1/:
filters, _ := openapi.CompileRouteFilters([]openapi.RouteFilterConfig{
{Methods: []string{"GET"}, Paths: `^/v1/.*`},
})
routes := openapi.FilterRoutes(spec, filters)Expose everything except DELETE operations:
filters, _ := openapi.CompileRouteFilters([]openapi.RouteFilterConfig{
{Methods: []string{"DELETE"}, Paths: `.*`, Exclude: true},
{Paths: `.*`},
})
routes := openapi.FilterRoutes(spec, filters)Pin a curated subset (order matters: more specific rules first):
filters, _ := openapi.CompileRouteFilters([]openapi.RouteFilterConfig{
{Methods: []string{"POST"}, Paths: `^/v1/jobs$`},
{Methods: []string{"GET"}, Paths: `^/v1/jobs/[^/]+$`},
{Methods: []string{"GET", "POST"}, Paths: `^/v1/catalog/.*`},
// Anything that didn't match above is implicitly dropped.
})flowchart TD
A[openapi.Loader]
B[openapi.FilterRoutes]
C[mcptools.Describe]
D[adapters/mcpgo]
E[adapters/mcpsdk]
A -- "*openapi.Spec" --> B
B -- "[]RouteMatch" --> C
C -- "[]ToolDescriptor" --> D
C -- "[]ToolDescriptor" --> E
classDef core fill:#1f6feb,stroke:#0b3d91,color:#ffffff;
classDef adapter fill:#2ea44f,stroke:#1a7f37,color:#ffffff;
class A,B,C core;
class D,E adapter;
openapi.Loaderreads the spec from a URL, a file or raw bytes.openapi.FilterRouteskeeps only the routes you want, using regex and HTTP methods.mcptools.Describeturns each route into aToolDescriptor(name, description, JSON Schema, handler).adapters/mcpgoregisters those descriptors on amark3labs/mcp-goserver.adapters/mcpsdkdoes the same on amodelcontextprotocol/go-sdkserver.
Each layer is independent, so you can:
- Drop the
HTTPExecutorand provide your own handler factory. - Use only the
schemapackage to build JSON Schemas fromopenapi.Schemavalues. - Write your own adapter for any other MCP library. A
ToolDescriptoris just a name, a description, a JSON Schema map, an OpenAPI route and a handler function.
| Package | What it does |
|---|---|
openapi |
OpenAPI 3.x types, loader, $ref resolver and route filters. |
schema |
Convert openapi.Schema to map[string]any (JSON Schema) and build the full input schema for an operation. |
mcptools |
Library-agnostic ToolDescriptor, DescribeOptions and HTTPExecutor. |
adapters/mcpgo |
Register tools on a mark3labs/mcp-go server. |
adapters/mcpsdk |
Register tools on a modelcontextprotocol/go-sdk server. |
See examples/ for runnable programs:
examples/mcpgo: a server usingmark3labs/mcp-go.examples/mcpsdk: a server using the official SDK.examples/inspect: load a spec and print the generated tool descriptors as JSON, no MCP server attached.
Extracted from a production MCP. The core types and the schema converter have been used against real OpenAPI specs.