Class 2: Your First MCP Server
Duration: ~30 minutes | Level: Beginner | Prerequisites: Class 1: Environment Setup
What We'll Build
A working MCP server with one tool, echo, that takes a string and returns it. It is the "Hello, World!" of MCP: your code compiles, the server starts, the MCP handshake succeeds, and Claude can call your tool.
The Server Class
If you followed Class 1, carry straight on from the project you already have.
If you skipped it, clone the class_1 branch to start from the same place:
git clone --branch class_1 https://github.com/the-mcp-guy/mcp-java-sdk-course.git
Open src/main/java/com/themcpguy/HelloMcpServer.java, the file you created in Class 1, and replace its contents with the following. (We name our class HelloMcpServer rather than McpServer to avoid a name collision with the SDK interface we import.)
package com.themcpguy;
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.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class HelloMcpServer {
private static final Logger log = LoggerFactory.getLogger(HelloMcpServer.class);
public static void main(String[] args) throws InterruptedException {
McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
Tool echoTool = Tool.builder("echo", jsonMapper, """
{
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The text to echo back"
}
},
"required": ["message"]
}
""")
.description("Returns whatever text you pass in. Useful for testing.")
.build();
var echoSpec = McpServerFeatures.SyncToolSpecification.builder()
.tool(echoTool)
.callHandler((exchange, request) -> {
Object raw = request.arguments().get("message");
if (!(raw instanceof String text) || text.isBlank()) {
return CallToolResult.builder()
.isError(true)
.addTextContent("'message' must be a non-blank string")
.build();
}
log.debug("echo called: '{}'", text);
return CallToolResult.builder()
.addTextContent("Echo: " + text)
.build();
})
.build();
McpServer.sync(transportProvider)
.serverInfo("my-first-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(echoSpec)
.build();
log.info("hello-mcp-server started (stdio); awaiting messages on stdin");
Thread.currentThread().join();
}
}
What Each Part Does
StdioServerTransportProvider
Lets the SDK communicate over stdin and stdout: Claude Desktop launches your server as a child process and speaks to it through that process's standard input and output. Every message is JSON-RPC, a JSON object naming a method and its arguments, and a request carries an id that its answer repeats. The messages are newline-delimited, and the transport handles the framing.
The constructor needs an McpJsonMapper, the SDK's wrapper around a JSON library, and your classpath decides which one:
| Artifact | Package of JacksonMcpJsonMapper | What its constructor takes |
|---|---|---|
mcp-json-jackson2, added in Class 1 | io.modelcontextprotocol.json.jackson2 | com.fasterxml.jackson.databind.ObjectMapper |
mcp-json-jackson3, pulled in by the bundled mcp artifact | io.modelcontextprotocol.json.jackson3 | tools.jackson.databind.json.JsonMapper |
They are not interchangeable. The SDK documentation writes McpJsonDefaults.getMapper(), which picks up whichever module is on the classpath. We name ours because Tool.builder(...) below needs it.
Tool (built via Tool.builder(name, jsonMapper, schema))
The tool definition the AI sees, carrying three things:
- name: the identifier used in tool call requests,
echohere - description: what the AI reads to decide when to use this tool; make it descriptive, because this text is the entire basis for that decision
- inputSchema: a JSON Schema, a JSON document describing the shape of another JSON document. The SDK advertises it to clients as the argument contract, and the 2.0 server validates incoming calls against it before your handler runs
The name and the schema are arguments to Tool.builder(...), while the description is chained on afterwards. A tool is not valid MCP without a name and a schema, so the builder asks for both before it gives you anything to chain onto. Our echo tool sets only the description, and the same builder also offers title, outputSchema, annotations, icons and meta.
Two forms are available:
Tool.builder(name, jsonMapper, schemaAsJsonText) // JSON text, used above
Tool.builder(name, schemaAsMap) // a pre-built Map<String, Object>
We pass JSON text in a Java text block so you can copy a schema out of the MCP specification and paste it in unchanged. The Map form needs the same schema hand-translated into nested Map.of(...) calls, where a mistyped key like "requried" still compiles and only misbehaves at runtime.
Older tutorials and code build tools like this:
Tool.builder().name("echo").inputSchema(new JsonSchema(...)).build();
The no-argument Tool.builder(), the .inputSchema(JsonSchema) setter, and the JsonSchema record itself are all deprecated in SDK 2.0.0. That code still compiles and still works, but it warns, and the JsonSchema record makes you pass three trailing nulls for fields you rarely need.
SyncToolSpecification
Pairs the tool definition with the code that runs it, built via SyncToolSpecification.builder(). The .callHandler(...) lambda receives two arguments:
exchange, the handle on this client's live connection, used to read session information and send notifications backrequest, aCallToolRequestwhose.arguments()returns the client's arguments as aMap<String, Object>
It returns a CallToolResult, built via CallToolResult.builder(), carrying content (text, image, audio, resource links, or embedded resources) and an isError flag.
"Sync" means your handler is plain blocking Java: the SDK calls it on a worker thread and waits for it to return. Class 3 introduces the async variant, whose handler returns a Reactor Mono<CallToolResult>, one future result the SDK subscribes to.
Checking the Argument
The handler does not trust arguments() to hold a valid string:
Object raw = request.arguments().get("message");
if (!(raw instanceof String text) || text.isBlank()) {
return CallToolResult.builder()
.isError(true)
.addTextContent("'message' must be a non-blank string")
.build();
}
This looks redundant, since we just told the SDK that message is a required string and the server validates against that schema by default. The two layers catch different things:
What the client sends as message | Schema validation, on by default | The handler's instanceof check |
|---|---|---|
"hello" | passes | passes, and the handler returns Echo: hello |
42 | rejected, the SDK returns a CallToolResult with isError set | without the check, a ClassCastException |
| the field is missing | rejected, message is listed in required | instanceof is false, so the handler returns isError |
" " | passes, because our schema says only "type": "string" | rejected by text.isBlank() |
anything, after someone calls .validateToolInputs(false) | skipped entirely | still checked |
Validation is opt-out, switched off by .validateToolInputs(false) on the server builder, so a handler cannot assume it ran. A pattern of "\\S", which asks for at least one non-whitespace character, would reject the blank string in the schema, and a constraint written there is also shown to the model in tools/list. instanceof with a pattern variable rules out both the wrong type and null, and binds text as a String, so the rest of the block does not need a cast. What a tool returns reaches the model too, and Class 8 shows how database rows can carry text the model reads as instructions.
Returning isError(true) instead of throwing an exception decides where the failure lands.
The specification asks clients to hand tool execution errors to the model so it can correct itself, and only says they may pass a protocol error on. Class 6 follows both paths on the wire.
ServerCapabilities
ServerCapabilities.builder().tools(true) puts "tools": {"listChanged": true} on the wire, a promise to send notifications/tools/list_changed if the tool list changes while the server runs. In Class 3 we keep that promise, registering a tool while the server runs with addTool(...) and announcing it with notifyToolsListChanged().
Logging
The class logs through SLF4J, the logging interface that Logback implements, at two levels:
log.info("hello-mcp-server started (stdio); awaiting messages on stdin");
log.debug("echo called: '{}'", text);
The logback.xml from Class 1 sends all of this to stderr. Three streams connect Claude Desktop to your server, and only two of them carry MCP messages.
Stdout carries only JSON-RPC messages, one per line, so a single stray System.out.println lands in the middle of a message and breaks the connection. That is why we wired Logback up before writing any server code.
The startup line is INFO, so you see it. The per-call line is DEBUG, and the Class 1 config sets the root level to INFO, so it stays hidden until you ask for it. To watch individual calls, add this to logback.xml above the <root> element:
<logger name="com.themcpguy" level="DEBUG" />
Keeping per-call logging at DEBUG is a habit worth forming early. Tool arguments are user data, and on a real server you do not want them written to a log file unless someone has deliberately asked for that.
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 no longer has work to do, but if it exits, the JVM exits. Thread.currentThread().join() blocks indefinitely, and the SDK does not interrupt it when stdin closes. The specification tells the client to close stdin, wait for the server to exit, then send SIGTERM and finally SIGKILL. This process ends on the SIGTERM. Cleanup code after join() does not run, so a server holding a database pool or a file lock should close itself when stdin closes.
Build and Test
mvn package
java -jar target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar
The server prints its startup line and appears to hang:
12:00:00 [main] INFO com.themcpguy.HelloMcpServer - hello-mcp-server started (stdio); awaiting messages on stdin
That is correct. A stdio server waits for a client to speak first, and you started it without a client, so nothing else happens. Press Ctrl+C to stop it, then let Claude Desktop start it properly.
If the process exits straight away instead of waiting, the dependencies probably did not resolve. Run mvn -U clean package to force Maven to check again.
Connect to Claude Desktop
You did this in Class 1: the JAR keeps the same name, so the entry you already have still points at the right file. If you haven't added it yet, do it now:
{
"mcpServers": {
"my-first-server": {
"command": "java",
"args": ["-jar", "/absolute/path/to/mcp-java-sdk-course/target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar"]
}
}
}
Use the absolute path to your JAR. A relative path will not work, because Claude Desktop does not start your server from your project directory.
Restart Claude Desktop properly
Claude Desktop reads that config file once, when it starts, so you have to quit it completely and open it again. Closing the window is not enough. On macOS the app keeps running with the window shut, and on Windows it keeps running in the system tray. Either way it keeps using the config it read at startup, and your tool is missing from the list.
- macOS: press
Cmd + Q, then launch Claude again. If the window closes but Claude stays in the Dock, pressCmd + Option + Esc, select Claude, and click Force Quit. - Windows: right-click the Claude icon in the system tray and choose Quit, then reopen it.
You will repeat this after every rebuild. Claude keeps the server process it launched alive until Claude quits, so a freshly built JAR does not take effect until Claude restarts.
Try it
Once Claude is back, open a new conversation and click the + button at the bottom left of the message box, then Connectors → Manage connectors. You should see my-first-server listed with its echo tool.
Now ask Claude: "Use the echo tool to send me 'Hello from Java'"
Claude replies with Echo: Hello from Java. Behind that one line, the two processes exchanged this:
Messages 2 to 4 are the handshake: the two sides agree a protocol version and say what they support. Message 6 is where the description and the schema you wrote reach the model.
If the tool does not appear, check Claude Desktop's logs, where your server's stderr ends up:
tail -n 20 -f ~/Library/Logs/Claude/mcp*.log
mcp.log covers the connection; mcp-server-my-first-server.log holds everything your server wrote to stderr, including that startup line. On Windows, look in %APPDATA%\Claude\logs.
The Companion Code
This class is the class_2 branch, which is exactly the server above:
git clone --branch class_2 https://github.com/the-mcp-guy/mcp-java-sdk-course.git
What's Next
Your first MCP server works. In the next class, we implement three real Tools: pure computation, sandboxed filesystem access, and async database search.
Further Reading
- MCP specification: Tools: the normative rules for tool definitions, result content types and the
isErrorconvention this class uses. - MCP specification: Transports: the stdio section, which says messages are newline-delimited, that stdout carries only MCP messages, and that logs belong on stderr.
- MCP specification: Lifecycle: the
initializehandshake behind the round trip, and the three-step stdio shutdown. - MCP Java SDK: MCP Server: the SDK's own page for building sync and async servers, with the stdio transport and tool specifications used here.
- java-sdk MIGRATION-2.0.md: what changed in 2.0, including the required-first builder factories and tool-argument validation on by default.
- McpSchema.Tool.Builder javadoc: every option on the tool builder, including the
title,outputSchema,annotations,iconsandmetacalls this class only names in passing. - Connect to local MCP servers: the Claude Desktop side, from the config file to the connector list and the log files.
- Understanding JSON Schema: the guide to writing the schemas that go in
inputSchema, from types andrequiredthrough to the keywords that constrain a value.
Sources
- MCP specification: Tools: the five result content types, the
listChangedpromise, and the rule that clients should hand tool execution errors to the model while they may withhold protocol errors. - MCP specification: Transports: messages on stdio are newline-delimited JSON, a server must write only MCP messages to stdout, and it may write logs to stderr.
- MCP specification: Lifecycle: the stdio shutdown sequence of closing stdin, then
SIGTERM, thenSIGKILL. - java-sdk MIGRATION-2.0.md: the no-argument
Tool.builder(), theinputSchema(JsonSchema)setter and theJsonSchemarecord are deprecated in 2.0, andvalidateToolInputsdefaults totrue. - java-sdk McpServer.java v2.0.0:
McpServeris an interface, andMcpServer.sync(...)returns the specification carryingserverInfo,capabilities,toolsandvalidateToolInputs. - java-sdk mcp/pom.xml v2.0.0: the bundled
mcpartifact depends onmcp-json-jackson3, which is why this course takesmcp-corewithmcp-json-jackson2. - java-sdk McpServerSession.java v2.0.0: an exception thrown out of a request handler becomes a JSON-RPC error response with code
-32603. - Connect to local MCP servers: Claude Desktop must be quit completely and restarted after a config change,
mcp.logholds connection logging,mcp-server-SERVERNAME.logholds the server's stderr, and the Windows logs live in%APPDATA%\Claude\logs. - JSON Schema: string:
patternmatches a string against a regular expression, which is how a schema rejects a blank one.