Module 2: Your First MCP Server
Duration: ~30 minutes | Level: Beginner | Prerequisites: Module 1: Environment Setup
What You'll Build
A working MCP server with one tool, echo, that takes a string and returns it back. Not useful, but it proves the entire stack works: your code compiles, the server starts, the MCP handshake succeeds, and Claude can call your tool.
"Hello, World!" for MCP.
The Server Class
Create src/main/java/com/example/HelloMcpServer.java. (We name our class HelloMcpServer rather than McpServer to avoid a name collision with the SDK class we'll import.)
package com.example;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.JsonSchema;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import java.util.List;
import java.util.Map;
public class HelloMcpServer {
public static void main(String[] args) throws InterruptedException {
// 1. Transport: stdio uses the process's stdin/stdout for the wire.
// The transport provider needs an McpJsonMapper. The 2.x SDK ships two
// implementations: mcp-json-jackson2 (Jackson 2's ObjectMapper from
// com.fasterxml.jackson) and mcp-json-jackson3 (Jackson 3's JsonMapper
// from tools.jackson). Module 1 pulled in mcp-json-jackson2, so we use
// its JacksonMcpJsonMapper here with a fresh ObjectMapper.
McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
// 2. Tool definition: name, description, and JSON Schema for arguments.
// Tool.Builder#inputSchema accepts a JsonSchema record (type, properties,
// required, additionalProperties, $defs, definitions); we use it here and
// pass nulls for the fields we don't need. The 2.0 builder also offers
// inputSchema(Map) and inputSchema(McpJsonMapper, String) overloads.
Tool echoTool = Tool.builder()
.name("echo")
.description("Returns whatever text you pass in. Useful for testing.")
.inputSchema(new JsonSchema(
"object",
Map.of("message", Map.of(
"type", "string",
"description", "The text to echo back")),
List.of("message"),
null, null, null))
.build();
// 3. Tool handler: receives the CallToolRequest, returns a CallToolResult.
// The handler is plain synchronous Java; the framework calls it on the
// server's worker thread when the matching tool/call request arrives.
var echoSpec = McpServerFeatures.SyncToolSpecification.builder()
.tool(echoTool)
.callHandler((exchange, request) -> {
String message = (String) request.arguments().get("message");
return CallToolResult.builder()
.addTextContent("Echo: " + message)
.build();
})
.build();
// 4. Build the server with the fluent builder.
McpServer.sync(transportProvider)
.serverInfo("my-first-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(echoSpec)
.build();
// 5. The sync server is driven by the transport: stdio reads from the
// JVM's stdin on a worker thread. Block the main thread so the JVM
// doesn't exit immediately.
Thread.currentThread().join();
}
}
A complete runnable version of this server (including the logback.xml that routes logs to stderr) is in the companion repository at projects/mcp-java-sdk/hello-mcp-server/. Build it with mvn -B -pl hello-mcp-server -am package from projects/mcp-java-sdk/ and you'll get a fat JAR ready to wire into Claude Desktop.
What Each Part Does
StdioServerTransportProvider
Provides the SDK with the means to communicate over stdin/stdout. This is what Claude Desktop uses to talk to local servers. All MCP messages are newline-delimited JSON; the transport handles the framing. The constructor needs an McpJsonMapper. The mcp-json-jackson2 artifact (added in Module 1) provides io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; pass it a fresh ObjectMapper. Note: the bundled mcp artifact pulls in mcp-json-jackson3 instead, whose JacksonMcpJsonMapper lives in io.modelcontextprotocol.json.jackson3 and takes a Jackson 3 tools.jackson.databind.json.JsonMapper, so the two are not drop-in interchangeable.
Tool (built via Tool.builder())
The tool definition the AI will see. Required fields:
- name: the identifier used in tool call requests
- description: what the AI reads to decide when to use this tool; make it descriptive
- inputSchema: a
JsonSchemarecord (withtype,properties,required,additionalProperties,$defs, anddefinitionsfields) describing the arguments. The SDK advertises this schema to clients as the argument contract. In the 2.0 SDK the server also validates incoming calls against it by default before invoking your handler (you can opt out with.validateToolInputs(false)on the server builder). Note that theTool.inputSchema()getter now returns aMap<String, Object>even though the builder still accepts theJsonSchemarecord
SyncToolSpecification
A synchronous tool handler, built via SyncToolSpecification.builder(). The .callHandler(...) lambda receives:
exchange, the current MCP exchange (access to session info, ability to send notifications)request, aCallToolRequestwhose.arguments()returns theMap<String, Object>of arguments as sent by the client; in the 2.0 server these are validated againstinputSchemaby default before your handler runs (unless you set.validateToolInputs(false))
Return a CallToolResult built via CallToolResult.builder() (or its constructors), with content (text, image, audio, embedded resources) and an isError flag.
Blocking the Main Thread
The sync McpServer is event-driven: once build() returns, the transport runs on background threads and dispatches incoming messages. The main thread has nothing left to do, but if it exits, the JVM exits. Thread.currentThread().join() blocks indefinitely. The SDK does not interrupt this thread when stdin closes; the process exits because Claude Desktop terminates it. Don't put cleanup code after join(); it will never run.
Build and Test
# Build the fat JAR
mvn package -q
# Test that it starts (it will wait for stdin, hit Ctrl+C after a second)
java -jar target/my-mcp-server-1.0-SNAPSHOT.jar
You should see the server start and wait. Good, it's listening on stdin for MCP messages.
Connect to Claude Desktop
Update your claude_desktop_config.json with the path to your new JAR:
{
"mcpServers": {
"my-first-server": {
"command": "java",
"args": ["-jar", "/path/to/my-mcp-server/target/my-mcp-server-1.0-SNAPSHOT.jar"]
}
}
}
Restart Claude Desktop (fully quit and reopen). In a new conversation, click the tools/plug icon; you should see your echo tool listed.
Try asking Claude: "Use the echo tool to send me 'Hello from Java'"
Claude will call your tool, your server will process it, and you'll see the response in the chat.
Adding Better Logging
To see what's happening during development, add logging to the tool handler:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class HelloMcpServer {
private static final Logger log = LoggerFactory.getLogger(HelloMcpServer.class);
// ... in the tool handler:
.callHandler((exchange, request) -> {
String message = (String) request.arguments().get("message");
log.info("echo called with: {}", message);
return CallToolResult.builder()
.addTextContent("Echo: " + message)
.build();
})
Since we configured Logback to write to stderr, these logs appear in Claude Desktop's logs (Settings → Logs) without corrupting the protocol stream.
A Note on Error Handling
What happens if request.arguments().get("message") returns null, even though it's required in the schema? In the 2.0 SDK the server validates incoming arguments against your inputSchema by default (a missing required field, or a value of the wrong type, is rejected before your handler ever runs). You can turn this off with .validateToolInputs(false) on the server builder, in which case inputSchema is advertised to the client but not enforced and a required field is only as reliable as the client.
Because validation is opt-out (and because a misbehaving client, or your own validateToolInputs(false), can still hand your handler a missing or mistyped value), defensive handling is good practice regardless:
(exchange, request) -> {
Object raw = request.arguments().get("message");
if (!(raw instanceof String text)) {
return CallToolResult.builder()
.isError(true)
.addTextContent("Error: 'message' must be a string")
.build();
}
return CallToolResult.builder()
.addTextContent("Echo: " + text)
.build();
}
Returning isError: true tells the AI that the tool call failed. It can reason about the failure and decide what to do next, rather than treating garbage output as a successful result.
What's Next
Your first MCP server works. Now let's build something useful. In the next module, we implement three real Tools: pure computation, sandboxed filesystem access, and async database search.