Anıl Şenocak Anıl Şenocak
Yazılım Mühendisi
Cover image
2026-07-11 0 toplam yorum
Testing Spring AI MCP Tools in Swagger UI

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.

The Solution: A Custom OpenAPI Bridge

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.

How It Works

Here is a quick breakdown of the magic happening under the hood:

1. Standard Protocol Methods

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.

2. Reflection & Context Scanning

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.

3. Annotation Detection

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.

4. Swagger UI Injection

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.

The Result

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!

Yorumlar
Henüz yorum yok. İlk yorumu sen yaz.
Yorum Bırak
Adınız (isteğe bağlı)
Yorumunuzu yazın...
0/500