// Built in the openWorked on 2026

Building an annotation-driven MCP server in Java

Exposing a Java method to an AI agent should take one annotation, not a protocol implementation. How I built java-mcp-server on Spring Boot 3, and what I'd change next.

#java#springboot#ai#mcp

Exposing a Java method to an AI agent should take one annotation. When I started using MCP servers daily with Claude Code and Cursor, I noticed that almost every example was written in Python or TypeScript — while most of the systems I’d want an agent to call are Spring Boot services.

So I wrote java-mcp-server: a small toolkit where this is the whole integration:

@Component
public class MyTools {

    @McpTool(name = "greet", description = "Say hello to someone")
    public String greet(@McpParam(value = "name", description = "Person's name") String name) {
        return "Hello, " + name + "!";
    }

    @McpResource(uri = "config://app/{key}", description = "Read a config value")
    public String config(@McpParam("key") String key) {
        return System.getProperty(key, "(not set)");
    }
}

Start the app, and any MCP client can list and call greet and read config://app/java.version. This post covers how it works, the decisions behind it, and the parts I’d change.

MCP in one paragraph

The Model Context Protocol is a JSON-RPC 2.0 protocol between an AI client (Claude Desktop, Claude Code, Cursor) and a server that offers tools (functions the model may call), resources (read-only data addressed by URI) and prompts. A session starts with initialize, the client asks tools/list, and when the model decides to use a tool the client sends tools/call with a name and JSON arguments. That’s the core loop — and it maps almost one-to-one onto “find a method, describe it, invoke it”.

The shape of the toolkit

I split the project into four Maven modules, and the split turned out to be the most useful decision in the project:

Module What it holds Depends on Spring?
mcp-core Annotations (@McpTool, @McpResource, @McpParam) and the JSON-RPC / tool models No
mcp-server-spring-boot Auto-configuration, bean scanning, the /mcp HTTP endpoint Yes
mcp-server-stdio Newline-delimited JSON-RPC over stdin/stdout, for Claude Desktop No
mcp-server-sample A runnable app with system-info and math tools Yes

Keeping mcp-core free of Spring meant the stdio transport — which Claude Desktop uses when it launches a server as a child process — was a small module on top of the same models, not a second implementation.

An MCP client calls POST /mcp; the controller routes JSON-RPC methods to a tool registry and a resource registry, which invoke annotated Spring bean methods.An MCP client calls POST /mcp; the controller routes JSON-RPC methods to a tool registry and a resource registry, which invoke annotated Spring bean methods.
One endpoint, two registries, plain Spring beans underneath. Explore the interactive diagram ↗

Step 1: turn methods into tool definitions

At startup, McpToolRegistry walks every bean in the ApplicationContext, looks for methods carrying @McpTool, and builds a ToolDefinition for each one. The interesting part is the input schema, because that’s what the model reads to decide how to call your method:

for (Parameter param : method.getParameters()) {
    McpParam mp = param.getAnnotation(McpParam.class);
    String paramName = mp != null ? mp.value() : param.getName();
    String paramDesc = mp != null ? mp.description() : "";
    boolean isRequired = mp == null || mp.required();

    properties.put(paramName, new ParameterSchema(jsonType(param.getType()), paramDesc));
    if (isRequired) required.add(paramName);
}

Java types map to JSON Schema types (int/long → integer, double → number, boolean → boolean, everything else → string). It’s deliberately simple. I found that descriptions matter more than types: a model given name: string guesses; a model given “Person’s name, as it should appear in the greeting” gets it right.

Step 2: one endpoint, a switch on the method

Every request arrives at a single POST /mcp (configurable via mcp.endpoint). The controller is a switch over the JSON-RPC method:

return switch (method) {
    case "initialize"     -> handleInitialize(id);
    case "tools/list"     -> handleToolsList(id);
    case "tools/call"     -> handleToolsCall(id, request.getParams());
    case "resources/list" -> handleResourcesList(id);
    case "resources/read" -> handleResourcesRead(id, request.getParams());
    case "notifications/initialized",
         "notifications/cancelled" -> JsonRpcResponse.success(id, null);
    default -> JsonRpcResponse.error(id, ERR_METHOD_NOT_FOUND, "Unknown method: " + method);
};

Errors use the JSON-RPC codes clients expect: -32601 for an unknown method, -32602 for bad params (a missing tool name, an unknown tool, a URI that matches no resource), -32603 for anything that blew up inside.

Step 3: invoke, and never throw at the model

McpToolInvoker maps each JSON argument onto the Java parameter type with Jackson’s treeToValue, calls the method reflectively, and wraps the return value: strings pass through, anything else is serialised to JSON.

The rule I settled on: a failing tool returns a tool error, not a protocol error. If your method throws, the client receives a ToolResult flagged as an error with the cause’s message. The model can read that and recover (“the divisor can’t be zero, let me ask the user”), which it can’t do with a transport-level failure.

Resources: a tiny URI-template matcher

@McpResource(uri = "config://app/{key}") registers a template. When a client reads config://app/java.version, the registry splits both on /, matches literal segments, and captures {variables} into a map that’s passed to the method. It’s twenty lines and handles the common cases; it doesn’t do wildcards or query strings, and I haven’t needed them yet.

Two transports, one handler

HTTP is the natural fit for a service already running in your infrastructure. Claude Desktop, though, launches MCP servers as child processes and speaks newline-delimited JSON-RPC over stdin/stdout. McpStdioRunner is a read-line loop around the same request handler, with two details that bit me:

  • Logs must go to stderr. Anything written to stdout that isn’t a JSON-RPC message corrupts the stream.
  • Notifications get no response. A message without an id must not produce a reply, or strict clients drop the connection.

What I’d do differently

This is a v0.1, and three things are already on my list:

  1. Proxied beans hide annotations. The registry reads bean.getClass().getMethods(). If the bean is wrapped in a CGLIB proxy — say because it has @Transactional — the proxy’s overriding methods don’t carry @McpTool, and the tool silently disappears. The fix is to resolve the target class with AopUtils.getTargetClass() and use AnnotationUtils.findAnnotation.
  2. Missing arguments become null. That’s fine for String, but a missing value for an int parameter fails at invoke time. Validate against the required list before calling and return a clear -32602.
  3. Parameter names need -parameters. Without @McpParam, names come from reflection, which yields arg0 unless the code is compiled with the -parameters flag. Either fail fast at startup or require @McpParam.

Takeaways

  • Keep the protocol core independent of your framework; transports become cheap.
  • Spend your effort on descriptions and schemas — that’s the interface the model actually reads.
  • Return tool failures as data the model can reason about.

The code, tests and sample app are on GitHub. If you’re putting internal Java services in front of agents, I’d like to hear how you’re handling auth — that’s the next big piece.

// TL;DR, in one picture
PMcamera offEMcamera offQAcamera off@McpTool attend_meetingSounds good,let's take thisoffline.me, actuallywriting code(headphones on)Leave meetingFinally, an MCP tool that attends meetings for me.ayushwing.com