How to Build an MCP Server in Java
Two real paths to a Java MCP server — the raw official SDK and Spring AI — plus the transport and tool-count decisions that actually bite.

To build an MCP server in Java you have two sane choices: the official io.modelcontextprotocol SDK for full control, or Spring AI's MCP server starter if your app already runs on Spring Boot. Both speak the same protocol, both let you expose Java methods as tools, and both default to stdio — the transport about 90% of servers in this directory run over. Below is what each path looks like, when to pick which, and the decisions that trip people up.
If you've never wired a server into a client before, skim how to add an MCP server first — the second half of this piece assumes you know where the client config file lives.
Pick your path: raw SDK vs Spring AI
Use the official Java SDK when you want a small, dependency-light process and control over the lifecycle; use Spring AI when the server is part of an existing Spring Boot app and you want tools discovered by annotation. The protocol on the wire is identical, so this is an ergonomics call, not a capability one.
| Official Java SDK | Spring AI MCP Server | |
|---|---|---|
| Best for | Standalone servers, minimal deps | Existing Spring Boot apps |
| Tool definition | Register SyncToolSpecification | @Tool on a bean method |
| Transport setup | You choose stdio/HTTP explicitly | Starter picks it from config |
| Boot footprint | Tiny, fast cold start | Full Spring context |
| Escape hatch | You own everything | Drop to the SDK when needed |
My default: standalone tool? Raw SDK. Exposing capabilities that already live inside a Spring service? Spring AI, because you're not going to re-plumb your beans just to satisfy a protocol.
Minimal server with the official SDK
Add the SDK dependency, then register one tool and start a stdio transport — that's the whole server. The core object is McpServer; each tool is a name, a JSON input schema, and a handler that returns content.
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
</dependency>
var transport = new StdioServerTransportProvider();
var echo = new McpServerFeatures.SyncToolSpecification(
new Tool("echo", "Echo back the input",
"{\"type\":\"object\",\"properties\":\n {\"text\":{\"type\":\"string\"}}}"),
(exchange, args) -> new CallToolResult(
List.of(new TextContent((String) args.get("text"))), false));
McpServer.sync(transport)
.serverInfo("java-demo", "0.1.0")
.tools(echo)
.build();
Two things people get wrong here. First, never write logging to stdout — stdio uses standard out for the JSON-RPC frames, so a stray System.out.println corrupts the stream. Route logs to stderr or a file. Second, the input schema is the contract the model sees; a vague schema produces vague tool calls. Spend more effort there than on the handler.
The Spring AI path
With Spring AI, add the MCP server starter and annotate a method with @Tool — component scanning registers it, and the starter wires the transport from application.properties. This is the least-code option if you're already in Spring.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>
@Service
class WeatherTools {
@Tool(description = "Get the current temperature for a city")
String temperature(String city) {
return lookup(city); // your logic
}
}
Spring AI infers the JSON schema from the method signature, which is convenient but worth double-checking — inferred schemas are only as clear as your parameter names. city is fine; arg0 is not.
stdio or HTTP? Choose the transport deliberately
Default to stdio; reach for HTTP (streamable HTTP / SSE) only when the server must run as a shared, network-reachable service. Local, single-user tools should stay on stdio — it's simpler, needs no auth layer, and matches how the vast majority of the ecosystem actually ships.
- stdio — client launches your JAR as a subprocess. No ports, no auth, no CORS. This is the right call for anything that touches the local machine, and it's what the official Filesystem reference server uses.
- HTTP — the server runs independently and clients connect over the network. Now authentication, transport security, and access control are your problem. Read what actually matters for MCP security before you expose a write-capable tool this way — the trade-offs are real. The GitHub MCP server ships both a local Docker mode and a hosted remote for exactly this reason.
If you're genuinely unsure, ship stdio first. Promoting a working stdio server to HTTP later is a transport swap; the tool code doesn't change.
Keep the tool count honest
Expose the fewest tools that do the job — most clients degrade past roughly 40 active tools, and that budget is shared across every server the user has installed, not yours alone. A Java server that dumps 30 tools into the session is antisocial even if each one works.
The pattern that scales: a handful of broad, well-described tools beats a wide grid of narrow ones. The median server in this directory exposes about 10 tools; that's a good ceiling to aim for. If your Spring service has 40 public methods, resist annotating all of them — pick the ones a model should actually call. When you're wiring several servers together, the best MCP servers list and our config generator both help you stay under the ceiling.
Wire it into a client and test
Point your client's config at the JAR and confirm the tools appear before you trust them. For a stdio server, the client config runs java -jar and talks to the process over stdin/stdout:
{
"mcpServers": {
"java-demo": {
"command": "java",
"args": ["-jar", "/abs/path/target/java-demo.jar"]
}
}
}
Use an absolute path to the JAR — relative paths resolve against the client's working directory, not yours, and that's the number-one reason a freshly built server "doesn't show up." If the tool list is empty or a call hangs, the troubleshooting guide covers the usual stdio suspects (stdout pollution, wrong path, non-executable JAR). Want the model to write against a real, current API while you build? Point it at Context7 so it pulls version-specific docs instead of guessing.