All posts
This is some text inside of a div block.
Engineering

What we learned converting complex OpenAPI specs to MCP servers

At Stainless, we generate client SDKs in various languages for our customers’ APIs. Recently, those same customers started asking us for something new: a Model Context Protocol (MCP) server that wraps their API and exposes it as a set of tools for LLMs.

At first, converting an OpenAPI spec to an MCP server seems like it should be a pretty trivial task. OpenAPI schemas and MCP server schemas both use JSON Schema, and each API endpoint could map to an MCP “tool.”

You might think you could just copy those schemas from OpenAPI into the server definition, mapping each operation to a tool and call it a day. However, once you start actually building this, it’s a lot harder than you’d expect.

Below are some of the issues we ran into when trying to make a nice MCP Server implementation.

Hello world

In a simple case, this OpenAPI spec:

openapi: 3.0.0
info:
  title: Simple Hello API
  version: '1.0.0'
paths:
  /hello:
    post:
	    summary: Say hello
			requestBody:
        content:
          application/json:
            schema:
              type: string
              description: Name of the person to greet
      ...

would be transformed into this MCP Tool:

export const tool: Tool = {
  name: 'hello',
  description: 'Say hello',
  inputSchema: {
    type: 'object',
    properties: {
      name: {
        type: 'string',
        description: 'Name of the person to greet',
      },
    },
  },
};

This would work perfectly as-is; simple copy-paste. Done, right?

Here are some of the challenges we encountered in converting complex OpenAPI specs to MCP servers:

OpenAPI endpoints don’t perfectly map to Tools

While both MCP tools and OpenAPI specs use JSON Schema, you can’t just copy them over as-is. You have to combine the request body, path parameters, query parameters, and header parameters all into one schema, and handle any naming collisions automatically.

Additionally, MCP tools require you to define an object as the root schema, but OpenAPI requests do not (you can have an endpoint that takes a string or array only). To handle this, we map that to a single property at the root.

For example, this OpenAPI request body schema:

paths:
  /users/{user_id}/hello:
    post:
      summary: Say hello to multiple people
      parameters:
        - name: user_id
          in: path
          schema:
            type: string
          description: ID of the user making the request
        - name: capitalize
          in: query
          schema:
            type: boolean
          description: Whether to capitalize the names
      requestBody:
        content:
          application/json:
            schema:
              type: array
              items:
                type: string
              description: List of names to say hello to

Would be transformed into this MCP Tool:

export const tool: Tool = {
  name: 'say_hello',
  description: 'Say hello to multiple people',
  inputSchema: {
    type: 'object',
    properties: {
      user_id: {
        type: 'string',
        description: 'ID of the user making the request'
      },
      capitalize: {
        type: 'boolean',
        description: 'Whether to capitalize the names'
      },
      names: {
        type: 'array',
        items: {
          type: 'string'
        },
        description: 'List of names to say hello to'
      }
    },
    required: ['user_id', 'names']
  }
};

Luckily, at Stainless we already had to do all of these transformations for our client SDKs, so including it in MCP was free.

Handle $refs and recursive references

OpenAPI schemas use $ref to point to chunks of reusable schema elsewhere in the file, e.g. #/components/schemas/YourSchema. However, MCP Tool schemas must be completely self-contained, meaning they cannot reference anything outside themselves.

To solve this, you can simply put each schema component into a $defs section at the top of your MCP Tool schema, and then change your $ref from #/components/schemas/YourSchema to #/$defs/YourSchema. This would be correct, but LLMs tend to get confused when there’s too much indirection (e.g., if there’s nesting or the names aren’t very clear).

Alternatively, you might consider inlining all $refs into the schema, but this can get very verbose when they are large and referenced multiple times. Even worse, this simply doesn’t work if you have a recursive schema, like a tree of relationships.

We solved this at Stainless with a concept we already have called “models." These are the main user-facing schemas in an API, and get nice names when we generate their classes or types in our client SDKs.

We only define a $def for a schema component that has a model, so they are always named reasonably and used judiciously, so the LLMs don’t have to jump around too much.

type: object
properties:
  name:
    type: string
    description: Company name
  ceo:
    $ref: '#/$defs/employee'
  cto:
    $ref: '#/$defs/employee'
$defs:
  employee:
    type: object
    properties:
      name:
        type: string
      startDate:
        type: string
        format: date

Note that not all MCP Clients seem to handle $refs well, so see how we worked around this schema limitation below.

Many APIs have too many endpoints to be imported at once

MCP currently works by loading all of the tool schemas for an MCP Server at once into its context. The LLM then chooses which tool to use based on your request.

Because LLMs have a limited context window (generally 200k-1M tokens) that they use to hold all conversation history and loaded MCP servers, having too many exposed tools in an MCP server causes problems.

We’ve found that people generally just want to use a few tools at once—maybe up to a dozen or so at a time.

To solve this issue, we built several command line arguments to let end users choose which tools they want to import:

  • --tool imports one or more tools by name
  • --resource imports a whole resource (generally create/read/update/delete operations)
  • --tag imports a set of related tools grouped by a customer-defined tag (e.g. invoicing )
  • --operation imports only read or only write operations

All flags can also be inverted, e.g., --no-operation=write.

For example:

# include all tools tagged invoicing (except for delete_invoice), 
# any tools in the users or accounts resource, and list_transactions
npx -y my-api-mcp \
  --tag=invoicing  \
  --no-tool=delete_invoice \
  --resource=users,accounts \
  --tool=list_transactions \

This can also be specified when importing into an MCP Client JSON:

{
  "mcpServers": {
    "my_api": {
      "command": "npx",
      "args": [
        "-y", 
        "my-api-mcp",
        "--tag=invoicing",
        "--no-tool=delete-invoice",
        "--resource=users,accounts",
        "--tool=list_transactions"
      ],
      "env": {
        "MY_API_KEY": "abc-123"
      }
    }
  }
}

This means that a single MCP server can expose any number of tools, and change run-to-run based on the user’s needs.

MCP Clients have different schema limitations

One of the harder issues was dealing with different clients. Claude Desktop unsurprisingly handles the MCP protocol pretty well, but it turns out that JSON Schemas are interpreted differently between various AI models and MCP clients today, and have different limitations.

For example:

  1. OpenAI agents only supports certain schemas:
    1. Only supports anyOf (not allOf or oneOf)
    2. Cannot have an anyOf at the root
  2. Claude has not been as explicit about its limitations, but you can imagine they have some similar but different pitfalls.
    1. Claude Desktop supports anyOf at the root, and handles it (mostly) correctly, but Claude Code does not
    2. Sometimes Claude will provide an object or array property as a JSON-serialized string
  3. Cursor is quite limited in our testing
    1. it only supports up to 40 tools per server
    2. it has a 60 character max limit for server name + tool name
    3. It doesn’t seem to handle $refs or anyOfs at all

On one hand, all of the MCP clients and LLMs are getting better rapidly, so what is a limitation today may not be one tomorrow. On the other hand, we want people to be use our MCP server with as many clients as possible today!

One option to work around this is to generate a lowest-common-denominator schema that attempts to work with all clients. This could work, but it’s unfortunate to hobble an OpenAPI spec for Claude just because Cursor doesn’t support a particular feature.

Instead of doing that, we introduced the concept of “client capabilities”. Client capabilities are features of MCP or schemas that the user can say their client supports.

Top-level-unions

For example, we have a capability called top-level-unions that indicates whether a client can support this or not. If a client doesn’t support the feature, we have a function to dynamically transform the schema into a form that the client could support.

For the top-level-unions example, if a client does not support it, we will automatically transform it into multiple tools - one per variant.

export const create_employee: Tool = {
  name: 'create_employee',
  description: 'Create a new employee record',
  inputSchema: {
    anyOf: [
      {
        type: 'object',
        title: 'Product Manager',
        properties: {
          name: {
            type: 'string',
            description: 'Employee\'s full name'
          },
          product_area: {
            type: 'number',
            description: 'The product area the employee is assigned to'
          }
        }
      },
      {
        type: 'object',
        title: 'Engineer',
        properties: {
          name: {
            type: 'string',
            description: 'Employee\'s full name'
          },
          team: {
            type: 'string',
            description: 'Engineering team name'
          }
        }
      }
    ]
  }
};

Would get transformed into a Product Manager and an Employee tool:

export const create_product_manager: Tool = {
  name: 'create_employee_product_manager',
  description: 'Create a new employee record',
  inputSchema: {
    type: 'object',
    title: 'Product Manager',
    properties: {
      name: {
        type: 'string',
        description: 'Employee\'s full name'
      },
      product_area: {
        type: 'number',
        description: 'The product area the employee is assigned to'
      }
    }
  }
};

export const create_engineer: Tool = {
  name: 'create_employee_engineer',
  description: 'Create a new employee record',
  inputSchema: {
    type: 'object',
    title: 'Engineer',
    properties: {
      name: {
        type: 'string',
        description: 'Employee\'s full name'
      },
      team: {
        type: 'string',
        description: 'Engineering team name'
      }
    }
  }
};

Refs

Similarly, if a client does not support $ref, we will inline all references. If it turns out there’s a circular reference that cannot be represented without refs, then we just drop that property during the transformation.

For example:

# Original schema with circular reference
type: object
properties:
  name: 
    type: string
  manager:
    $ref: '#/$defs/employee'
$defs:
  employee:
    type: object  
    properties:
      name:
        type: string
      directReports:
        type: array
        items:
          $ref: '#/$defs/employee'

# Transformed schema (refs inlined until circular reference detected)
type: object
properties:
  name:
    type: string
  manager:
    type: object
    properties:
      name:
        type: string
      # directReports property dropped since it would cause infinite nesting

This way, even though we lose some schema fidelity, the tool is still usable by clients that don't support references.

There are more transformations that we won’t dig into further, but this seems sufficient to get most MCP clients working:

  • valid-json: Some clients/LLMs may incorrectly send arguments as a JSON-encoded string instead of a proper JSON object. If a client does this, the MCP server will attempt to parse string values as JSON if the initial validation against the schema fails.
  • unions: Some clients/LLMs do not support union types (anyOf) in JSON schemas. If a client lacks this capability, the MCP server removes all anyOf fields and uses only the first variant as the schema.
  • formats: Some clients/LLMs do not support the 'format' keyword in JSON Schema specifications. If a client lacks this capability, the MCP server removes all format fields and appends the format information to the field's description in parentheses.
  • tool-name-length=N: Some clients/LLMs impose a maximum length on tool names. If this capability is set, the MCP server will automatically truncate tool names exceeding the specified length (N), ensuring uniqueness by appending numbers if necessary.

Specifying the Client

Ultimately, users don’t really care what limitations their tool has—they just want to use the MCP server. Requiring that they specify these transformations themselves would be much too onerous.

Instead, the user provides the client they are using, .e.g --client=cursor , --client=claude , etc. This automatically applies the schema transformations that those clients are known to need to operate effectively.

If the user has a client not in the list, or the client has since been updated and has a different set of capabilities, the user can manually add or remove capabilities themselves.

Custom Tweaks and Servers

After all of this, it’s possible that the API schema is still too complex for LLMs today, and that we can’t automatically convert it to something that works well.

Or maybe you want to host a Remote MCP server of your own and just include some of the tools that we generate. You can import our MCP server as a library and just import the tools and schemas as desired.

For example:

import { init, server, endpoints } from "my-api-package-mcp/server";
import MyStainlessClient from "my-api-package";

// Instantiate your own client
const client = new MyStainlessClient({
  authToken: this.env.MY_API_TOKEN,
});

// filter generated endpoints (tools) by resource, tool, tag, etc
const filteredEndpoints = endpoints.filter((endpoint) =>
  endpoint.metadata.tags.includes("mytag")
);

// make custom tools
const customTool = {
  tool: {
    name: "my-custom-tool",
    description: "A custom tool that does something",
    inputSchema: {
      type: "object" as const,
      properties: { a_property: { type: "string" } },
    },
  },
  handler: async (client: MyStainlessClient, args: any) => {
    return { a_value: "a_value" };
  },
};

// call the init function and provide 
init({
  server: this.server,
  endpoints: [...filteredEndpoints, customTool],
  client,
});

You may also want to directly tweak the MCP server’s functionality without having to make your own and import it. This gets tricky when you’re producing the MCP server with code generation, like we are at Stainless.

In our case, you can directly edit any generated file in the repo if you like. This way, you could tweak one problematic schema, or add a CLI feature, and continue to release it alongside the generated changes.

Looking forward

We learned at lot about the MCP protocol and the developing ecosystem while building this product. There’s more on the horizon—remote MCP servers are now starting to get adopted, and LLMs will continue to get more clever. We’re going to continue iterating and improving our implementation to support these new use-cases for our customers, but we feel like this is a good start.

If you want to generate an MCP server from your own API, try it out at https://sdk.new and check out our docs.

For questions or feedback, reach out to support@stainless.com

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Posted by
David Ackerman
David Ackerman
Software Engineer