Building Your First MCP Server with Java and Spring Boot

1. Overview

Model Context Protocol (MCP) is a communication standard that helps AI assistants (Claude, Copilot, etc.) call tools and read resources from your system in a structured way. This guide shows you how to write an MCP server in Java 21, expose an order lookup tool (lookup_order), and test it with an MCP client over stdio transport. By the end, you’ll have a working MCP server, write tests for your tool handler, and know common pitfalls when getting started.

2. What is MCP and when you should write a server

MCP defines three main concepts that a server can expose:

  • Tool: a function that an AI model can call, with a clear input schema (like function calling).
  • Resource: static or dynamic data that a client can read (files, config, database records…).
  • Prompt: pre-built prompt templates for the client to reuse.

You need to write an MCP server when you want an AI assistant to interact directly with your internal systems — for example, look up orders, create tickets, query logs — without having to manually copy-paste data into chat.

Note: If you just need a model to answer based on static text, using RAG or standard prompting will be much simpler than building an entire MCP server.

3. Setting up your project

3.1. Maven Dependencies

The MCP Java SDK provides classes for building servers and transports. Add the following dependency to your pom.xml:

<dependency>
    <groupId>io.modelcontextprotocol.sdk</groupId>
    <artifactId>mcp</artifactId>
    <version>0.8.1</version>
</dependency>

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Jackson is used to serialize/deserialize JSON-RPC messages that MCP uses internally.

3.2. A simple domain model

Before writing a tool, you need data for it to return. Use a mock Order:

public record Order(String id, String status, double total) {
}
@Service
public class OrderService {

    private final Map<String, Order> orders = Map.of(
        "ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
        "ORD-002", new Order("ORD-002", "PENDING", 120_000)
    );

    public Optional<Order> findById(String orderId) {
        return Optional.ofNullable(orders.get(orderId));
    }
}

OrderService is just an in-memory map for demo purposes — in a real project, you’d call a repository or API.

4. Define your first tool: lookup_order

Each tool in MCP needs a name, a description (the model uses this to decide whether to call the tool), and an input schema in JSON Schema format.

public final class OrderTools {

    public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
        "lookup_order",
        "Look up the status and total amount of an order by order ID",
        """
        {
          "type": "object",
          "properties": {
            "orderId": {
              "type": "string",
              "description": "Order ID, for example ORD-001"
            }
          },
          "required": ["orderId"]
        }
        """
    );
}
  • "lookup_order" is the tool name; the client will use exactly this string to call it.
  • The second description is very important: the model uses this to know when to call this tool instead of another.
  • The schema requires orderId as a mandatory field of type string.

Next, write a handler to execute the tool — take parameters, call OrderService, and return CallToolResult:

@Component
public class OrderToolHandler {

    private final OrderService orderService;

    public OrderToolHandler(OrderService orderService) {
        this.orderService = orderService;
    }

    public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
        String orderId = (String) arguments.get("orderId");

        return orderService.findById(orderId)
            .map(order -> success(order))
            .orElseGet(() -> error("Order not found: " + orderId));
    }

    private McpSchema.CallToolResult success(Order order) {
        String text = "Order %s: status %s, total %.0f đ"
            .formatted(order.id(), order.status(), order.total());
        return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
    }

    private McpSchema.CallToolResult error(String message) {
        return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
    }
}

The second parameter of CallToolResult is isError. When true, the client knows the tool failed and can report it to the model to try another approach, rather than treating it as a valid result.

5. Initialize the MCP server with stdio transport

The most common transport when starting with MCP is stdio: the server reads requests from stdin and writes responses to stdout. This is also how Claude Desktop launches local servers.

@Component
public class McpOrderServer implements CommandLineRunner {

    private final OrderToolHandler toolHandler;

    public McpOrderServer(OrderToolHandler toolHandler) {
        this.toolHandler = toolHandler;
    }

    @Override
    public void run(String... args) {
        McpServerTransportProvider transportProvider =
            new StdioServerTransportProvider(new ObjectMapper());

        McpServerFeatures.SyncToolSpecification lookupOrderSpec =
            new McpServerFeatures.SyncToolSpecification(
                OrderTools.LOOKUP_ORDER,
                (exchange, arguments) -> toolHandler.handle(arguments)
            );

        McpSyncServer server = McpServer.sync(transportProvider)
            .serverInfo("nonla-order-server", "1.0.0")
            .capabilities(McpSchema.ServerCapabilities.builder()
                .tools(true)
                .build())
            .tools(lookupOrderSpec)
            .build();

        server.start();
    }
}
  • StdioServerTransportProvider converts the process’s stdin/stdout into a JSON-RPC communication channel.
  • serverInfo(...) declares the name and version — the client will display this when listing connected servers.
  • capabilities().tools(true) tells the client this server exposes tools; the client will send tools/list to get the list.
  • server.start() blocks the process and waits for requests from the client over stdin.

Note: Since stdio transport keeps the process alive by blocking, if running in a standard Spring Boot web app (with @SpringBootApplication and a web server), you should disable the web server (spring.main.web-application-type=none in application.properties) to avoid port conflicts and log mixing into stdout.

spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=

Banner and console logging must be disabled — MCP uses stdout as a data channel; any log lines mixed in will corrupt the JSON-RPC message.

6. Add a resource: expose static data

Beyond tools, you can expose resources for the client to read directly (without needing to call through the model):

McpSchema.Resource policyResource = new McpSchema.Resource(
    "file:///policies/return-policy.md",
    "Return Policy",
    "Return policy content",
    "text/markdown",
    null
);

McpServerFeatures.SyncResourceSpecification policySpec =
    new McpServerFeatures.SyncResourceSpecification(
        policyResource,
        (exchange, request) -> new McpSchema.ReadResourceResult(
            List.of(new McpSchema.TextResourceContents(
                policyResource.uri(),
                "text/markdown",
                "# Return Policy\nCustomers can return items within 7 days."
            ))
        )
    );

Add .resources(policySpec) to the builder in step 5 and the server will expose this additional resource.

7. Test with Claude Desktop

Build the jar and configure Claude Desktop (or another MCP client) to point to your server:

{
  "mcpServers": {
    "nonla-order-server": {
      "command": "java",
      "args": ["-jar", "/path/to/order-mcp-server.jar"]
    }
  }
}

Restart the client, start a new conversation, and ask: “What status is order ORD-001?”. The model will decide on its own to call lookup_order with orderId = "ORD-001", the server returns the text, and the model uses that result to answer you.

8. Write tests for your tool handler

The most important part — handling parameters and returning CallToolResult — is completely testable like regular Java code, without needing a real transport.

class OrderToolHandlerTest {

    private final OrderService orderService = new OrderService();
    private final OrderToolHandler handler = new OrderToolHandler(orderService);

    @Test
    void shouldReturnOrderInfo_whenOrderExists() {
        var result = handler.handle(Map.of("orderId", "ORD-001"));

        assertThat(result.isError()).isFalse();
        assertThat(result.content())
            .hasSize(1)
            .first()
            .isInstanceOfSatisfying(McpSchema.TextContent.class,
                content -> assertThat(content.text()).contains("SHIPPED"));
    }

    @Test
    void shouldReturnError_whenOrderNotFound() {
        var result = handler.handle(Map.of("orderId", "ORD-999"));

        assertThat(result.isError()).isTrue();
    }
}

The first test checks a valid order: isError() must be false and the text content contains the status SHIPPED. The second test ensures that when an order is not found, isError() returns true — this is an important signal for the client/model to know it needs to handle it differently.

✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound

9. Common mistakes

  • Logs mixed into stdout: using stdio transport but forgetting to disable banner/log console will corrupt the JSON-RPC messages received by the client; parse errors are very hard to debug. Always verify that stdout only contains MCP messages by redirecting to a file when testing manually.
  • Vague tool descriptions: if the description just says “lookup order”, the model often doesn’t know when to call it. Write descriptions that clearly explain expected input/output.
  • Missing required in input schema: the model might call the tool with missing required fields, causing NullPointerException when casting parameters. Always validate input parameters in your handler; don’t fully trust the schema.
  • Using async server (McpServer.async) incorrectly: easy to get race conditions when multiple tool requests arrive close together. If you’re not comfortable with reactive programming, start with McpServer.sync as shown in this guide.

When not to write a separate MCP server: if your system already has a stable REST API and only needs one client calling it directly, adding an MCP layer is redundant. MCP is only worth the investment when you want multiple different AI clients (Claude Desktop, IDE plugins, custom agents) to be able to share the same tools without needing separate integration for each.

10. Conclusion

You’ve just built a complete MCP server in Java: declared tools with clear schemas, wrote handlers for business logic, exposed additional resources, and tested handlers independently from the transport. The natural next step is to try SSE transport to run your server over HTTP instead of stdio, or add prompt templates for common recurring tasks in your domain.

mcpjavaspring-bootaitool-calling