Anıl Şenocak

However, if you’ve set up an MCP server in Spring Boot, you might have noticed a slight downgrade in your Developer Experience when it comes to API documentation. Because MCP communicates via JSON-RPC 2.0 over a single /mcp endpoint, standard OpenAPI (Swagger) generation doesn't know how to document your individual tools. Instead of a beautiful list of endpoints, you get a single POST /mcp route. Testing your tools means manually crafting JSON-RPC payloads in Postman or curl.
Let’s fix that.
We can bridge this gap by writing a custom OpenApiCustomizer bean. This solution dynamically scans your Spring application context for any methods annotated with @McpTool and injects ready-to-use JSON-RPC payload examples directly into your Swagger UI.
Here’s how I wired it up with a custom OpenApiCustomizer so every @McpTool method becomes a testable, documented example in Swagger.
Here is a quick breakdown of the magic happening under the hood:
The code manually defines the baseline JSON-RPC payloads required by the MCP specification, specifically initialize and tools/list. This gives you one-click access to handshake with your server and verify your tools are registered.
This is where the automation happens. By injecting the ListableBeanFactory, the customizer iterates through every bean in your Spring context. It uses Spring's ReflectionUtils and AopUtils (crucial if your beans are proxied by Spring) to inspect the methods of each class.
When it finds a method annotated with @McpTool, it inspects the method signature, extracts the parameter names and types, and constructs a perfectly formatted tools/call JSON-RPC payload.
Finally, it groups all these examples and binds them to a single POST /mcp endpoint in the OpenAPI schema. It also ensures the necessary headers (mcp-session-id and Accept) are documented.
When you boot up your application and navigate to /swagger-ui.html, your /mcp endpoint will now feature a rich Examples dropdown menu. You can simply select a tool, and Swagger UI will populate the request body with the exact JSON-RPC payload required to execute it.
🎉 No more guesswork, no more manual payload typing just seamless testing for your Spring AI agents.
https://gist.github.com/senocak/5284714adebc1db4294c61c9778b28e9
@Configuration
public class OpenApi3Configuration {
@Bean
public OpenApiCustomizer mcpToolCustomizer(final ListableBeanFactory beanFactory) {
return openApi -> {
final var sessionHeader = new io.swagger.v3.oas.models.parameters.Parameter()
.in(SecurityScheme.In.HEADER.toString())
.name("mcp-session-id")
.required(true)
.allowEmptyValue(false)
.description("MCP Session ID used for request tracking")
.schema(new StringSchema()._default("00000000-0000-0000-0000-000000000000"));
final var acceptHeader = new io.swagger.v3.oas.models.parameters.Parameter()
.in("header")
.name("Accept")
.required(true)
.explode(false)
.allowEmptyValue(false)
.description("Accepted response types: application/json, text/event-stream")
.schema(new StringSchema()._default("application/json, text/event-stream"));
final Tag mcpTag = new Tag()
.name("MCP")
.description("Model Context Protocol (JSON-RPC 2.0)");
final List<Example> examples = new ArrayList<>();
// initialize method
final Map<String, Object> initialize = Map.of(
"jsonrpc", "2.0",
"id", 0,
"method", "initialize",
"params", Map.of(
"protocolVersion", "2024-11-05",
"clientInfo", Map.of(
"name", "http-client",
"version", "1.0.0"
),
"capabilities", Map.of()
)
);
examples.add(new Example()
.summary("initialize")
.value(initialize));
// tools/list method
final Map<String, Object> toolsList = Map.of(
"jsonrpc", "2.0",
"id", 3,
"method", "tools/list",
"params", Map.of("_meta", Map.of("progressToken", 3))
);
examples.add(new Example()
.summary("tools/list")
.value(toolsList));
// tools/call methods
final String[] beanNames = beanFactory.getBeanDefinitionNames();
int idCounter = 10;
for (String beanName : beanNames) {
Object bean;
try {
bean = beanFactory.getBean(beanName);
} catch (Exception _) {
continue;
}
final Class<?> targetClass = AopUtils.getTargetClass(bean);
int finalIdCounter = idCounter;
int finalIdCounter1 = idCounter;
ReflectionUtils.doWithMethods(targetClass, method -> {
final McpTool annotation = AnnotationUtils.findAnnotation(method, McpTool.class);
if (annotation == null)
return;
final Map<String, Object> arguments = new LinkedHashMap<>();
for (Parameter parameter : method.getParameters()) {
arguments.put(parameter.getName(), parameter.getType().getSimpleName());
}
final Map<String, Object> toolCall = Map.of(
"jsonrpc", "2.0",
"id", finalIdCounter,
"method", "tools/call",
"params", Map.of(
"name", annotation.name(),
"arguments", arguments,
"_meta", Map.of("progressToken", finalIdCounter1)
)
);
examples.add(new Example()
.summary(annotation.name())
.value(toolCall));
});
idCounter++;
}
// BUILD SWAGGER CONTENT (ONE ENDPOINT ONLY)
final Map<String, Example> exampleMap = examples.stream()
.collect(Collectors.toMap(
Example::getSummary,
e -> e,
(a, _) -> a,
LinkedHashMap::new
));
final MediaType mediaType = new MediaType().examples(exampleMap);
final RequestBody requestBody = new RequestBody()
.required(true)
.content(new Content().addMediaType("application/json", mediaType));
final ApiResponses responses = new ApiResponses();
final ApiResponse response = new ApiResponse()
.description("JSON-RPC response or SSE stream")
.content(new Content().addMediaType("application/json, text/event-stream", mediaType));
responses.addApiResponse("200", response);
final Operation operation = new Operation()
.summary("MCP JSON-RPC Endpoint")
.description("""
Single MCP endpoint supporting JSON-RPC 2.0:
- initialize
- tools/list
- tools/call
Each request must include:
- jsonrpc: "2.0"
- id: number
- method: string
- params: object
""")
.tags(List.of(mcpTag.getName()))
.requestBody(requestBody)
.addParametersItem(sessionHeader)
.addParametersItem(acceptHeader)
.responses(responses);
openApi.path("/mcp", new PathItem().post(operation));
openApi.addTagsItem(mcpTag);
};
}
}
Happy coding!