Sitemap

Building an MCP Server with Spring AI (and Testing with Claude Desktop)

8 min readSep 8, 2025

A deep dive into MCP server development in Spring Boot, with practical testing using Claude Desktop as the MCP client

Press enter or click to view image in full size
Photo by Anton Savinov on Unsplash

You can read my previous blog post to learn more details about The Model Context Protocol (MCP) — Introduction to MCP: Architecture, Java SDK, and Spring AI M6 Integration:

In this post, I will be sharing several important steps for implementing an MCP server using Spring AI “spring-ai.version — 1.0.1” and testing it with “Claude Desktop” app (acting as our MCP client).

The tools implemented in the project will allow me to login to my account in Mailim (with OTP verification), refresh token if required, list unread e-mails (with paging) and read the details of an e-mail with the given id. WebClient will be used for making API requests. WebClient is a reactive web client introduced in Spring 5.

Implementation Details

This is my project folder structure:

Project Folder Structure

A “session id — user session data” mapping should be saved. Previously, it was possible to get it directly but currently we can’t; so I used the code snippet from the following StackOverflow post:

I created a utility class named “SessionIdUtils.java”:

public class SessionIdUtils {

static final Field exchangeField;
static final Field sessionField;
static final Field idField;

static {
exchangeField = getField(McpSyncServerExchange.class, "exchange");
sessionField = getField(McpAsyncServerExchange.class, "session");
idField = getField(McpServerSession.class, "id");
}

static Field getField(Class<?> clazz, String fieldName) {
try {
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
return field;
} catch (Exception e) {
return null;
}
}


public static String getSessionId(McpSyncServerExchange syncServerExchange) {
try {
McpAsyncServerExchange asyncServerExchange = (McpAsyncServerExchange) exchangeField.get(syncServerExchange);
McpServerSession session = (McpServerSession) sessionField.get(asyncServerExchange);
return (String) idField.get(session);
} catch (Exception e) {
return null;
}
}
}

Now I am able to keep sessionState (required user input or other parameters/variables for further tool calls such as auth token, device id, user mail, etc.) of the user, associated with sessionId:

@Service
public class SessionRegistryService {

// you can use a database or Redis, but for this demo we'll just keep it in memory
private final ConcurrentHashMap<String, SessionState> sessionRegistry = new ConcurrentHashMap<>();

public SessionState addState(McpSyncServerExchange exchange) {
return sessionRegistry.computeIfAbsent(SessionIdUtils.getSessionId(exchange), k -> new SessionState());
}
}

I created a class file named “MailimTools” under “tools” directory of my project, adding “@Service” annotation to the top. The following is a method annotated with “@Tool” within “MailimTools”. The “description” attribute explains what the tool does in natural language. It’s what clients will show to users when listing available tools. You can now perform your actions related to your tool (call a service, etc.) within the method.

By default, parameters may be considered required unless otherwise specified. By setting “required=false”, we allow the tool to provide default behavior when no value is supplied.

The “ToolContext” parameter is where you can keep per-connection state, such as authentication tokens, session details, or metadata about the current client request. It’s not passed by the user but injected automatically by the MCP framework.

    @Tool(name = "bind", description = "Bind this MCP connection to a username (email). If omitted, uses env MAILIM_EMAIL.")
public String bind(
@ToolParam(description = "Username (e.g., email). If empty, will fall back to env MAILIM_EMAIL", required = false) String username,
ToolContext toolContext
) {
SessionState sessionState = getSessionState(toolContext);

String envEmail = ToolsUtils.getEnvironmentVariableByKey(ToolsConstants.ENV_MAILIM_EMAIL_KEY);
if (!StringUtils.hasText(username)) {
username = envEmail;
}

// if not provided via env or request param
if (!StringUtils.hasText(username)) {
return ToolsConstants.STATUS_BIND_FAILED + ": no username provided and MAILIM_EMAIL not set";
}

sessionState.username = username;
sessionState.email = username;

sessionState.deviceId = ToolsUtils.getDeviceId(sessionState);

return ToolsConstants.STATUS_BOUND + " username=" + sessionState.username + " deviceId=" + sessionState.deviceId;
}

To register our tools with the MCP framework, we need to add a bean definition:

@Configuration
public class McpServerConfig {

@Bean(name = "mailimToolCallbacks")
public List<ToolCallback> mailimToolCallbacks(MailimTools mailimTools) {
return List.of(ToolCallbacks.from(mailimTools));
}
}

The “ToolCallbacks.from()” method scans the service class (that contains our tools) for “@Tool” annotations and registers them with the MCP framework.

Later on, we add the following properties to our “application.properties” file:

spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=

We need to disable the banner and the console logging to allow the STDIO transport to work. This is crucial.

STDIO is used when both the MCP client and server are installed on the same machine. Since we are using STDIO transport for MCP, we don’t need a web server so we disable it.

For server type, I use “SYNC” since I am using WebClient with block.

MCP server name and version is the identification info of our MCP server for the clients.

#--- MCP SERVER CONFIGURATION ---#
spring.ai.mcp.server.name=mailim-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.stdio=true
# Server type: SYNC (blocking handler) or ASYNC (reactive handler)
spring.ai.mcp.server.type=SYNC
# Advertise tool capability (so clients can call @Tool methods)
spring.ai.mcp.server.capabilities.tool=true

For logging, you can keep using console logs directed to STDERR (required for MCP STDIO). You should not print to STDOUT, as it may corrupt the MCP STDIO protocol stream.

The following are the properties I added to my “application.properties” file related with logging levels:

#==== LOGGING PROPERTIES ======#
logging.level.root=ERROR
# Send your package logs at INFO (will be routed by logback.xml to STDERR)
logging.level.dev.nils.mailim.mcp=INFO
# Reactor Netty HTTP client (headers + body if wiretap is on) - for dev : DEBUG
logging.level.reactor.netty.http.client=DEBUG
# Connection lifecycle, I/O details - for dev : DEBUG
logging.level.reactor.netty.transport=DEBUG
#optional; raise to DEBUG for deeper Netty internals - for dev : INFO
logging.level.io.netty=INFO

The following is my “logback.xml” file:

<configuration>
<!-- Send console logging to STDERR, not STDOUT -->
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%date %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<root level="WARN">
<appender-ref ref="STDERR"/>
</root>

<logger name="dev.nils.mailim.mcp" additivity="false">
<appender-ref ref="STDERR"/>
</logger>
</configuration>

Keep in mind; if a level is set in application.properties, that level wins for that logger.

I did not proceed with the following option for now but if you would like to create a standalone binary that doesn’t require Java to be installed on the target system, you can create a native executable using Spring Native and GraalVM:

<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>

You can read more about GraalVM in my blog post:

Now, using the terminal, you can run the following command to prepare our .jar file (by cleaning out old build files, forcing Maven to update any SNAPSHOT dependencies from remote repositories, then recompiling, testing, and packaging the application into its distributable artifact):

mvn -U clean package

Testing with Claude Desktop

After installing Claude Desktop, to add your MCP server, open “Settings > Desktop app > Developer” and click on “Edit Config”:

Press enter or click to view image in full size
Claude Desktop — Developer Settings

It will create and open a file named “claude_desktop_config.json” located under “~/Library/Application Support/Claude”. You can also create it beforehand, manually under the same folder.

Its initial content is:

{
"mcpServers": {}
}

I will add my MCP server config and the final content will be like the following (replace ***PATH_TO_JAR_FILE*** with the .jar file path such as “/Users/senoritadev/dev/workspace/mcp/target/mcp-0.0.1-SNAPSHOT.jar”):

{
"mcpServers": {
"ym-mcp-server": {
"command": "/opt/homebrew/Cellar/openjdk@21/21.0.7/bin/java",
"args": [
"-Dio.netty.resolver.dns.useJdkDnsServerAddressStreamProvider=true",
"-Dvertx.disableDnsResolver=true",
"-Djava.net.preferIPv4Stack=true",
"-jar",
"***PATH_TO_JAR_FILE***"
],
"env": {
"MAILIM_EMAIL": "YOUR_MAIL_ADDRESS",
"MAILIM_PASSWORD": "YOUR_PASSWORD"
}
}
}
}

Problems I Encountered:

I added “-Dvertx.disableDnsResolver=true -Djava.net.preferIPv4Stack=true” JVM options to “args” list because I was getting an error like “ERROR: Failed to resolve WebClient” (I found the solution in the following StackOverflow post).

I added “-Dio.netty.resolver.dns.useJdkDnsServerAddressStreamProvider=true” because I was getting “DnsNameResolverTimeoutException”.

I also added the following dependency in my pom.xml:

<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver-dns-native-macos</artifactId>
<version>${netty.version}</version>
<classifier>osx-aarch_64</classifier>
<scope>runtime</scope>
</dependency>

Save the config file and quit Claude Desktop. Reopen the app and visit the same menu again. If it fails to load, the screen will look like the following (with state = “failed”):

Press enter or click to view image in full size

By clicking on “Open Logs Folder” button, you can inspect “mcp.log” file and also your MCP server’s own log file (mine is named as “mcp-server-ym-mcp-server.log”)

If successful, you will see state as “running”:

Press enter or click to view image in full size

I have named my MCP server as “ym-mcp-server” in Claude’s “claude_desktop_config.json” config file. You can see the MCP server app jar labelled with that name in “Local MCP servers” list. Claude Desktop doesn’t currently show a “tools catalog” UI.

You (as the user of the MCP client, then) can add environment variables under “env” and use them in your code like:

String envEmail = System.getenv("MAILIM_EMAIL");

To trail your MCP server logs, navigate to “~/Library/Logs/Claude/” path.

Press enter or click to view image in full size

You will come across “mcp.log” and “mcp-server-ym-mcp-server.log” files in the folder. You can inspect both of them. You will see logs starting with “Message from client:” or “Message from server:” giving info about which tool is called or if server is started successfully, etc. As understood from its name, “mcp-server-ym-mcp-server.log” file keeps the logs you defined with your “logback.xml” and “application.properties.file”.

From now on, you only need to restart Claude Desktop each time you make a change in your code & build.

As you start a new chat and create prompts that will likely trigger the related tools, a confirmation modal will be opened by Claude Desktop app, asking for your permission to run the tool:

Press enter or click to view image in full size

Here are some screenshots:

Press enter or click to view image in full size
MCP Server — Login Tool
Press enter or click to view image in full size
MCP Server — Otp Verify Tool
Press enter or click to view image in full size
MCP Server —List Unread Inbox Tool
Press enter or click to view image in full size
MCP Server —Get Message Details Tool — 1
Press enter or click to view image in full size
MCP Server — Get Message Details Tool — 2
Press enter or click to view image in full size
MCP Server — Refresh Token Tool
Press enter or click to view image in full size
MCP Server —Who Am I (Status) Tool

Voilà!

Alternatively, you can use “SeekChat” as an alternative but I haven’t tried it. It says it supports various AI service providers and chat history is stored locally to protect your privacy.

You can download it from here.

Happy Coding!

--

--

Nil Seri
Nil Seri

Written by Nil Seri

I would love to change the world, but they won’t give me the source code | coding 👩🏻‍💻 | coffee ☕️ | jazz 🎷 | anime 🐲 | books 📚 | drawing 🎨

Responses (2)