How to Build an MCP Server in Java
Use the official MCP Java SDK (io.modelcontextprotocol.sdk:mcp, version 2.1.0). Register each tool with a name, a description and a JSON Schema for its arguments, then start the server with StdioServerTransportProvider so an AI app can launch it as a child process. The bookmarks server in this guide is one Java class, and you run it with java -jar target/bookmarks.jar. The SDK version used here implements the 2025-11-25 protocol revision, not the newer 2026-07-28 one.
What you'll have at the end
A bookmarks server with two tools, save_bookmark and search_bookmarks, that any MCP-compatible AI app can call. You'll be able to say "bookmark modelcontextprotocol.io as MCP docs" and later "find my MCP bookmarks", and the model will call your code to do it. This is the result of calling save_bookmark from MCP Inspector:
{
"content": [
{
"type": "text",
"text": "Saved 'MCP docs'. Total bookmarks: 1"
}
],
"isError": false
}
The AI app starts your server as a child process and exchanges JSON-RPC messages with it over standard input and output. Your server never talks to the model directly; the app passes tool calls through.
Before you start
- Java: A JDK 17 or later. The SDK's build targets Java 17. The example was built and run on Java 21.
- Maven: Maven 3.9 or later, so the
mvncommand works in your terminal. - For testing: Node.js 22.19 or later, which MCP Inspector needs.
- Basic comfort with a terminal.
- To connect it at the end: Claude Desktop, or any other AI app from MCP clients.
1. Set up the project
Create a folder called bookmarks and save this as pom.xml in it. Maven fetches the SDK from Maven Central. The shade plugin packs your code and its dependencies into one runnable jar.
<?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>
<groupId>example</groupId>
<artifactId>bookmarks</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mcp.version>2.1.0</mcp.version>
<slf4j.version>2.0.16</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>${mcp.version}</version>
</dependency>
<!-- Writes SLF4J log output to stderr, so stdout carries only protocol messages. -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
</dependency>
</dependencies>
<build>
<finalName>bookmarks</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>example.bookmarks.BookmarksServer</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
The mcp artifact is the SDK's convenience bundle: the core server and client code, with Jackson for JSON. slf4j-simple gives the SDK's log messages a destination, and it writes them to stderr.
2. Write the server
Each tool needs a name, a description the model reads to decide when to call it, and a JSON Schema for its arguments. The schemas here are JSON text in Java text blocks. The description you put on each property is the text the model sees for that argument.
Save this as src/main/java/example/bookmarks/BookmarksServer.java:
package example.bookmarks;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class BookmarksServer {
private static final Logger logger = LoggerFactory.getLogger(BookmarksServer.class);
private static final McpJsonMapper JSON = McpJsonDefaults.getMapper();
private static final TypeRef<List<Bookmark>> BOOKMARK_LIST = new TypeRef<>() {
};
// Tool calls share one file. A lock keeps two calls from reading and writing it at once.
private static final Object FILE_LOCK = new Object();
private static final String SAVE_SCHEMA = """
{
"type": "object",
"properties": {
"url": {"type": "string", "description": "Full URL of the page, including https://"},
"title": {"type": "string", "description": "Short human-readable title for the page"},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional lowercase tags, e.g. [\\"recipes\\", \\"python\\"]"
}
},
"required": ["url", "title"]
}
""";
private static final String SEARCH_SCHEMA = """
{
"type": "object",
"properties": {
"query": {"type": "string", "description": "One or more words to look for, case-insensitive"}
},
"required": ["query"]
}
""";
private static final Tool SAVE_TOOL = Tool.builder("save_bookmark", JSON, SAVE_SCHEMA)
.description("Save a web page to the user's bookmarks. "
+ "Use this when the user asks to save, bookmark or remember a link.")
.build();
private static final Tool SEARCH_TOOL = Tool.builder("search_bookmarks", JSON, SEARCH_SCHEMA)
.description("Search saved bookmarks by words in the title, URL or tags.")
.build();
record Bookmark(String url, String title, List<String> tags) {
}
private BookmarksServer() {
}
public static void main(String[] args) {
// Stdout carries the protocol. Everything the server logs goes to stderr.
McpServer.sync(new StdioServerTransportProvider(JSON))
.serverInfo("bookmarks", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(false).build())
.toolCall(SAVE_TOOL, BookmarksServer::saveBookmark)
.toolCall(SEARCH_TOOL, BookmarksServer::searchBookmarks)
.build();
}
private static CallToolResult saveBookmark(McpSyncServerExchange exchange, CallToolRequest request) {
String url = (String) request.arguments().get("url");
String title = (String) request.arguments().get("title");
List<String> tags = stringList(request.arguments().get("tags"));
synchronized (FILE_LOCK) {
try {
List<Bookmark> bookmarks = new ArrayList<>(loadBookmarks());
if (bookmarks.stream().anyMatch(b -> b.url().equals(url))) {
return text("Already saved: " + url);
}
bookmarks.add(new Bookmark(url, title, tags));
saveBookmarks(bookmarks);
logger.info("Saved bookmark {}", url);
return text("Saved '" + title + "'. Total bookmarks: " + bookmarks.size());
}
catch (IOException e) {
logger.error("Could not save bookmark {}", url, e);
return failure("Could not save the bookmark: " + e.getMessage());
}
}
}
private static CallToolResult searchBookmarks(McpSyncServerExchange exchange, CallToolRequest request) {
String query = (String) request.arguments().get("query");
List<String> words = Arrays.stream(query.toLowerCase(Locale.ROOT).split("\\s+"))
.filter(word -> !word.isEmpty())
.toList();
synchronized (FILE_LOCK) {
try {
List<String> lines = loadBookmarks().stream()
.filter(b -> matches(b, words))
.map(b -> "- " + b.title() + ": " + b.url())
.toList();
if (lines.isEmpty()) {
return text("No bookmarks match '" + query + "'.");
}
return text(String.join("\n", lines));
}
catch (IOException e) {
logger.error("Could not read bookmarks", e);
return failure("Could not read the bookmarks: " + e.getMessage());
}
}
}
private static boolean matches(Bookmark bookmark, List<String> words) {
String haystack = Stream.concat(Stream.of(bookmark.title(), bookmark.url()), bookmark.tags().stream())
.collect(Collectors.joining(" "))
.toLowerCase(Locale.ROOT);
return words.stream().allMatch(haystack::contains);
}
private static List<Bookmark> loadBookmarks() throws IOException {
Path file = bookmarksFile();
if (!Files.exists(file)) {
return List.of();
}
return JSON.readValue(Files.readString(file), BOOKMARK_LIST);
}
private static void saveBookmarks(List<Bookmark> bookmarks) throws IOException {
Files.writeString(bookmarksFile(), JSON.writeValueAsString(bookmarks));
}
private static Path bookmarksFile() {
String override = System.getenv("BOOKMARKS_FILE");
if (override != null && !override.isBlank()) {
return Path.of(override);
}
return Path.of(System.getProperty("user.home"), ".bookmarks-mcp.json");
}
private static List<String> stringList(Object value) {
if (value == null) {
return List.of();
}
return ((List<?>) value).stream().map(String.class::cast).toList();
}
private static CallToolResult text(String message) {
return CallToolResult.builder().addTextContent(message).build();
}
private static CallToolResult failure(String message) {
return CallToolResult.builder().addTextContent(message).isError(true).build();
}
}
The pieces to notice:
Tool.builder(name, JSON, schema)parses the schema text into the tool definition.mainregisters each tool withtoolCall, which pairs the definition with the method that runs when the model calls it.- Each handler gets the arguments as a map and returns a
CallToolResult. The handlers readurl,title,tagsandqueryfrom that map. The SDK checks each call against the schema before the handler runs, so the required fields are present. ServerCapabilities.builder().tools(false)means the server won't send notifications when its tool list changes. This server's list never changes, so it doesn't need them.McpServer.sync(...).build()returns once the server is configured. The stdio transport keeps reading requests from stdin, and the JVM exits when the client closes stdin.- Logs go to stderr through
slf4j-simple. The stdio transport in the MCP spec says the server "MUST NOT write anything to its stdout that is not a valid MCP message", so a straySystem.out.printlnwould break the connection. - Both tools read and write the same file, so they take a lock. Without it, two calls could interleave a read and a write.
- If the file can't be read, the tool returns a result with
isErrorset totrueand the error message, so the model sees the failure and can report it. The test intest.shchecks this by pointingBOOKMARKS_FILEat a directory.
Spring AI adds Spring Boot starters on top of the same SDK. This guide uses the plain SDK, so there's no Spring configuration to set up.
3. Build the jar
From the bookmarks folder, build the project:
mvn package
Maven writes target/bookmarks.jar. Run it to check it starts:
java -jar target/bookmarks.jar
Nothing appears on screen because the server is waiting for JSON-RPC messages on stdin. Press Ctrl+C to stop it.
4. Test it with MCP Inspector
MCP Inspector connects to your server the same way an AI app does. Its command-line mode is the fastest check. Put the Inspector's own flags before --, and the command that starts your server after it.
List the tools:
npx @modelcontextprotocol/inspector@2.11.0 --cli --protocol-era legacy --method tools/list -- java -jar target/bookmarks.jar
The output lists both tools, each with the parameter descriptions you wrote:
{
"tools": [
{
"name": "save_bookmark",
"description": "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Full URL of the page, including https://"
},
...
},
"required": [
"url",
"title"
]
}
},
{
"name": "search_bookmarks",
...
}
]
}
--protocol-era legacy makes the Inspector use the 2025-era initialize handshake. It's also the Inspector's default for command runs, so the flag is optional. This SDK doesn't implement the 2026-07-28 revision, so --protocol-era modern fails with a version negotiation error. Keep the flag on legacy until you move to an SDK release that supports the newer revision.
Call save_bookmark:
npx @modelcontextprotocol/inspector@2.11.0 --cli --protocol-era legacy --method tools/call \
--tool-name save_bookmark --tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs" \
-- java -jar target/bookmarks.jar
Then search for it:
npx @modelcontextprotocol/inspector@2.11.0 --cli --protocol-era legacy --method tools/call \
--tool-name search_bookmarks --tool-arg query=mcp \
-- java -jar target/bookmarks.jar
The search returns - MCP docs: https://modelcontextprotocol.io. Bookmarks are saved to ~/.bookmarks-mcp.json by default. Set the BOOKMARKS_FILE environment variable to use a different file.
Leave out --cli to open the Inspector's web interface, where you can browse tools and fill in parameters with a form. See Test with MCP Inspector.
5. Connect it to Claude Desktop
Open Claude Desktop's config file in a text editor, creating it if it doesn't exist:
| OS | Config file |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
Add your server under the mcpServers key. Use the absolute path to the jar. Run pwd in the bookmarks folder on macOS or Linux, or cd on Windows, to get the folder path, then add /target/bookmarks.jar to the end of it.
{
"mcpServers": {
"bookmarks": {
"command": "java",
"args": ["-jar", "/ABSOLUTE/PATH/TO/bookmarks/target/bookmarks.jar"]
}
}
}
If Claude Desktop can't find java, put its full path in command. Get it with which java on macOS or Linux, or where java on Windows. On Windows, write paths with double backslashes (C:\\Users\\you\\bookmarks\\target\\bookmarks.jar) or forward slashes.
Save the file, then quit Claude Desktop completely and reopen it. Closing the window isn't enough.
Ask Claude to "bookmark modelcontextprotocol.io as MCP docs". If the server is connected, Claude can call save_bookmark for that request. In a new chat, ask "what have I bookmarked about mcp?" so Claude can use search_bookmarks.
The same jar works in any app that starts local MCP servers over stdio. Each app keeps its config in a different place: see Claude Code, Cursor, VS Code and Codex.
Common problems
The server doesn't appear in Claude Desktop.
The config file usually has a JSON syntax error, such as a trailing comma or a missing brace. Check it with a JSON validator, then fully quit and reopen the app. Claude Desktop writes MCP logs to ~/Library/Logs/Claude on macOS and %APPDATA%\Claude\logs on Windows. Follow them while you restart the app:
tail -n 20 -F ~/Library/Logs/Claude/mcp*.log
Claude Desktop can't find java.
Desktop apps don't always load your shell's PATH. Put the full path from which java (macOS or Linux) or where java (Windows) in command.
The server disconnects right after it starts.
Something is writing to stdout. Look for System.out calls you added, and for libraries that print to stdout. Use the SLF4J logger, which writes to stderr.
--protocol-era modern fails with Version negotiation failed.
This SDK version doesn't implement the 2026-07-28 revision, so there's nothing for the Inspector to negotiate with. Use --protocol-era legacy. The Python and TypeScript servers in the build-an-MCP-server guide answer both revisions.
For more errors and fixes, see Fix MCP connection errors.
FAQ
Which Java version do I need for an MCP server?
Java 17 or later. The SDK targets Java 17, and this example was built and run on Java 21. Any JDK 17 or newer works for the build and for running the jar.
Do I need Spring to build an MCP server in Java?
No. This guide uses the official SDK on its own, with no framework. If you already use Spring Boot, Spring AI provides MCP support on top of the same SDK.
Does the Java SDK support the newest MCP protocol revision?
Not in the stable release. SDK 2.1.0 supports the 2025-11-25 revision and the two before it. The 2.2.0-M1 pre-release adds the 2026-07-28 revision, but it is a milestone build, so this guide does not use it.
Why does the server write its logs to stderr?
Over stdio, the AI app reads protocol messages from stdout and nothing else. Anything else written there breaks the connection, so logs go to stderr, which the app ignores.
- Build an MCP server in Python or TypeScriptThe same bookmarks server in Python and TypeScript, with a test in both protocol eras.
- Test with MCP InspectorUse the Inspector web interface and CLI to debug tools, resources and prompts.
- Write tools the model picks correctlyNames and descriptions that lead to the right tool call.
- Connect MCP servers to Claude DesktopConfig locations, local and remote servers, and logs for Claude Desktop.
- Fix MCP connection errorsReal connection errors, their causes and their fixes.