Skip to main content

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

Companion code

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:

ArtifactPackage of JacksonMcpJsonMapperWhat its constructor takes
mcp-json-jackson2, added in Class 1io.modelcontextprotocol.json.jackson2com.fasterxml.jackson.databind.ObjectMapper
mcp-json-jackson3, pulled in by the bundled mcp artifactio.modelcontextprotocol.json.jackson3tools.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, echo here
  • 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 snippets use a deprecated builder

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 back
  • request, a CallToolRequest whose .arguments() returns the client's arguments as a Map<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 messageSchema validation, on by defaultThe handler's instanceof check
"hello"passespasses, and the handler returns Echo: hello
42rejected, the SDK returns a CallToolResult with isError setwithout the check, a ClassCastException
the field is missingrejected, message is listed in requiredinstanceof 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 entirelystill 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, press Cmd + 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.

→ Class 3: Implementing Tools


Further Reading

  • MCP specification: Tools: the normative rules for tool definitions, result content types and the isError convention 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 initialize handshake 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, icons and meta calls 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 and required through to the keywords that constrain a value.

Sources

  • MCP specification: Tools: the five result content types, the listChanged promise, 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, then SIGKILL.
  • java-sdk MIGRATION-2.0.md: the no-argument Tool.builder(), the inputSchema(JsonSchema) setter and the JsonSchema record are deprecated in 2.0, and validateToolInputs defaults to true.
  • java-sdk McpServer.java v2.0.0: McpServer is an interface, and McpServer.sync(...) returns the specification carrying serverInfo, capabilities, tools and validateToolInputs.
  • java-sdk mcp/pom.xml v2.0.0: the bundled mcp artifact depends on mcp-json-jackson3, which is why this course takes mcp-core with mcp-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.log holds connection logging, mcp-server-SERVERNAME.log holds the server's stderr, and the Windows logs live in %APPDATA%\Claude\logs.
  • JSON Schema: string: pattern matches a string against a regular expression, which is how a schema rejects a blank one.