From 3b06ec81dfcbaf58ca7e4752f48296a766f623f8 Mon Sep 17 00:00:00 2001 From: Hardik Singh Behl Date: Thu, 2 Apr 2026 21:06:41 +0530 Subject: [PATCH 1/2] add codebase to new module --- .../com/baeldung/restclient/Application.java | 12 + .../java/com/baeldung/restclient/Article.java | 30 ++ .../restclient/ArticleController.java | 82 +++++ .../restclient/ArticleNotFoundException.java | 4 + .../InvalidArticleResponseException.java | 4 + .../restclient/RestClientLiveTest.java | 327 ++++++++++++++++++ 6 files changed, 459 insertions(+) create mode 100644 spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java create mode 100644 spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java create mode 100644 spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java create mode 100644 spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java create mode 100644 spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java create mode 100644 spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java new file mode 100644 index 000000000000..f2660a60dfe1 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Application.java @@ -0,0 +1,12 @@ +package com.baeldung.restclient; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java new file mode 100644 index 000000000000..0f2c892e1743 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/Article.java @@ -0,0 +1,30 @@ +package com.baeldung.restclient; + +public class Article { + private Integer id; + private String title; + + public Article() { + } + + public Article(Integer id, String title) { + this.id = id; + this.title = title; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java new file mode 100644 index 000000000000..4ffb635db7f4 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleController.java @@ -0,0 +1,82 @@ +package com.baeldung.restclient; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/articles") +class ArticleController { + + Map database = new HashMap<>(); + + @GetMapping + ResponseEntity> getArticles() { + Collection
values = database.values(); + if (values.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(values); + } + + @GetMapping("/{id}") + ResponseEntity
getArticle(@PathVariable("id") Integer id) { + Article article = database.get(id); + if (article == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(article); + } + + @GetMapping(value = "/{id}", headers = "API-Version=2") + ResponseEntity
getArticleV2(@PathVariable("id") Integer id) { + return ResponseEntity.ok(new Article(100, "SECRET ARTICLE")); + } + + @GetMapping("/search") + ResponseEntity
searchArticleByTitle(@RequestParam(name = "title") String title) { + Optional
article = database.values().stream() + .filter(a -> a.getTitle().contains(title)) + .findFirst(); + if (article.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(article.get()); + } + + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE) + void createArticle(@RequestBody Article article) { + database.put(article.getId(), article); + } + + @PutMapping("/{id}") + void updateArticle(@PathVariable("id") Integer id, @RequestBody Article article) { + assert Objects.equals(id, article.getId()); + database.remove(id); + database.put(id, article); + } + + @DeleteMapping("/{id}") + void deleteArticle(@PathVariable("id") Integer id) { + database.remove(id); + } + + @DeleteMapping + void deleteAllArticles() { + database.clear(); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java new file mode 100644 index 000000000000..a0a1cb6c540a --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/ArticleNotFoundException.java @@ -0,0 +1,4 @@ +package com.baeldung.restclient; + +class ArticleNotFoundException extends RuntimeException { +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java new file mode 100644 index 000000000000..b43837997e88 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/main/java/com/baeldung/restclient/InvalidArticleResponseException.java @@ -0,0 +1,4 @@ +package com.baeldung.restclient; + +class InvalidArticleResponseException extends RuntimeException { +} \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java b/spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java new file mode 100644 index 000000000000..fa0bf9d4bc74 --- /dev/null +++ b/spring-boot-modules/spring-boot-client/src/test/java/com/baeldung/restclient/RestClientLiveTest.java @@ -0,0 +1,327 @@ +package com.baeldung.restclient; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.test.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.client.RestClient; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RestClientLiveTest { + + @LocalServerPort + private int port; + private String uriBase; + RestClient restClient = RestClient.create(); + + @Autowired + JsonMapper jsonMapper; + + @BeforeEach + void setup() { + uriBase = "http://localhost:" + port; + } + + @AfterEach + void teardown() { + restClient.delete() + .uri(uriBase + "/articles") + .retrieve() + .toBodilessEntity(); + } + + @Test + void whenSavedArticleFetchedAsString_thenCorrectValueReturned() { + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + String articlesAsString = restClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(String.class); + + assertThat(articlesAsString) + .isEqualToIgnoringWhitespace(""" + [{"id":1,"title":"How to use RestClient"}] + """); + } + + @Test + void whenArticleFetchedById_thenCorrectArticleReturned() { + int id = 1; + Article article = new Article(id, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + Article fetchedArticle = restClient.get() + .uri(uriBase + "/articles/" + id) + .retrieve() + .body(Article.class); + + assertThat(fetchedArticle) + .usingRecursiveComparison() + .isEqualTo(article); + } + + @Test + void whenArticlesFetchedAsParameterizedTypeReference_thenListReturned() { + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + List
articles = restClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + assertThat(articles) + .hasSize(1) + .first() + .usingRecursiveComparison() + .isEqualTo(article); + } + + @Test + void whenUsingCustomJsonMapper_thenArticleSerializedWithCustomFormat() { + JsonMapper jsonMapper = JsonMapper.builder() + .findAndAddModules() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + RestClient customClient = restClient + .mutate() + .configureMessageConverters(converters -> converters + .registerDefaults() + .jsonMessageConverter(new JacksonJsonHttpMessageConverter(jsonMapper))) + .build(); + + int id = 1; + Article article = new Article(id, "How to use RestClient"); + customClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + String fetchedArticle = customClient.get() + .uri(uriBase + "/articles/" + id) + .retrieve() + .body(String.class); + + assertThat(fetchedArticle) + .isEqualToIgnoringWhitespace(""" + {"id":1,"title":"How to use RestClient"} + """); + } + + @Test + void whenUpdatingExistingArticle_thenArticleUpdatedSuccessfully() { + int id = 1; + Article article = new Article(id, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + Article updatedArticle = new Article(id, "How to use RestClient even better"); + restClient.put() + .uri(uriBase + "/articles/" + id) + .contentType(MediaType.APPLICATION_JSON) + .body(updatedArticle) + .retrieve() + .toBodilessEntity(); + + List
articles = restClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + assertThat(articles) + .hasSize(1) + .first() + .usingRecursiveComparison() + .isEqualTo(updatedArticle); + } + + @Test + void whenDeletingExistingArticle_thenArticleDeletedSuccessfully() { + int id = 1; + Article article = new Article(id, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + restClient.delete() + .uri(uriBase + "/articles/" + id) + .retrieve() + .toBodilessEntity(); + + ResponseEntity fetchedArticleResponse = restClient.get() + .uri(uriBase + "/articles") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .toBodilessEntity(); + + assertThat(fetchedArticleResponse.getStatusCode()) + .isEqualTo(HttpStatusCode.valueOf(204)); + } + + @Test + void shouldPostAndGetArticlesWithExchange() { + assertThatThrownBy(this::getArticlesWithExchange).isInstanceOf(ArticleNotFoundException.class); + + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + List
articles = getArticlesWithExchange(); + + assertThat(articles) + .usingRecursiveComparison() + .isEqualTo(List.of(article)); + } + + private List
getArticlesWithExchange() { + return restClient.get() + .uri(uriBase + "/articles") + .exchange((request, response) -> { + if (response.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(204))) { + throw new ArticleNotFoundException(); + } else if (response.getStatusCode().isSameCodeAs(HttpStatusCode.valueOf(200))) { + return jsonMapper.readValue(response.getBody(), new TypeReference<>() {}); + } else { + throw new InvalidArticleResponseException(); + } + }); + } + + @Test + void shouldPostAndGetArticlesWithErrorHandling() { + assertThatThrownBy(() -> { + restClient + .get() + .uri(uriBase + "/articles/1234") + .retrieve() + .onStatus( + status -> status.value() == 404, + (request, response) -> { + throw new ArticleNotFoundException(); + } + ) + .body(new ParameterizedTypeReference() {}); + }).isInstanceOf(ArticleNotFoundException.class); + } + + @Test + void whenUsingApiVersionInserter_thenVersionHeaderAddedToRequest() { + RestClient versionedClient = restClient.mutate() + .defaultApiVersion("2") + .apiVersionInserter(ApiVersionInserter.useHeader("API-Version")) + .build(); + + Article fetchedArticle = versionedClient.get() + .uri(uriBase + "/articles/" + 1) + .retrieve() + .body(Article.class); + + assertThat(fetchedArticle.getId()) + .isEqualTo(100); + assertThat(fetchedArticle.getTitle()) + .isEqualTo("SECRET ARTICLE"); + } + + @Test + void whenInterceptorSetsRequestAttribute_thenAttributeAvailableDuringExecution() { + String key = "test-key"; + String value = "test-value"; + + Map capturedAttributes = new HashMap<>(); + + ClientHttpRequestInterceptor interceptor = (request, body, execution) -> { + request.getAttributes().put(key, value); + capturedAttributes.putAll(request.getAttributes()); + return execution.execute(request, body); + }; + RestClient interceptedClient = restClient + .mutate() + .requestInterceptor(interceptor) + .build(); + + interceptedClient.get() + .uri(uriBase + "/articles") + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + assertThat(capturedAttributes) + .containsEntry(key, value); + } + + @Test + void whenSearchingArticleByTitle_thenCorrectArticleReturned() { + Article article = new Article(1, "How to use RestClient"); + restClient.post() + .uri(uriBase + "/articles") + .contentType(MediaType.APPLICATION_JSON) + .body(article) + .retrieve() + .toBodilessEntity(); + + Article fetchedArticle = restClient.get() + .uri(uriBuilder -> uriBuilder + .scheme("http") + .host("localhost") + .port(port) + .path("/articles/search") + .queryParam("title", "RestClient") + .build()) + .retrieve() + .body(Article.class); + + assertThat(fetchedArticle) + .usingRecursiveComparison() + .isEqualTo(article); + } + +} From 98fe3897d05810ef1be36cbef9afa0912fc35e00 Mon Sep 17 00:00:00 2001 From: Hardik Singh Behl Date: Wed, 20 May 2026 18:13:15 +0530 Subject: [PATCH 2/2] add codebase for custom agent skills demonstration in spring ai --- spring-ai-modules/pom.xml | 1 + .../skills/article-summarizer/SKILL.md | 10 + .../scripts/fetch_article.py | 211 ++++++++++++++++++ .../spring-ai-agent-skills/pom.xml | 68 ++++++ .../com/baeldung/agentskills/Application.java | 13 ++ .../agentskills/ChatbotConfiguration.java | 26 +++ .../agentskills/ChatbotController.java | 34 +++ .../src/main/resources/application.yaml | 7 + .../agentskills/AgentSkillLiveTest.java | 54 +++++ 9 files changed, 424 insertions(+) create mode 100755 spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/SKILL.md create mode 100755 spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/scripts/fetch_article.py create mode 100644 spring-ai-modules/spring-ai-agent-skills/pom.xml create mode 100644 spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/Application.java create mode 100644 spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotConfiguration.java create mode 100644 spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotController.java create mode 100644 spring-ai-modules/spring-ai-agent-skills/src/main/resources/application.yaml create mode 100644 spring-ai-modules/spring-ai-agent-skills/src/test/java/com/baeldung/agentskills/AgentSkillLiveTest.java diff --git a/spring-ai-modules/pom.xml b/spring-ai-modules/pom.xml index 40888c4f4dcf..ac7e5edfe759 100644 --- a/spring-ai-modules/pom.xml +++ b/spring-ai-modules/pom.xml @@ -20,6 +20,7 @@ spring-ai-2 spring-ai-3 spring-ai-4 + spring-ai-agent-skills spring-ai-agentic-patterns spring-ai-anthropic-agent-skills spring-ai-chat-stream diff --git a/spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/SKILL.md b/spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/SKILL.md new file mode 100755 index 000000000000..126e88196901 --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/SKILL.md @@ -0,0 +1,10 @@ +--- +name: article-summarizer +description: Summarizes articles into concise digests. Useful when user asks to summarize or get key points from an article. +--- +# Article Summarizer +## Instructions +When summarizing an article: +1. If given a URL: Run `uv run scripts/fetch_article.py ` to retrieve the content. +2. Once content is available, extract the main thesis, few key points, and conclusion. +3. Structure the output as a TL;DR, key points, and a bottom line. \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/scripts/fetch_article.py b/spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/scripts/fetch_article.py new file mode 100755 index 000000000000..bbf6c9f3c39c --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/.openai/skills/article-summarizer/scripts/fetch_article.py @@ -0,0 +1,211 @@ +# This script just returns a hardcoded article for demonstration. +# Original article: https://www.baeldung.com/spring-ai-mcp-elicitations + +ARTICLE = """ +

1. Overview

+When working with Model Context Protocol (MCP), there are scenarios where MCP servers need additional details from users during tool execution that weren't included in the original request. Without a standardized way to request this information, the tool has no way to communicate this back to the client and fails to execute. + +MCP Elicitations address this issue by allowing the MCP server to pause and explicitly request the missing information from the user. This enables us to build interactive tools capable of dynamically gathering additional context. + +In this tutorial, we'll explore how to implement MCP Elicitations using Spring AI. +

2. Creating an MCP Server

+Let's start by building an MCP server that exposes a tool for fetching author details. + +We'll design this tool to conditionally trigger an elicitation request for additional details if they are missing from the original request. +

2.1. Dependencies and Configuration

+Let’s start by adding the necessary dependencies to our project’s pom.xml file: +
<dependency>
+    <groupId>org.springframework.ai</groupId>
+    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
+    <version>1.1.2</version>
+</dependency>
+We import Spring AI’s MCP server dependency, which provides the necessary classes for creating a custom HTTP-based MCP server. + +Next, let's edit the application.properties file to configure our application as an MCP server: +
spring.ai.mcp.server.name=author-server
+spring.ai.mcp.server.type=SYNC
+spring.ai.mcp.server.protocol=streamable
+Here, we configure a name for our MCP server, set it to be synchronous, and specify the transport type as streamable HTTP. +

2.2. Defining a Tool

+Next, let’s define a tool that our MCP server will expose. + +We’ll create an AuthorRepository class that provides a method to fetch author details using an article title. If the requested article is a premium one, we'll elicit additional information from the user before returning the author details: +
private static final Logger log = LoggerFactory.getLogger(AuthorRepository.class);
+
+@McpTool(description = "Get Baeldung author details using an article title")
+Author getAuthorByArticleTitle(
+    @McpToolParam(description = "Title/name of the article") String articleTitle,
+    @McpToolParam(required = false, description = "Name of user requesting author information") String username,
+    @McpToolParam(required = false, description = "Reason for requesting author information") String reason,
+    McpSyncRequestContext requestContext
+) {
+    log.info("Author requested for article: {}", articleTitle);
+    if (isPremiumArticle(articleTitle)) {
+        log.info("Article is premium, further information required");
+        if ((isBlank(username) || isBlank(reason)) && requestContext.elicitEnabled()) {
+            log.info("Required details missing, initiating elicitation");
+            StructuredElicitResult<PremiumArticleAccessRequest> elicitResult = requestContext.elicit(
+                e -> e.message("Baeldung username and reason required."),
+                PremiumArticleAccessRequest.class
+            );
+            if (McpSchema.ElicitResult.Action.ACCEPT.equals(elicitResult.action())) {
+                username = elicitResult.structuredContent().username();
+                reason = elicitResult.structuredContent().reason();
+                log.info("Elicitation accepted - username: {}, reason: {}", username, reason);
+            }
+        }
+        if (isSubscriber(username) && isValidReason(reason)) {
+            log.info("Access granted, returning author details");
+            return new Author("John Doe", "john.doe@baeldung.com");
+        }
+    }
+    return null;
+}
+
+record Author(String name, String email) {
+}
+
+record PremiumArticleAccessRequest(String username, String reason) {
+}
+We annotate our getAuthorByArticleTitle() method with the @McpTool annotation to expose it as an MCP tool. The method accepts the articleTitle as a required parameter, along with optional username and reason parameters. + +Additionally, we inject the McpSyncRequestContext as a method parameter, which provides access to the current request's metadata and enables us to initiate elicitation requests back to the MCP client. + +If the requested article is premium and any of the optional parameters are missing, we use the elicit() method to trigger an elicitation request. We pass a message explaining what information is needed and a schema defining the expected response structure. + +We should also note that before initiating this elicitation request, we call the elicitEnabled() method to check whether the connected MCP client supports elicitation, since attempting to elicit from an unsupported client would result in an error. + +If the user accepts the elicitation request and provides valid details, we extract the username and reason from the result and proceed with the authorization checks before returning hardcoded author details. + +For our demonstration, the isPremiumArticle(), isSubscriber(), and isValidReason() private methods always return true. +

3. Creating an MCP Host

+Now that we have our MCP server ready, we need an application to consume it. + +We’ll be building a chatbot using Anthropic’s Claude model, which will act as our MCP host. Alternatively, we can use a local LLM via Hugging Face or Ollama, as the specific AI model is irrelevant for this demonstration. + +We’ll be creating a new Spring Boot application in this section. +

3.1. Dependencies and Configuring an LLM

+First, let’s include the necessary dependency in our pom.xml file: +
<dependency>
+    <groupId>org.springframework.ai</groupId>
+    <artifactId>spring-ai-starter-model-anthropic</artifactId>
+    <version>1.1.2</version>
+</dependency>
+<dependency>
+    <groupId>org.springframework.ai</groupId>
+    <artifactId>spring-ai-starter-mcp-client</artifactId>
+    <version>1.1.2</version>
+</dependency>
+The Anthropic starter dependency is a wrapper around the Anthropic Message API, and we’ll use it to interact with the Claude model in our application. + +Additionally, we import the MCP client starter dependencywhich will allow us to configure clients inside our Spring Boot application that maintain 1:1 connections with the MCP servers. + +Next, let's configure our Anthropic API key and chat model in the application.properties file: +
spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
+spring.ai.anthropic.chat.options.model=claude-opus-4-5-20251101
+We use the ${} property placeholder to load the value of our API Key from an environment variable. + +Additionally, we specify Claude Opus 4.5 by Anthropic, using the claude-opus-4-5-20251101 model ID. Feel free to explore and use a different model based on requirements. + +With these two properties set, Spring AI automatically creates a bean of type ChatModel, allowing us to interact with the specified model. +

3.2. Configuring an MCP Client and Enabling MCP Elicitation

+Finally, to use our custom MCP server in our chatbot application, we need to configure an MCP client against it: +
spring.ai.mcp.client.capabilities.elicitation={}
+spring.ai.mcp.client.streamable-http.connections.author-server.url=http://localhost:8081/mcp
+In our application.properties file, we first enable MCP elicitation, which allows the MCP servers to send back an elicitation request if a tool requires additional information. + +Next, we configure a new client against our custom MCP server using the streamable HTTP transport type. Our configuration assumes the MCP server to be running at http://localhost:8081/mcp. We need to make sure to update the url property if it’s running on a different host or port. + +During application startup, Spring AI will scan our configuration, create the MCP client, and establish a connection with the corresponding MCP server. Additionally, it creates a bean of type SyncMcpToolCallbackProvider, which provides a list of all the tools exposed by the configured MCP servers. +

3.3. Building a Basic Chatbot

+With our LLM and MCP client configured, let’s build a simple chatbot. + +We'll start by creating a bean of type ChatClient using the auto-configured ChatModel and SyncMcpToolCallbackProvider beans: +
@Bean
+ChatClient chatClient(ChatModel chatModel, SyncMcpToolCallbackProvider toolCallbackProvider) {
+    return ChatClient
+      .builder(chatModel)
+      .defaultToolCallbacks(toolCallbackProvider.getToolCallbacks())
+      .build();
+}
+The ChatClient class will act as our main entry point for interacting with our chat completion model, i.e., Claude Opus 4.5. + +Next, let’s inject the ChatClient bean in a controller class and expose a REST API: +
@PostMapping("/chat")
+ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest chatRequest) {
+    String answer = chatClient
+      .prompt()
+      .user(chatRequest.question())
+      .call()
+      .content();
+    return ResponseEntity.ok(new ChatResponse(answer));
+}
+
+record ChatRequest(String question) {
+}
+
+record ChatResponse(String answer) {
+}
+Here, we simply pass the user’s question to the chatClient bean and return the LLM's response. We’ll use this API endpoint to interact with our chatbot later in the tutorial. +

3.4. Handling Elicitation Requests

+When the MCP server initiates an elicitation request, the MCP client must have a mechanism to intercept this request and provide the necessary data. + +Let's define a method in our configuration class to handle these requests: +
private static final Logger log = LoggerFactory.getLogger(ChatbotConfiguration.class);
+
+@McpElicitation(clients = "author-server")
+ElicitResult handleElicitation(ElicitRequest elicitRequest) {
+    log.info("Elicitation requested: {}", elicitRequest.message());
+    log.info("Requested schema: {}", elicitRequest.requestedSchema());
+
+    return new ElicitResult(
+      ElicitResult.Action.ACCEPT,
+      Map.of(
+        "username", "john.smith",
+        "reason", "Contacting author for article feedback"
+      )
+    );
+}
+We annotate our handleElicitation() method with @McpElicitation, specifying the clients attribute as author-server to link it to the client we configured earlier in our application.properties file. + +When the MCP server triggers an elicitation request, this handler receives the ElicitRequest containing the message and requested schema. + +In our handler, we log the elicitation details and return an ElicitResult with the ACCEPT action along with the requested details. For our demonstration, we're returning hardcoded values. In a production application, the MCP client would typically prompt the user for this information. + +It's worth noting that MCP Elicitation also supports a URL mode, where the server directs users to an external URL to request sensitive details or perform protected actions. This ensures sensitive data never passes through the MCP client. However, at the time of this writing, Spring AI does not support URL mode elicitation. +

4. Interacting With Our Chatbot

+Now that we've built our MCP server and host application, let's interact with our chatbot and test the elicitation flow. + +We’ll use the HTTPie CLI to invoke the chatbot’s API endpoint: +
http POST :8080/chat question="Who wrote the article 'Testing CORS in Spring Boot?' on Baeldung, and how can I contact them?"
+Here, we send a simple question asking for details regarding the author who wrote a specific article. We deliberately don't specify the username or reason in our query to trigger the elicitation flow on the MCP server. + +Let’s see what we get as a response: +
{
+    "answer": "The article 'Testing CORS in Spring Boot' on Baeldung was written by John Doe. You can contact him via email at [john.doe@baeldung.com](mailto:john.doe@baeldung.com)."
+}
+As we can see, the chatbot successfully returns the author's details. + +Let's look at the logs of our chatbot to confirm the elicitation request was received: +
[2026-01-28 13:16:25] [INFO] [c.b.s.m.c.ChatbotConfiguration] - Elicitation requested: Baeldung username and reason required.
+[2026-01-28 13:16:25] [INFO] [c.b.s.m.c.ChatbotConfiguration] - Requested schema: {type=object, properties={reason={type=string}, username={type=string}}, required=[reason, username]}
+The logs confirm that our elicitation handler received the request from the MCP server, including the message and the expected schema for the response. + +Now, let's also examine the logs from our MCP server to confirm the complete flow: +
[2026-01-28 15:28:00] [INFO] [c.b.s.m.s.AuthorRepository] - Author requested for article: Testing CORS in Spring Boot
+[2026-01-28 15:28:00] [INFO] [c.b.s.m.s.AuthorRepository] - Article is premium, further information required
+[2026-01-28 15:28:00] [INFO] [c.b.s.m.s.AuthorRepository] - Required details missing, initiating elicitation
+[2026-01-28 15:28:00] [INFO] [c.b.s.m.s.AuthorRepository] - Elicitation accepted - username: john.smith, reason: Contacting author for article feedback
+[2026-01-28 15:28:00] [INFO] [c.b.s.m.s.AuthorRepository] - Access granted, returning author details
+Here, the tool detected that the article is premium, initiated an elicitation request to gather the missing parameters, received the hardcoded values from our elicitation handler, and finally executed the tool logic to return the author details. +

5. Conclusion

+In this article, we explored how to implement MCP Elicitations with Spring AI. + +We built an MCP server exposing a tool that requests additional information when accessing premium content. Then, we created an MCP host application with an elicitation handler that responds to these requests. + +Finally, we tested our implementation and verified the elicitation flow through the application logs. This pattern enables more interactive AI applications where tools can gather additional context from the user when needed. + +As always, all the code examples used in this article are available over on GitHub. +""" + +print(ARTICLE) \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/pom.xml b/spring-ai-modules/spring-ai-agent-skills/pom.xml new file mode 100644 index 000000000000..9baa7b46a2c4 --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + com.baeldung + spring-ai-modules + 0.0.1 + ../pom.xml + + + com.baeldung + spring-ai-agent-skills + 0.0.1 + spring-ai-agent-skills + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.ai + spring-ai-starter-model-openai + ${spring-ai.version} + + + org.springaicommunity + spring-ai-agent-utils + ${spring-ai-agent-utils.version} + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + 21 + 4.0.6 + 2.0.0-M6 + 0.7.0 + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/Application.java b/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/Application.java new file mode 100644 index 000000000000..b218f8019afa --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/Application.java @@ -0,0 +1,13 @@ +package com.baeldung.agentskills; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotConfiguration.java b/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotConfiguration.java new file mode 100644 index 000000000000..afeed7d69072 --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotConfiguration.java @@ -0,0 +1,26 @@ +package com.baeldung.agentskills; + +import org.springaicommunity.agent.tools.FileSystemTools; +import org.springaicommunity.agent.tools.ShellTools; +import org.springaicommunity.agent.tools.SkillsTool; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class ChatbotConfiguration { + + @Bean + ChatClient chatClient(ChatModel chatModel) { + return ChatClient + .builder(chatModel) + .defaultToolCallbacks(SkillsTool.builder() + .addSkillsDirectory(".openai/skills") + .build()) + .defaultTools(FileSystemTools.builder().build()) + .defaultTools(ShellTools.builder().build()) + .build(); + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotController.java b/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotController.java new file mode 100644 index 000000000000..2909d54a477a --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/src/main/java/com/baeldung/agentskills/ChatbotController.java @@ -0,0 +1,34 @@ +package com.baeldung.agentskills; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ChatbotController { + + private final ChatClient chatClient; + + ChatbotController(ChatClient chatClient) { + this.chatClient = chatClient; + } + + @PostMapping("/chat") + ResponseEntity chat(@RequestBody ChatRequest chatRequest) { + String answer = chatClient + .prompt() + .user(chatRequest.question) + .call() + .content(); + return ResponseEntity.ok(new ChatResponse(answer)); + } + + record ChatRequest(String question) { + } + + record ChatResponse(String answer) { + } + +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/src/main/resources/application.yaml b/spring-ai-modules/spring-ai-agent-skills/src/main/resources/application.yaml new file mode 100644 index 000000000000..1f3b6f0062ac --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/src/main/resources/application.yaml @@ -0,0 +1,7 @@ +spring: + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-5.5 \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-agent-skills/src/test/java/com/baeldung/agentskills/AgentSkillLiveTest.java b/spring-ai-modules/spring-ai-agent-skills/src/test/java/com/baeldung/agentskills/AgentSkillLiveTest.java new file mode 100644 index 000000000000..9c2eea1d739d --- /dev/null +++ b/spring-ai-modules/spring-ai-agent-skills/src/test/java/com/baeldung/agentskills/AgentSkillLiveTest.java @@ -0,0 +1,54 @@ +package com.baeldung.agentskills; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class AgentSkillLiveTest { + + private static final String API_PATH = "/chat"; + private static final String REQUEST_BODY_TEMPLATE = """ + { + "question": "%s" + } + """; + + @Autowired + private MockMvc mockMvc; + + @Test + void whenArticleSummaryRequested_thenResponseContainsExpectedSections() throws Exception { + String requestBody = REQUEST_BODY_TEMPLATE.formatted( + "Can you summarize the following article: https://www.baeldung.com/sample-non-existing-article" + ); + + MvcResult result = mockMvc + .perform(post(API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isOk()) + .andReturn(); + + String response = result + .getResponse() + .getContentAsString() + .toLowerCase(); + + assertThat(response) + .contains("tl;dr", "key points", "bottom line"); + } + +} \ No newline at end of file