Spring AI MCP Client-Server with SSE Transport

MCP Client Server with SSE transport to manage local KinD cluster resources
In this blog have tried to use Spring AI MCP with SSE transport to manage local KinD Kubernetes cluster. This is a follow up of previous blog Spring AI MCP Client Server using STDIO transport.
The Spring AI MCP Server code shown below includes set of functionality annotated with @Tool
in service layer. These functionality will use Kubernetes Java client to manage the local KinD cluster resources. Like in previous blog have used the Ollama service running in docker with llama-3.2 model. And accessible in http://localhost:11434
.
Pre-requisites:
Docker Desktop
Kind CLI
- KinD cluster created using
kind create cluster --name sample
- KinD cluster created using
Java IDE (IntelliJ Idea community edition)
Overview
With STDIO
transport when running the MCP client the server is configured to run with java command, but in case of SSE
transport the MCP server runs as standalone application (or accessed using HTTP). Spring provides OAuth configuration to secure the MCP server which is not explained here. For SSE
Spring AI by default exposes the endpoint at /sse
.
Info:
With KinD CLI, the kube config file will be updated and placed in default .kube folder. This kube config file is reqired by the Kubernetes Java client to connect and manage the resources. When running the MCP Server from IDE like IntelliJ Idea set the kube config path in environment variable
KUBECONFIG
. Also note, if there are more than one KinD cluster set appropriate context for the server to access. Refer the Kubernetes documentation for this specific details.In my local machine, the docker daemon is configured in WSL2 with Ubuntu-24.04 distro. So the environment configured looks like
KUBECONFIG=\\wsl.
localhost
\Ubuntu-24.04\home\<user-name>\.kube\config
. When running the Spring Application class in IntelliJ IDE set the environment variables usingModify Run configuration
.
MCP Server code
The pom.xml
for the project is listed below, the spring-ai-starter-mcp-server-webmvc
dependency is added. Please refer the Spring documentation for production grade implementations.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.k8s.mcp</groupId>
<artifactId>k8s</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>k8s</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>24</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>io.kubernetes</groupId>
<artifactId>client-java</artifactId>
<version>24.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</pluginRepository>
</pluginRepositories>
</project>
- The code with functionality annotated with
@Tool
in Service layer. The code logic is self-explanatory. As mentioned in the previous blog, the Tools annotation requiresname
anddescription
which is used by the MCP to detetmine the context when using the local LLM in this case.
//# filename: K8sService.java
package com.k8s.mcp.k8s.service;
import com.k8s.mcp.k8s.data.K8sNamespaceInfo;
import com.k8s.mcp.k8s.data.K8sPodInfo;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.Configuration;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1Namespace;
import io.kubernetes.client.openapi.models.V1NamespaceList;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1PodList;
import io.kubernetes.client.util.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class K8sService {
private static final Logger LOGGER = LoggerFactory.getLogger(K8sService.class);
ApiClient client;
CoreV1Api api;
public K8sService() {
try {
client = Config.defaultClient(); // for local use
} catch (IOException e) {
LOGGER.error("exception occurred accessing client ",e);
throw new RuntimeException(e);
}
Configuration.setDefaultApiClient(client);
api = new CoreV1Api();
}
@Tool(name="get_pods_from_given_namespace",
description = "This function will return the list of pods for a given namespace " +
"from the kubernetes cluster")
public K8sPodInfo getPods(String namespace){
K8sPodInfo podInfo;
if (namespace == null || namespace.isBlank()){ namespace = "default"; }
try {
V1PodList podList = api.listNamespacedPod(namespace).execute();
List<String> podNames = podList.getItems().stream()
.map(pod -> pod.getMetadata().getName())
.toList();
podInfo = new K8sPodInfo(namespace,podNames);
} catch (ApiException e) {
LOGGER.error("Exception listing pod",e);
throw new RuntimeException(e);
}
return podInfo;
}
@Tool(name="get_all_pods_in_cluster",
description = "This function will list all the pods from the cluster")
public List<K8sPodInfo> getAllPodsFromCluster(){
List<K8sPodInfo> allPodInfo = new ArrayList<>();
try{
Map<String,List<String>> nsPodNames = new HashMap<>();
V1PodList podList = api.listPodForAllNamespaces().execute();
podList.getItems().forEach(pod -> {
String podName = pod.getMetadata().getName();
String ns = pod.getMetadata().getNamespace();
nsPodNames.putIfAbsent(ns, new ArrayList<String>());
nsPodNames.get(ns).add(podName);
}
);
nsPodNames.forEach((ns,pods)->{
allPodInfo.add(new K8sPodInfo(ns,pods));
});
} catch (ApiException e) {
LOGGER.error("Exception accessing all pods from namespace", e);
throw new RuntimeException(e);
}
return allPodInfo;
}
@Tool(name="get_all_namespace",
description = "This function will return list of the namespaces from the kubernetes cluster")
public K8sNamespaceInfo getNamespaces(){
K8sNamespaceInfo k8sNamespaceInfo;
try {
V1NamespaceList namespaceList = api.listNamespace().execute();
List<String> nameSpaces = namespaceList.getItems().stream()
.filter(ns -> ns.getMetadata() != null)
.map( ns -> ns.getMetadata().getName())
.toList();
k8sNamespaceInfo = new K8sNamespaceInfo(nameSpaces);
} catch (ApiException e) {
LOGGER.error("Exception fetching namespace ",e);
throw new RuntimeException(e);
}
return k8sNamespaceInfo;
}
@Tool(name="create_namespace",
description = "This function will create a namespace in the kubernetes cluster" +
" and return the list of namespace available in the cluster as response with created namespace")
public K8sNamespaceInfo K8sCreateNamespace(String namespace){
LOGGER.info("Creating namespace named : {}",namespace);
if(namespace == null || namespace.isBlank()){
throw new RuntimeException("namespace can't be empty or blank");
}
V1Namespace nsObject = new V1Namespace();
V1ObjectMeta nsMetadata = new V1ObjectMeta();
nsMetadata.setName(namespace);
nsObject.setMetadata(nsMetadata);
CoreV1Api.APIcreateNamespaceRequest nsRequest = api.createNamespace(nsObject);
try {
nsRequest.execute();
} catch (ApiException e) {
LOGGER.error("error creating namespace ",e);
throw new RuntimeException(e);
}
return getNamespaces();
}
}
- Spring Bean configuration to registering the service to the spring context.
package com.k8s.mcp.k8s.config;
import com.k8s.mcp.k8s.service.K8sService;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class McpServerToolConfig {
@Bean
public ToolCallbackProvider toolCallbackProvider(K8sService k8sService) {
return MethodToolCallbackProvider
.builder()
.toolObjects(k8sService)
.build();
}
}
- Pojo classes used to hold the resource information form the cluster which will be sent part of response
//# filename: K8sNamespaceInfo.java
package com.k8s.mcp.k8s.data;
import java.util.List;
public record K8sNamespaceInfo(List<String> namespaces) {}
//#filename: K8sPodInfo
package com.k8s.mcp.k8s.data;
import java.util.List;
public record K8sPodInfo(String namespace, List<String> podNames) {}
- Entry point Spring Application
package com.k8s.mcp.k8s;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class K8sApplication {
public static void main(String[] args) {
SpringApplication.run(K8sApplication.class, args);
}
}
application.yaml
file of server, the properties in below commented as default can be removed for the code to work it is optional.
spring:
application:
name: k8s-mcp-server
main:
banner-mode: off
ai:
mcp:
server:
enabled: true
name: k8s-mcp-server
version: 1.0.0
resource-change-notification: true # default
tool-change-notification: true # default
prompt-change-notification: true # default
type: sync # default
capabilities: # default
completion: true # default
prompt: true # default
resource: true # default
tool: true # default
logging:
level:
io.modelcontextprotocol: TRACE
org.springframework.ai.mcp: TRACE
Testing server endpoint
Curl command
Once the server application is up and running, we can access using cURL command
Executing below the curl command we would connect to the server
sse
endpoint and continuously listens for event
$ curl http://localhost:8080/sse
id:f087df6d-795d-472c-8313-80f41ed76c56
event:endpoint
data:/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56
The lifecycle of MCP connection can be found in Model Context protocol documentation
To connect to the server with curl command, we use the below url, which is part of the response from the above
- Open another git bash instance to initialize the transaction
curl -XPOST http://localhost:8080/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": {
"listChanged": true
},
"sampling": {}
},
"clientInfo": {
"name": "ExampleClient",
"version": "1.0.0"
}
}
}'
- Upon executing above initialize method on the endpoint, we could see response on the sse endpoint prompt like below few lines
$ curl http://localhost:8080/sse
id:f087df6d-795d-472c-8313-80f41ed76c56
event:endpoint
data:/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56
id:f087df6d-795d-472c-8313-80f41ed76c56
event:message
data:{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"k8s-mcp-server","version":"1.0.0"}}}
- Upon executing below command, since below is a notification to the cluster there won't be any response just the 200 OK
curl -XPOST http://localhost:8080/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}'
- With below curl command we can list the tools
curl -XPOST http://localhost:8080/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}'
- Executing above curl command with tools list method we could see the list of tools in the
sse
endpoint prompt as response, refer the last few lines with response
$ curl http://localhost:8080/sse
id:f087df6d-795d-472c-8313-80f41ed76c56
event:endpoint
data:/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56
id:f087df6d-795d-472c-8313-80f41ed76c56
event:message
data:{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"k8s-mcp-server","version":"1.0.0"}}}
id:f087df6d-795d-472c-8313-80f41ed76c56
event:message
data:{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_all_namespace","description":"This function will return list of the namespaces from the kubernetes cluster","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false}},{"name":"get_all_pods_in_cluster","description":"This function will list all the pods from the cluster","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false}},{"name":"create_namespace","description":"This function will create a namespace in the kubernetes cluster and return the list of namespace available in the cluster as response with created namespace","inputSchema":{"type":"object","properties":{"namespace":{"type":"string"}},"required":["namespace"],"additionalProperties":false}},{"name":"get_pods_from_given_namespace","description":"This function will return the list of pods for a given namespace from the kubernetes cluster","inputSchema":{"type":"object","properties":{"namespace":{"type":"string"}},"required":["namespace"],"additionalProperties":false}}]}}
- To call the functionality we can execute below command in different git bash, upon executing the response will be displayed in the prompt where the
sse
is listening
curl -XPOST http://localhost:8080/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56 -H "Content-Type: application/json" -d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "get_pods_from_given_namespace",
"arguments": {
"namespace": "apisix"
}
},
"id": 2
}'
- Executing the command above we could see response on the bash where we executed
sse
endpoint
$ curl http://localhost:8080/sse
id:f087df6d-795d-472c-8313-80f41ed76c56
event:endpoint
data:/mcp/message?sessionId=f087df6d-795d-472c-8313-80f41ed76c56
id:f087df6d-795d-472c-8313-80f41ed76c56
event:message
data:{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"k8s-mcp-server","version":"1.0.0"}}}
id:f087df6d-795d-472c-8313-80f41ed76c56
event:message
data:{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_all_namespace","description":"This function will return list of the namespaces from the kubernetes cluster","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false}},{"name":"get_all_pods_in_cluster","description":"This function will list all the pods from the cluster","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false}},{"name":"create_namespace","description":"This function will create a namespace in the kubernetes cluster and return the list of namespace available in the cluster as response with created namespace","inputSchema":{"type":"object","properties":{"namespace":{"type":"string"}},"required":["namespace"],"additionalProperties":false}},{"name":"get_pods_from_given_namespace","description":"This function will return the list of pods for a given namespace from the kubernetes cluster","inputSchema":{"type":"object","properties":{"namespace":{"type":"string"}},"required":["namespace"],"additionalProperties":false}}]}}
id:f087df6d-795d-472c-8313-80f41ed76c56
event:message
data:{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"namespace\":\"apisix\",\"podNames\":[\"apisix-85fcc8d665-xrx2w\",\"apisix-dashboard-68b7748c98-sbcqv\",\"apisix-etcd-0\",\"apisix-etcd-1\",\"apisix-etcd-2\",\"apisix-ingress-controller-f6fb8548-bgdnn\"]}"}],"isError":false}}
MCP inspector
To start the MCP inspector use below command, this will open up the browser
npx @modelcontextprotocol/inspector
- From the browser select below configuration values
Transport type: SSE
URL: http://localhost:8080/sse
Select Tools, and list the tools, if the curl command to sse is enabled terminate that connection else you might receive timeout message in case of sync connection.
MCP client code
- pom.xml for client with mcp client and ollama dependencies
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.k8s.client</groupId>
<artifactId>k8s-client</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>k8s-client</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>24</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- The controller class
package com.k8s.client.k8s_client;
import io.modelcontextprotocol.client.McpSyncClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/input")
public class InputController {
private static final Logger log = LoggerFactory.getLogger(InputController.class);
private final ChatClient chatClient;
private final List<McpSyncClient> mcpSyncClients;
public InputController(ChatClient.Builder chatClientBuilder,
ToolCallbackProvider toolCallbackProvider,
List<McpSyncClient> mcpSyncClients){
this.chatClient = chatClientBuilder.build();
this.mcpSyncClients = mcpSyncClients;
printToolInfoFromServer(toolCallbackProvider);
}
@PostMapping("/in")
public String input(@RequestBody String inputData){
log.info("input data received - {}",inputData);
return chatClient.prompt()
.user(inputData)
.toolCallbacks(new SyncMcpToolCallbackProvider(mcpSyncClients))
.call()
.content();
}
private static void printToolInfoFromServer(ToolCallbackProvider toolCallbackProvider) {
List<ToolCallback> toolCallbacks = List.of(toolCallbackProvider.getToolCallbacks());
if(toolCallbacks.isEmpty()){
log.warn("No tools found");
} else {
System.out.println("**************************************");
for (ToolCallback toolCallback : toolCallbacks){
ToolDefinition toolDefinition = toolCallback.getToolDefinition();
System.out.println("Tool Name: "+toolDefinition.name());
System.out.println(" |___ Description: "+toolDefinition.description());
System.out.println(" |___ Input Schem: "+toolDefinition.inputSchema());
System.out.println("__________________________________");
}
System.out.println("**************************************");
}
}
}
- Entry point of spring application class
package com.k8s.client.k8s_client;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class K8sClientApplication {
public static void main(String[] args) {
SpringApplication.run(K8sClientApplication.class, args);
}
}
- Resource configuration
#application.yaml
spring:
application:
name: k8s-client
ai:
mcp:
client:
enabled: true
toolcallback:
enabled: true
name: k8s-mcp-client
version: 1.0.0
request-timeout: 30s
type: SYNC # or ASYNC for reactive applications
sse:
connections:
server1:
url: http://localhost:8080
ollama:
base-url: http://localhost:11434
chat:
options:
model: llama3.2
logging:
level:
io:
modelcontextprotocol:
client: trace
spec: trace
server:
port: 8085
Output
List of pods from the kind cluster
kubectl get pods -A
List of namespace from theWith
curl
http://localhost:8085/input/in
-d 'get me the list of pods from all namespace'
the output snapshot looks like belowwith
curl
http://localhost:8085/input/in
-d 'get me the pods from apisix namespace'
the output snapshot looks like belowwith
curl
http://localhost:8085/input/in
-d 'create a new namespace named test-k8s-mcp in the cluster'
the output looks like below.Note, the response from the functionality returns list of namespace after namespace is created but the here client displays the message not the list of namespaces. This probably the description test in the
@Tool
annotation. Which can be modified to be more explicit if needed.
Complete code from github
Subscribe to my newsletter
Read articles from Thirumurthi S directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
