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:
would be transformed into this MCP Tool:
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:
Would be transformed into this MCP Tool:
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.
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:
This can also be specified when importing into an MCP Client JSON:
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:
- OpenAI agents only supports certain schemas:
- Only supports
anyOf
(notallOf
oroneOf
) - Cannot have an
anyOf
at the root
- Only supports
- Claude has not been as explicit about its limitations, but you can imagine they have some similar but different pitfalls.
- Claude Desktop supports
anyOf
at the root, and handles it (mostly) correctly, but Claude Code does not - Sometimes Claude will provide an object or array property as a JSON-serialized string
- Claude Desktop supports
- Cursor is quite limited in our testing
- it only supports up to 40 tools per server
- it has a 60 character max limit for server name + tool name
- It doesn’t seem to handle
$refs
oranyOfs
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.
Would get transformed into a Product Manager and an Employee tool:
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:
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:
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