This version is still in development and is not considered stable yet. For the latest snapshot version, please use Spring AI 1.1.2!

MCP Annotations Examples

This page provides comprehensive examples of using MCP annotations in Spring AI applications.

Complete Application Examples

Simple Calculator Server

A complete example of an MCP server providing calculator tools:

@SpringBootApplication
public class CalculatorServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(CalculatorServerApplication.class, args);
    }
}

@Component
public class CalculatorTools {

    @McpTool(name = "add", description = "Add two numbers")
    public double add(
            @McpToolParam(description = "First number", required = true) double a,
            @McpToolParam(description = "Second number", required = true) double b) {
        return a + b;
    }

    @McpTool(name = "subtract", description = "Subtract two numbers")
    public double subtract(
            @McpToolParam(description = "First number", required = true) double a,
            @McpToolParam(description = "Second number", required = true) double b) {
        return a - b;
    }

    @McpTool(name = "multiply", description = "Multiply two numbers")
    public double multiply(
            @McpToolParam(description = "First number", required = true) double a,
            @McpToolParam(description = "Second number", required = true) double b) {
        return a * b;
    }

    @McpTool(name = "divide", description = "Divide two numbers")
    public double divide(
            @McpToolParam(description = "Dividend", required = true) double dividend,
            @McpToolParam(description = "Divisor", required = true) double divisor) {
        if (divisor == 0) {
            throw new IllegalArgumentException("Division by zero");
        }
        return dividend / divisor;
    }

    @McpTool(name = "calculate-expression",
             description = "Calculate a complex mathematical expression")
    public CallToolResult calculateExpression(
            CallToolRequest request,
            McpSyncRequestContext context) {

        Map<String, Object> args = request.arguments();
        String expression = (String) args.get("expression");

        // Use convenient logging method
        context.info("Calculating: " + expression);

        try {
            double result = evaluateExpression(expression);
            return CallToolResult.builder()
                .addTextContent("Result: " + result)
                .build();
        } catch (Exception e) {
            return CallToolResult.builder()
                .isError(true)
                .addTextContent("Error: " + e.getMessage())
                .build();
        }
    }
}

Configuration:

spring:
  ai:
    mcp:
      server:
        name: calculator-server
        version: 1.0.0
        type: SYNC
        protocol: SSE  # or STDIO, STREAMABLE
        capabilities:
          tool: true
          resource: true
          prompt: true
          completion: true

Document Processing Server

An example of a document processing server with resources and prompts:

@Component
public class DocumentServer {

    private final Map<String, Document> documents = new ConcurrentHashMap<>();

    @McpResource(
        uri = "document://{id}",
        name = "Document",
        description = "Access stored documents")
    public ReadResourceResult getDocument(String id, McpMeta meta) {
        Document doc = documents.get(id);

        if (doc == null) {
            return new ReadResourceResult(List.of(
                new TextResourceContents("document://" + id,
                    "text/plain", "Document not found")
            ));
        }

        // Check access permissions from metadata
        String accessLevel = (String) meta.get("accessLevel");
        if ("restricted".equals(doc.getClassification()) &&
            !"admin".equals(accessLevel)) {
            return new ReadResourceResult(List.of(
                new TextResourceContents("document://" + id,
                    "text/plain", "Access denied")
            ));
        }

        return new ReadResourceResult(List.of(
            new TextResourceContents("document://" + id,
                doc.getMimeType(), doc.getContent())
        ));
    }

    @McpTool(name = "analyze-document",
             description = "Analyze document content")
    public String analyzeDocument(
            McpSyncRequestContext context,
            @McpToolParam(description = "Document ID", required = true) String docId,
            @McpToolParam(description = "Analysis type", required = false) String type) {

        Document doc = documents.get(docId);
        if (doc == null) {
            return "Document not found";
        }

        // Access progress token from context
        String progressToken = context.request().progressToken();

        if (progressToken != null) {
            context.progress(p -> p.progress(0.0).total(1.0).message("Starting analysis"));
        }

        // Perform analysis
        String analysisType = type != null ? type : "summary";
        String result = performAnalysis(doc, analysisType);

        if (progressToken != null) {
            context.progress(p -> p.progress(1.0).total(1.0).message("Analysis complete"));
        }

        return result;
    }

    @McpPrompt(
        name = "document-summary",
        description = "Generate document summary prompt")
    public GetPromptResult documentSummaryPrompt(
            @McpArg(name = "docId", required = true) String docId,
            @McpArg(name = "length", required = false) String length) {

        Document doc = documents.get(docId);
        if (doc == null) {
            return new GetPromptResult("Error",
                List.of(new PromptMessage(Role.SYSTEM,
                    new TextContent("Document not found"))));
        }

        String promptText = String.format(
            "Please summarize the following document in %s:\n\n%s",
            length != null ? length : "a few paragraphs",
            doc.getContent()
        );

        return new GetPromptResult("Document Summary",
            List.of(new PromptMessage(Role.USER, new TextContent(promptText))));
    }

    @McpComplete(prompt = "document-summary")
    public List<String> completeDocumentId(String prefix) {
        return documents.keySet().stream()
            .filter(id -> id.startsWith(prefix))
            .sorted()
            .limit(10)
            .toList();
    }
}

MCP Client with Handlers

A complete MCP client application with various handlers:

@SpringBootApplication
public class McpClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(McpClientApplication.class, args);
    }
}

@Component
public class ClientHandlers {

    private final Logger logger = LoggerFactory.getLogger(ClientHandlers.class);
    private final ProgressTracker progressTracker = new ProgressTracker();
    private final ChatModel chatModel;

    public ClientHandlers(@Lazy ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @McpLogging(clients = "server1")
    public void handleLogging(LoggingMessageNotification notification) {
        switch (notification.level()) {
            case ERROR:
                logger.error("[MCP] {} - {}", notification.logger(), notification.data());
                break;
            case WARNING:
                logger.warn("[MCP] {} - {}", notification.logger(), notification.data());
                break;
            case INFO:
                logger.info("[MCP] {} - {}", notification.logger(), notification.data());
                break;
            default:
                logger.debug("[MCP] {} - {}", notification.logger(), notification.data());
        }
    }

    @McpSampling(clients = "server1")
    public CreateMessageResult handleSampling(CreateMessageRequest request) {
        // Use Spring AI ChatModel for sampling
        List<Message> messages = request.messages().stream()
            .map(msg -> {
                if (msg.role() == Role.USER) {
                    return new UserMessage(((TextContent) msg.content()).text());
                } else {
                    return AssistantMessage.builder()
                        .content(((TextContent) msg.content()).text())
                        .build();
                }
            })
            .toList();

        ChatResponse response = chatModel.call(new Prompt(messages));

        return CreateMessageResult.builder()
            .role(Role.ASSISTANT)
            .content(new TextContent(response.getResult().getOutput().getContent()))
            .model(request.modelPreferences().hints().get(0).name())
            .build();
    }

    @McpElicitation(clients = "server1")
    public ElicitResult handleElicitation(ElicitRequest request) {
        // In a real application, this would show a UI dialog
        Map<String, Object> userData = new HashMap<>();

        logger.info("Elicitation requested: {}", request.message());

        // Simulate user input based on schema
        Map<String, Object> schema = request.requestedSchema();
        if (schema != null && schema.containsKey("properties")) {
            Map<String, Object> properties = (Map<String, Object>) schema.get("properties");

            properties.forEach((key, value) -> {
                // In real app, prompt user for each field
                userData.put(key, getDefaultValueForProperty(key, value));
            });
        }

        return new ElicitResult(ElicitResult.Action.ACCEPT, userData);
    }

    @McpProgress(clients = "server1")
    public void handleProgress(ProgressNotification notification) {
        progressTracker.update(
            notification.progressToken(),
            notification.progress(),
            notification.total(),
            notification.message()
        );

        // Update UI or send websocket notification
        broadcastProgress(notification);
    }

    @McpToolListChanged(clients = "server1")
    public void handleServer1ToolsChanged(List<McpSchema.Tool> tools) {
        logger.info("Server1 tools updated: {} tools available", tools.size());

        // Update tool registry
        toolRegistry.updateServerTools("server1", tools);

        // Notify UI to refresh tool list
        eventBus.publish(new ToolsUpdatedEvent("server1", tools));
    }

    @McpResourceListChanged(clients = "server1")
    public void handleServer1ResourcesChanged(List<McpSchema.Resource> resources) {
        logger.info("Server1 resources updated: {} resources available", resources.size());

        // Clear resource cache for this server
        resourceCache.clearServer("server1");

        // Register new resources
        resources.forEach(resource ->
            resourceCache.register("server1", resource));
    }
}

Configuration:

spring:
  ai:
    mcp:
      client:
        type: SYNC
        initialized: true
        request-timeout: 30s
        annotation-scanner:
          enabled: true
        sse:
          connections:
            server1:
              url: http://localhost:8080
        stdio:
          connections:
            local-tool:
              command: /usr/local/bin/mcp-tool
              args:
                - --mode=production

Async Examples

Async Tool Server

@Component
public class AsyncDataProcessor {

    @McpTool(name = "fetch-data", description = "Fetch data from external source")
    public Mono<DataResult> fetchData(
            @McpToolParam(description = "Data source URL", required = true) String url,
            @McpToolParam(description = "Timeout in seconds", required = false) Integer timeout) {

        Duration timeoutDuration = Duration.ofSeconds(timeout != null ? timeout : 30);

        return WebClient.create()
            .get()
            .uri(url)
            .retrieve()
            .bodyToMono(String.class)
            .map(data -> new DataResult(url, data, System.currentTimeMillis()))
            .timeout(timeoutDuration)
            .onErrorReturn(new DataResult(url, "Error fetching data", 0L));
    }

    @McpTool(name = "process-stream", description = "Process data stream")
    public Flux<String> processStream(
            McpAsyncRequestContext context,
            @McpToolParam(description = "Item count", required = true) int count) {

        // Access progress token from context
        String progressToken = context.request().progressToken();

        return Flux.range(1, count)
            .delayElements(Duration.ofMillis(100))
            .flatMap(i -> {
                if (progressToken != null) {
                    double progress = (double) i / count;
                    return context.progress(p -> p.progress(progress).total(1.0).message("Processing item " + i))
                        .thenReturn("Processed item " + i);
                }
                return Mono.just("Processed item " + i);
            });
    }

    @McpResource(uri = "async-data://{id}", name = "Async Data")
    public Mono<ReadResourceResult> getAsyncData(String id) {
        return Mono.fromCallable(() -> loadDataAsync(id))
            .subscribeOn(Schedulers.boundedElastic())
            .map(data -> new ReadResourceResult(List.of(
                new TextResourceContents("async-data://" + id,
                    "application/json", data)
            )));
    }
}

Async Client Handlers

@Component
public class AsyncClientHandlers {

    @McpSampling(clients = "async-server")
    public Mono<CreateMessageResult> handleAsyncSampling(CreateMessageRequest request) {
        return Mono.fromCallable(() -> {
            // Prepare request for LLM
            String prompt = extractPrompt(request);
            return prompt;
        })
        .flatMap(prompt -> callLLMAsync(prompt))
        .map(response -> CreateMessageResult.builder()
            .role(Role.ASSISTANT)
            .content(new TextContent(response))
            .model("gpt-4")
            .build())
        .timeout(Duration.ofSeconds(30));
    }

    @McpProgress(clients = "async-server")
    public Mono<Void> handleAsyncProgress(ProgressNotification notification) {
        return Mono.fromRunnable(() -> {
            // Update progress tracking
            updateProgressAsync(notification);
        })
        .then(broadcastProgressAsync(notification))
        .subscribeOn(Schedulers.parallel());
    }

    @McpElicitation(clients = "async-server")
    public Mono<ElicitResult> handleAsyncElicitation(ElicitRequest request) {
        return showUserDialogAsync(request)
            .map(userData -> {
                if (userData != null && !userData.isEmpty()) {
                    return new ElicitResult(ElicitResult.Action.ACCEPT, userData);
                } else {
                    return new ElicitResult(ElicitResult.Action.DECLINE, null);
                }
            })
            .timeout(Duration.ofMinutes(5))
            .onErrorReturn(new ElicitResult(ElicitResult.Action.CANCEL, null));
    }
}

Stateless Server Examples

@Component
public class StatelessTools {

    // Simple stateless tool
    @McpTool(name = "format-text", description = "Format text")
    public String formatText(
            @McpToolParam(description = "Text to format", required = true) String text,
            @McpToolParam(description = "Format type", required = true) String format) {

        return switch (format.toLowerCase()) {
            case "uppercase" -> text.toUpperCase();
            case "lowercase" -> text.toLowerCase();
            case "title" -> toTitleCase(text);
            case "reverse" -> new StringBuilder(text).reverse().toString();
            default -> text;
        };
    }

    // Stateless with transport context
    @McpTool(name = "validate-json", description = "Validate JSON")
    public CallToolResult validateJson(
            McpTransportContext context,
            @McpToolParam(description = "JSON string", required = true) String json) {

        try {
            ObjectMapper mapper = new ObjectMapper();
            mapper.readTree(json);

            return CallToolResult.builder()
                .addTextContent("Valid JSON")
                .structuredContent(Map.of("valid", true))
                .build();
        } catch (Exception e) {
            return CallToolResult.builder()
                .addTextContent("Invalid JSON: " + e.getMessage())
                .structuredContent(Map.of("valid", false, "error", e.getMessage()))
                .build();
        }
    }

    @McpResource(uri = "static://{path}", name = "Static Resource")
    public String getStaticResource(String path) {
        // Simple stateless resource
        return loadStaticContent(path);
    }

    @McpPrompt(name = "template", description = "Template prompt")
    public GetPromptResult templatePrompt(
            @McpArg(name = "template", required = true) String templateName,
            @McpArg(name = "variables", required = false) String variables) {

        String template = loadTemplate(templateName);
        if (variables != null) {
            template = substituteVariables(template, variables);
        }

        return new GetPromptResult("Template: " + templateName,
            List.of(new PromptMessage(Role.USER, new TextContent(template))));
    }
}

MCP Sampling with Multiple LLM Providers

This example demonstrates how to use MCP Sampling to generate creative content from multiple LLM providers, showcasing the annotation-based approach for both server and client implementations.