Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion genai-function-calling/spring-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,26 @@ Run maven after setting ENV variables like this:
./shdotenv ./mvnw -q clean package exec:exec
```

## Run with Model Context Protocol (MCP)

[Mcp.java](src/main/java/example/Mcp.java) includes code needed to decouple
tool invocation via the [Model Context Protocol (MCP) flow][flow-mcp]. To run
using MCP, adjust arguments to `docker compose run` or `./shdotenv ./mvnw`.

For example, to run with Docker:
```bash
docker compose run --build --rm genai-function-calling --mcp
```

Or to run with Maven:
```bash
./shdotenv ./mvnw -q clean package exec:exec -Dtools=mcp
```

## Notes

The LLM should generate something like "The latest stable version of
Elasticsearch is 8.17.4", unless it hallucinates. Just run it again, if you
Elasticsearch is 8.18.0", unless it hallucinates. Just run it again, if you
see something else.

Spring AI uses Micrometer which bridges to OpenTelemetry, but needs a few
Expand Down Expand Up @@ -80,3 +96,4 @@ OTEL_INSTRUMENTATION_MICROMETER_ENABLED=true
---
[flow]: ../README.md#example-application-flow
[spring-ai]: https://github.com/spring-projects/spring-ai/
[flow-mcp]: ../README.md#model-context-protocol-flow
10 changes: 10 additions & 0 deletions genai-function-calling/spring-ai/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0-M7</spring-ai.version>
<tools/>
</properties>
<dependencies>
<dependency>
Expand All @@ -30,6 +31,14 @@
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
Expand Down Expand Up @@ -110,6 +119,7 @@
also adding the project build directory -->
<classpath/>
<argument>example.Main</argument>
<argument>--${tools}</argument>
</arguments>
</configuration>
</plugin>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package example;

import java.util.Comparator;
import java.util.List;

import jakarta.annotation.Nullable;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

import jakarta.annotation.Nullable;
import java.util.Comparator;
import java.util.List;


@Component
Expand All @@ -33,11 +32,11 @@ String getLatestElasticsearchVersion(@ToolParam(description = "Major version to
// Filter out non-release versions (e.g. -rc1) and remove " GA" suffix
.map(release -> release.version().replace(" GA", ""))
.filter(version -> !version.contains("-"))
.filter(version -> {
if (majorVersion == null) {
return true;
}
return version.startsWith(majorVersion + ".");
.filter(version -> {
if (majorVersion == null) {
return true;
}
return version.startsWith(majorVersion + ".");
})
// "8.9.1" > "8.10.0", so coerce to an integer: 80901 < 81000
.max(Comparator.comparingInt(v -> {
Expand Down
103 changes: 52 additions & 51 deletions genai-function-calling/spring-ai/src/main/java/example/Main.java
Original file line number Diff line number Diff line change
@@ -1,67 +1,68 @@
package example;

import io.micrometer.tracing.annotation.NewSpan;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.GlobalOpenTelemetry;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import io.opentelemetry.api.OpenTelemetry;
import org.springframework.ai.model.SpringAIModelProperties;
import org.springframework.ai.model.SpringAIModels;
import org.springframework.ai.model.tool.DefaultToolCallingChatOptions;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.context.annotation.Import;

@SpringBootApplication
public class Main {
import java.util.Arrays;

@Component
static class VersionAgent implements CommandLineRunner {
/**
* Runs {@linkplain VersionAgent} with {@linkplain ElasticsearchTools}.
*/
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({VersionAgent.class, ElasticsearchTools.class})
class Main {
// Use javaagent instead of spring for configuring OpenTelemetry
@Bean
OpenTelemetry openTelemetry() {
return GlobalOpenTelemetry.get();
}

private final ChatClient chat;
private final ElasticsearchTools tools;
@Bean
public ToolCallbackProvider elasticsearchToolCallbackProvider(ElasticsearchTools elasticsearchTools) {
return MethodToolCallbackProvider.builder().toolObjects(elasticsearchTools).build();
}

VersionAgent(ChatModel chat, ElasticsearchTools tools) {
this.chat = ChatClient.builder(chat).build();
this.tools = tools;
}

@Override
// Without a root span, we get multiple traces and can't understand the multiple requests being made.
// Currently, no automatic root span is created for the CommandLineRunner so we do it ourselves.
// https://github.com/spring-projects/spring-ai/issues/1440
@NewSpan("version-agent")
public void run(String... args) {
String answer = chat.prompt()
.user("What is the latest version of Elasticsearch 8?")
.tools(tools)
.options(DefaultToolCallingChatOptions.builder().temperature(0.0).build())
.call()
.content();
public static void main(String[] args) {
// We use a common entrypoint so that we can launch with the same args
// regardless of if this is a client or server. If we didn't, we would
// need to search through the args and replace McpClientAgent with
// McpServer.
if (Arrays.asList(args).contains("--mcp-server")) {
McpServer.main(args);
} else if (Arrays.asList(args).contains("--mcp")) {
McpClientAgent.main(args);
} else {
run(Main.class, args,
"spring.ai.mcp.client.enabled=false",
"spring.ai.mcp.server.enabled=false"
);
}
}

System.out.println(answer);
}
}

// Use javaagent instead of spring for configuring OpenTelemetry
@Bean
OpenTelemetry openTelemetry() {
return GlobalOpenTelemetry.get();
}

public static void main(String[] args) {
// Choose between Azure OpenAI and OpenAI based on the presence of the official SDK
static void run(Class<?> source, String[] args, String... defaultProperties) {
// Choose between Azure OpenAI and OpenAI based on the presence of the official SDK
// environment variable AZURE_OPENAI_API_KEY. Otherwise, we'd create two beans.
String azureApiKey = System.getenv("AZURE_OPENAI_API_KEY");
String chatModel = azureApiKey != null && !azureApiKey.trim().isEmpty()
? SpringAIModels.AZURE_OPENAI
: SpringAIModels.OPENAI;
new SpringApplicationBuilder(Main.class)
.properties(SpringAIModelProperties.CHAT_MODEL + "=" + chatModel)
.run(args);
}
String azureApiKey = System.getenv("AZURE_OPENAI_API_KEY");
String chatModel = azureApiKey != null && !azureApiKey.trim().isEmpty()
? SpringAIModels.AZURE_OPENAI
: SpringAIModels.OPENAI;

String[] properties = Arrays.copyOf(defaultProperties, defaultProperties.length + 1);
properties[defaultProperties.length] = SpringAIModelProperties.CHAT_MODEL + "=" + chatModel;

new SpringApplicationBuilder(source)
.properties(properties)
.run(args);
}
}
123 changes: 123 additions & 0 deletions genai-function-calling/spring-ai/src/main/java/example/Mcp.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package example;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.OpenTelemetry;
import org.springframework.ai.mcp.client.autoconfigure.properties.McpStdioClientProperties;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.ByteArrayResource;

import java.nio.charset.StandardCharsets;

/**
* Launches {@linkplain McpServer} in a subprocess and runs
* {@linkplain VersionAgent} with its tools via MCP stdio client.
*/
@SpringBootConfiguration
@EnableAutoConfiguration
@Import(VersionAgent.class)
class McpClientAgent {
// Use javaagent instead of spring for configuring OpenTelemetry
@Bean
OpenTelemetry openTelemetry() {
return GlobalOpenTelemetry.get();
}

/**
* Generates MCP server configuration in <a href="https://modelcontextprotocol.io/quickstart/user">Claude Desktop Format</a>.
* <p/>
* Notably, this takes the existing process command, args, and env, and
* appends "--mcp-server" to the args. Since this process was launched via
* {@linkplain Main#main(String[])}, the additional arg will route the MCP
* server subprocess correctly to {@linkplain McpServer}.
*/
String mcpServersConfiguration() throws Exception {
ObjectMapper mapper = new ObjectMapper();
ObjectNode root = mapper.createObjectNode();
ObjectNode serverConfig = mapper.createObjectNode();

// Set up the configuration structure
root.set("mcpServers", mapper.createObjectNode().set("elasticsearch-versions", serverConfig));

// Get process information
ProcessHandle.Info info = ProcessHandle.current().info();

// Add command
serverConfig.put("command",
info.command().orElseThrow(() -> new IllegalStateException("Cannot get command of current process")));

// Add arguments with "--mcp-server" appended
ArrayNode argsNode = mapper.createArrayNode();
String[] args = info.arguments().orElseThrow(() -> new IllegalStateException("Cannot get arguments of current process"));
for (String arg : args) {
argsNode.add(arg);
}
argsNode.add("--mcp-server");
serverConfig.set("args", argsNode);

// Add environment variables
serverConfig.set("env", mapper.valueToTree(System.getenv()));

// Serialize to pretty-printed JSON
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root);
}

/**
* Overrides {@link McpStdioClientProperties} defined by properties with
* dynamic values, notably that derive MCP server launch configuration from
* the current process.
*/
@Bean
@Primary
McpStdioClientProperties stdioClientProperties() throws Exception {
String mcpServersConfiguration = mcpServersConfiguration();

McpStdioClientProperties properties = new McpStdioClientProperties();
properties.setServersConfiguration(new ByteArrayResource(mcpServersConfiguration.getBytes(StandardCharsets.UTF_8)));
return properties;
}


static void main(String[] args) {
Main.run(McpClientAgent.class, args,
"spring.ai.mcp.client.enabled=true",
"spring.ai.mcp.client.toolcallback.enabled=true",
"spring.ai.mcp.server.enabled=false"
);
}
}

/**
* Starts an MCP server and registers {@linkplain ElasticsearchTools},
* listening on stdin/stdout.
*/
@SpringBootConfiguration
@EnableAutoConfiguration
@Import(ElasticsearchTools.class)
class McpServer {
// Use javaagent instead of spring for configuring OpenTelemetry
@Bean
OpenTelemetry openTelemetry() {
return GlobalOpenTelemetry.get();
}

@Bean
public ToolCallbackProvider elasticsearchToolCallbackProvider(ElasticsearchTools elasticsearchTools) {
return MethodToolCallbackProvider.builder().toolObjects(elasticsearchTools).build();
}

static void main(String[] args) {
Main.run(McpServer.class, args,
"spring.ai.mcp.client.enabled=false",
"spring.ai.mcp.server.stdio=true"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package example;

import io.micrometer.tracing.annotation.NewSpan;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.tool.DefaultToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;

@Component
class VersionAgent implements CommandLineRunner {

private final ChatClient chat;
private final ToolCallbackProvider tools;
private final ConfigurableApplicationContext context;

VersionAgent(ChatModel chat, ToolCallbackProvider tools, ConfigurableApplicationContext context) {
this.chat = ChatClient.builder(chat).build();
this.tools = tools;
this.context = context;
}

@Override
// Without a root span, we get multiple traces and can't understand the multiple requests being made.
// Currently, no automatic root span is created for the CommandLineRunner so we do it ourselves.
// https://github.com/spring-projects/spring-ai/issues/1440
@NewSpan("version-agent")
public void run(String... args) {
String answer = chat.prompt()
.user("What is the latest version of Elasticsearch 8?")
.tools(tools)
.options(DefaultToolCallingChatOptions.builder().temperature(0.0).build())
.call()
.content();

System.out.println(answer);
context.close(); // See https://github.com/spring-projects/spring-ai/issues/2756
}
}