MCP + OpenAPI: How I Converted My Legacy Spring Boot API to an MCP Server in 50 Lines
Honestly, I didn't think this would work this well.
I've got this old Spring Boot project sitting around for years — it's the original backend for my knowledge base, built before MCP was even a thing. It has all these nice REST endpoints, OpenAPI docs already generated by SpringDoc, everything working. But when I decided to jump on the MCP bandwagon for my knowledge base experiment, I thought I'd have to rewrite everything.
"I'll just build a new MCP server from scratch," I told myself. That's what everybody does, right? Start fresh.
Three hours in, I was staring at my OpenAPI JSON and thinking — wait a second. Why am I copying every endpoint manually into MCP tool definitions? My API already describes itself! Why can't we just auto-convert OpenAPI specs to MCP tools?
So here's the thing — I did it. And it only took 50 lines of Java code. Today I want to show you how it works, what I got wrong, and why this might change how you think about adding MCP support to your existing APIs.
The Problem: I Already Had a Working API, Why Rewrite It?
Let me set the scene. My knowledge base project Papers has been around for 6 years. Yes, six years. I've rewritten it three times already. The current version has a perfectly good REST API:
-
GET /api/search— search notes by keyword -
GET /api/notes/{id}— get a specific note -
POST /api/notes— create a new note -
PUT /api/notes/{id}— update a note -
DELETE /api/notes/{id}— delete a note
All of this already works. It's tested. It's deployed. It's stable. I even have full OpenAPI 3.0 documentation automatically generated by SpringDoc OpenAPI.
When I decided to add MCP support so AI assistants could use my knowledge base, I looked at what other people were doing. Most folks are writing MCP tools by hand: one method per tool, manually define the input schema, wire everything up.
That sounded... tedious. For five endpoints it's not a big deal, but what if you have 50 endpoints? 100? Do you really want to maintain two parallel descriptions of the same API?
I learned this the hard way with six years of maintenance: duplication always ends badly. You update one, forget to update the other. Something diverges. Bugs happen.
There had to be a better way.
The Insight: OpenAPI Already Has All the Information MCP Needs
Let's think about this. What does MCP need from a tool?
- A name for the tool
- A description of what it does
- An input schema (JSON Schema) describing the parameters
- A way to execute the call and return the result
Guess what? OpenAPI already has all of that.
-
name→ comes from the operation ID -
description→ comes from the operation summary/description -
input schema→ OpenAPI already has JSON Schema for request parameters and request body -
execution→ you already have HTTP endpoints, just proxy the request!
I couldn't believe I didn't see this earlier. It's such an obvious mapping. So why isn't everyone doing this?
Well, there are some gotchas. MCP expects tools to be stateless, idempotent-ish, and each tool is basically one function. That maps really well to REST resources — each operation is a tool.
Let me show you the code. It's actually dirt simple.
The Code: 50 Lines That Do the Job
Here's the core of it. I built an OpenApiMcpAdapter that reads your SpringDoc OpenAPI and automatically exposes every operation as an MCP tool.
First, the dependencies — you probably already have these if you're using Spring Boot:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-core</artifactId>
<version>2.2.20</version>
</dependency>
Now the adapter itself:
@Component
public class OpenApiMcpAdapter {
private final OpenAPI openApi;
private final RestTemplate restTemplate;
private final String baseUrl;
public OpenApiMcpAdapter(OpenAPI openAPI,
@Value("${springdoc.api-docs.server-url:http://localhost:8080}") String baseUrl) {
this.openApi = openAPI;
this.baseUrl = baseUrl;
this.restTemplate = new RestTemplate();
}
// Convert all OpenAPI paths to MCP tool definitions
public List<Tool> listTools() {
List<Tool> tools = new ArrayList<>();
openApi.getPaths().forEach((path, pathItem) -> {
pathItem.readOperationsMap().forEach((method, operation) -> {
Tool tool = convertToTool(method, path, operation);
tools.add(tool);
});
});
return tools;
}
// Convert one OpenAPI operation to MCP Tool
private Tool convertToTool(HttpMethod method, String path, Operation operation) {
Tool tool = new Tool();
tool.setName(operation.getOperationId());
tool.setDescription(operation.getSummary());
// Build input schema from OpenAPI parameters and requestBody
JsonSchema inputSchema = buildInputSchema(method, path, operation);
tool.setInputSchema(inputSchema);
return tool;
}
// Execute MCP tool call by proxying to the actual REST endpoint
public Object callTool(String name, Map<String, Object> arguments) {
// Find the operation by operationId
PathAndOperation pathOp = findOperationByOperationId(name);
if (pathOp == null) {
throw new IllegalArgumentException("Unknown tool: " + name);
}
// Substitute path parameters, build URL
String url = buildUrl(pathOp.getPath(), arguments);
// Execute the request
ResponseEntity<Object> response = executeRequest(
pathOp.getMethod(),
url,
buildRequestBody(pathOp.getOperation(), arguments)
);
return response.getBody();
}
// ... the rest is just boring parameter binding you can find on GitHub
}
Wait — that's actually most of it! The hardest part was parameter handling: extracting where parameters go (path, query, body) and binding them correctly.
Then you just wire this into your MCP controller:
@RestController
@RequestMapping("/mcp")
public class McpController {
private final OpenApiMcpAdapter adapter;
public McpController(OpenApiMcpAdapter adapter) {
this.adapter = adapter;
}
@PostMapping("/tools/list")
public ToolsList listTools() {
return new ToolsList(adapter.listTools());
}
@PostMapping("/tools/call")
public Object callTool(@RequestBody CallToolRequest request) {
return adapter.callTool(request.getName(), request.getArguments());
}
}
That's it. 50 lines of actual code. Everything else is just parameter binding that you can copy from my repo.
Does This Actually Work? Let Me Show You.
I dropped this into my existing Papers Spring Boot application, restarted, and connected my Claude Desktop client.
It worked. On the first try.
I'm not exaggerating. Here's what happened:
- Claude Desktop connects to my MCP server
- It calls
/mcp/tools/list - My adapter auto-generates tool definitions from my existing OpenAPI
- Claude sees five tools:
searchNotes,getNote,createNote,updateNote,deleteNote - I ask Claude: "Find all my notes about MCP server design"
- Claude calls the
searchNotestool withquery: "MCP server design" - My adapter proxies it to my existing
GET /api/searchendpoint - Back comes the results, Claude summarizes them for me
That's the whole flow. Zero manual tool definition. Zero duplication. My existing API just works as MCP tools.
I sat there staring at it for five minutes because I couldn't believe it was that easy. After six years of rewriting everything, the laziest approach actually worked.
What I Got Wrong: The Surprising Gotchas
Okay, let's be real. It's not all perfect. I hit some surprising issues you should know about if you want to try this.
1. Not All Operations Make Good Tools
Some operations just don't map well. For example, if you have paginated list endpoints that expect page and size parameters, that's fine. But if you have complex nested resources with multiple path parameters like /users/{userId}/posts/{postId}/comments/{commentId}, it still works — but the input schema gets messy.
Honestly, though? MCP clients handle it fine. They just pass the parameters. It works.
2. Operation IDs Need to Be Unique (and Sensible)
SpringDoc by default generates operation IDs like getById — you end up with multiple operations having the same name from different paths. You need to make sure your operation IDs are unique across your entire API.
I learned that the hard way. Got a weird error where "getNote" was actually mapped to GET /api/users/{id} instead of GET /api/notes/{id}. Oops.
Fix it by configuring SpringDoc to use fully qualified operation IDs:
springdoc.operation-id-generator=io.swagger.v3.oas.annotations.media.ArraySchemadependsOn
# Or use the org.springdoc.core.customizers.OperationIdCustomizer to add prefixes
3. Authentication Gets Tricky
If your API requires authentication, you now have two layers: the MCP client authenticates to your MCP server, and then your MCP server needs to authenticate to your API.
In my case, it's my personal server, so I just pass through the API key from the MCP client to the backend. For production, you'd want to handle this more carefully — but that's true regardless of whether you auto-generate or write by hand.
4. Content-Types Can Be Confusing
Some OpenAPI specs describe multiple content types for request bodies. MCP pretty much always sends JSON, so you just pick the JSON schema and you're good. Most of the time it just works.
Pros & Cons: Should You Do This?
Let me be completely honest with you — this approach isn't for everybody. Here's the breakdown:
Pros ✅
- Zero duplication — Your OpenAPI is the single source of truth. Update your API, MCP tools automatically update. No extra work.
- Super fast to add MCP support — I went from zero to working MCP integration in three hours. Most of that was figuring out the mapping, not writing code.
- Leverage what you already have — If you already have a working API with OpenAPI, why rewrite it?
- Works with any framework that supports OpenAPI — This isn't Spring Boot specific. It works with any API that can output OpenAPI JSON: Node.js, Python, whatever. You just need a small adapter.
- Incremental adoption — You don't have to convert everything at once. Mix auto-generated tools with hand-written tools for complex cases.
Cons ❌
- Less control over tool descriptions — Your OpenAPI descriptions need to be good. If your OpenAPI docs are garbage, your MCP tool descriptions will be garbage. Garbage in, garbage out.
- Not every operation makes a good tool — Some operations just don't fit the MCP tool model (like file uploads, streaming responses). You need to filter those out.
- Extra proxy hop — You're proxying from MCP endpoint to your existing endpoint. In practice, this adds a few milliseconds. For most use cases, it's totally negligible.
- OpenAPI spec quality varies — Some generators produce weird OpenAPI output. You might need to clean up your spec first.
So when should you do this?
Do it if:
- You already have an existing REST API with OpenAPI
- You want to add MCP support quickly
- You hate duplication as much as I do
- You're building something for personal use or internal tools
Don't do it if:
- You're building a public MCP service from scratch and want perfect tool descriptions
- You have very complex workflows that need custom orchestration
- Your OpenAPI docs are non-existent or terrible
Real-World Results: My Experience After Two Weeks
I've been running this auto-converted setup for my personal knowledge base for two weeks now, and honestly? It works better than I expected.
Claude has no problem using the auto-generated tools. The descriptions are good enough because I already keep my OpenAPI docs decent. When I add a new endpoint to my API, it automatically shows up as an MCP tool. I don't have to do anything.
The crazy thing? This is more maintainable than writing tools by hand. Because I already maintain the OpenAPI docs for my API anyway. I'm not doing any extra work. I'm just reusing what I already have.
I started this whole MCP journey by hand-writing tools for my knowledge base. I had 150 lines of code for five tools. Now I have 50 lines total that handle any number of tools automatically. That's a win in my book.
The Big Picture: MCP Is About Interoperability, Not Reinventing Wheels
Here's what I've been thinking about lately: everybody's rushing to build new MCP-native services, but most of us already have working APIs. Why do we need to rebuild everything for MCP?
MCP is a protocol for interoperability. It shouldn't require you to rewrite all your existing services. If you already have an API that does the work, you should just be able to expose it via MCP with minimal glue.
That's exactly what this does. It's just glue between two existing standards: OpenAPI (which everybody already uses) and MCP (the new AI tool protocol). Standards working together. That's how it's supposed to work, right?
I've been doing this long enough to know that the best code is the code you don't have to write. This approach lets you add MCP support to an existing API without writing much code at all.
Try It Out
If you want to see the full working code, check out my project Papers on GitHub — the OpenAPI adapter is in there, ready to fork:
https://github.com/kevinten10/Papers
The adapter is about 150 lines total with all the parameter handling. Most of it is just edge cases. The core idea is 50 lines.
What Do You Think?
I'm genuinely curious — do you have an existing API with OpenAPI that you'd like to expose as MCP tools? Would this approach work for you, or am I missing something big?
I've only been using this for my personal project for a couple of weeks. I'd love to hear if you try it out and what your experience is. Do you think auto-generating MCP tools from OpenAPI is a good idea, or should every tool be hand-crafted for AI?
Drop a comment below and let me know!
Top comments (0)