DEV Community

Jean Claude Van Debug
Jean Claude Van Debug

Posted on

Build an MCP Server in Java with Spring AI (+ OAuth)

Claude Code can add a task to a team board and show Ana's open tasks as a table. It doesn't have a plugin for that. It talks to a Java server, built with Spring AI, that exposes the board over the Model Context Protocol (MCP). This article builds that server step by step: first as a handful of annotated methods, then locked down with OAuth 2, a permission for every tool, and validation on every argument. Getting it to work takes minutes. Making it safe is most of the job.

There's an animated video version of this article too.

TL;DR

  • With Spring AI 2.0, an MCP server is a few annotated methods.
  • Set spring.ai.mcp.server.protocol: STREAMABLE. Without it, Spring AI 2.0.1 serves the older SSE transport and /mcp returns 404.
  • An HTTP MCP server is an OAuth 2 resource server. An anonymous request gets a 401 that points the client at your authorization server.
  • Give every tool its own scope with @PreAuthorize, so a read-only token can't create anything.
  • The model writes every argument, so validate them like input from the internet.

Watch the video: https://www.youtube.com/watch?v=UjGuQt1TsT8

The code is in the spring-ai-mcp-task-board repository, one commit per step: GITHUB-LINK-HERE

What MCP is

MCP is an open protocol, introduced by Anthropic in November 2024, for connecting AI applications to tools and data. The AI application is the host: Claude Code, Claude Desktop or your IDE. Inside the host, one client keeps one connection to one server. Our server offers three kinds of things:

  • Tools are actions the model decides to take, such as creating a task. The model is in control.
  • Resources are data the application can read, each with an address such as task://1. The application or the user decides when to put one in front of the model.
  • Prompts are templates the user picks, often from a slash menu. Ours writes a standup update.

Every message is JSON-RPC 2.0. For a remote server, the transport is Streamable HTTP: one endpoint, /mcp. The client POSTs a message, and the server answers with a JSON body or, when it has more to say, a stream. After initialize, the server returns an Mcp-Session-Id header, and the client sends it back with every later request.

Setting up the server

We start from start.spring.io with Spring Web, Model Context Protocol Server and Validation, on Spring Boot 4.1.1 and Java 25. The dependency that matters is the MCP server starter for WebMVC. In the final pom.xml, the relevant part of the dependencies looks like this:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

The version comes from the Spring AI 2.0.1 BOM.

The configuration gives the server a name, a version and an instruction that clients show the model:

spring:
  ai:
    mcp:
      server:
        name: task-board
        protocol: STREAMABLE
        version: 1.0.0
        instructions: >
          A small team task board. Use the tools to create,
          list and complete tasks.

server:
  port: 8080
Enter fullscreen mode Exit fullscreen mode

The line that's easy to miss is protocol: STREAMABLE. In Spring AI 2.0.1, the WebMVC starter still serves the deprecated SSE transport by default, on /sse, and /mcp returns 404.

Start the application and send the first message every client sends, initialize. The response carries the server's name, version, instructions and capabilities: tools, resources and prompts. The server works, but it can't do anything yet.

The task board

The domain is deliberately small. A task has an id, a title, an assignee, an optional due date and a done flag. A TaskService keeps the tasks in a concurrent map and can create, list, find and complete them.

In a real application, this is the service you already have. MCP sits on top of code you already run.

Tools

TaskTools is a Spring component that gets the service injected. Each tool is a method with an @McpTool annotation. The name and the description are what the model sees, and each parameter gets an @McpToolParam with its own description:

@McpTool(name = "create_task", description = "Create a task on the team board")
public Task createTask(
        @McpToolParam(description = "Short title, e.g. 'Fix login bug'") String title,
        @McpToolParam(description = "Who does it, e.g. 'ana'") String assignee,
        @McpToolParam(description = "Due date as YYYY-MM-DD", required = false) String due) {
    return tasks.create(title, assignee, parseDue(due));
}
Enter fullscreen mode Exit fullscreen mode

Those descriptions aren't comments. They're the only documentation the model ever sees. A vague description means the model guesses, so write each one as if you were explaining the parameter to a new teammate.

The read-only tool, list_tasks, adds a hint:

@McpTool(name = "list_tasks", description = "List tasks, optionally only one person's",
        annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))
Enter fullscreen mode Exit fullscreen mode

Clients can use hints such as readOnlyHint to ask for less confirmation. They are hints, not security.

Spring AI turns every method into a tool and builds its JSON Schema from the Java types and the descriptions. In tools/list, create_task requires title and assignee, and due is optional.

[UPLOAD IMAGE HERE: mcp-tool-descriptions.png] (alt text: TaskTools.java with the @McpToolParam descriptions highlighted)

Calling create_task with a title, ana and the due date 2026-10-23 returns the new task as text, with id 1. Listing Ana's tasks returns it too.

The interesting case is an error. Completing task 99, which doesn't exist, throws an exception in our service. Spring AI turns it into a normal tool result marked isError: true, with the text No task with id 99 (Spring AI 2.0.1 repeats the message in the text). The model receives the message and can correct itself, instead of the whole call failing.

Resources and prompts

Tools are for actions. For data, MCP has resources. TaskResources has one method, annotated with @McpResource. Its address is a template, task://{id}, and the id from the address is passed straight into the method parameter:

@McpResource(uri = "task://{id}", name = "Task",
        description = "One task with all its details", mimeType = "text/markdown")
public String task(String id) {
    // looks up the task and returns it as a small markdown document
}
Enter fullscreen mode Exit fullscreen mode

On the wire, the client asks for the resource templates, sees task://{id}, and then reads task://1. It gets back a small markdown document:

# Task 1: Fix login bug
- assignee: ana
- due: 2026-10-23
- status: open
Enter fullscreen mode Exit fullscreen mode

The difference from a tool is who is in charge. The model decides to call a tool. The application or the user chooses which resource to put in front of the model.

The third primitive is a prompt. TaskPrompts has one method with @McpPrompt, and its single argument, @McpArg, is whose standup it is. The method loads that person's tasks, turns them into a checklist and wraps them in an instruction.

When a client calls prompts/get for Ana, it receives a ready message with her real tasks already filled in. The user picked the template, and our server supplied the facts.

The problem: anyone can call it

So now we have all three primitives, and they all work. And right now, the server is dangerous.

This client has no credentials at all. It connects, and it completes Ana's task:

Anyone who can reach this port can read every task, create new ones and close the rest. If a model is the client, it will do all of that whenever a document it read tells it to. An MCP server is an API and needs the same protection as one.

OAuth 2

The MCP specification says HTTP servers are OAuth 2.1 resource servers: the client gets an access token from an authorization server and sends it with every request.

For this demo, the authorization server is a small Spring Authorization Server in the same repository, on port 9000, configured entirely in properties. It registers two clients, an agent that may read and write tasks and a dashboard that may only read. Both use client_credentials, the grant for machine-to-machine clients:

spring:
  security:
    oauth2:
      authorizationserver:
        issuer: http://localhost:9000
        client:
          agent:
            registration:
              client-id: agent
              client-secret: "{noop}agent-secret"   # dev only
              authorization-grant-types: [client_credentials]
              scopes: [tasks.read, tasks.write]
            token:
              access-token-time-to-live: 1h
          dashboard:
            registration:
              client-id: dashboard
              client-secret: "{noop}dashboard-secret"
              authorization-grant-types: [client_credentials]
              scopes: [tasks.read]
          # ...
Enter fullscreen mode Exit fullscreen mode

The {noop} secrets and in-memory clients are for local development only.

Back in the task server, two dependencies turn it into a resource server: the MCP security starter from the Spring AI community, and Spring Boot's OAuth 2 resource server starter.

<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>mcp-server-security-spring-boot</artifactId>
    <version>0.1.14</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Then one property: the issuer. The task server will only trust tokens signed by that authorization server.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000
Enter fullscreen mode Exit fullscreen mode

Now the anonymous client tries again, and gets a 401:

HTTP/1.1 401
WWW-Authenticate: Bearer resource_metadata=http://localhost:8080/.well-known/oauth-protected-resource/mcp
Enter fullscreen mode Exit fullscreen mode

The 401 doesn't just say no. The header points to a Protected Resource Metadata document (RFC 9728) that names our authorization server, so the client can discover where to log in without being configured in advance.

The agent asks the authorization server for a token with both scopes, authenticating with its client id and secret:

curl -u agent:agent-secret http://localhost:9000/oauth2/token \
     -d grant_type=client_credentials -d scope='tasks.read tasks.write'
Enter fullscreen mode Exit fullscreen mode
{
  "access_token": "eyJraWQiOiJiOGEyYzJhOS02YjY1…",
  "scope": "tasks.write tasks.read",
  "token_type": "Bearer",
  "expires_in": 3599
}
Enter fullscreen mode Exit fullscreen mode

The access token is a signed JWT. Decoded, its issuer is our authorization server, its subject is the agent, and its scope claim lists both permissions. It expires an hour after it was issued. The task server verifies the signature with keys it downloads from the issuer address.

With that token, initialize works again and returns 200, with a session id of its own.

One scope per tool

A valid token shouldn't mean you can do everything. The dashboard should be able to look, not touch.

We turn on method security with @EnableMethodSecurity in a small SecurityConfig, and then give every tool the scope it needs. Spring Security maps the JWT scope claim to SCOPE_ authorities, so the annotation is a one-liner:

@PreAuthorize("hasAuthority('SCOPE_tasks.write')")
@McpTool(name = "create_task", description = "Create a task on the team board")
public Task createTask(/* ... */) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Creating and completing tasks need tasks.write. Listing tasks, and reading the task resource, need tasks.read. The @PreAuthorize annotations work on @McpTool methods, because the MCP security starter supports them.

Now the dashboard's token can still list tasks. When it tries to create one, the call comes back as an error:

{
  "content": [{ "type": "text", "text": "Access Denied\r\nAccess Denied" }],
  "isError": true
}
Enter fullscreen mode Exit fullscreen mode

The model sees why the call failed, and the board doesn't change. That's least privilege: an agent that only reads gets a token that can only read.

Validate every argument the model sends

The one people forget is validation. Every tool argument was written by a model that may have just read text from a stranger, so treat it like internet input.

The service checks each argument before it touches the board. The title must be between one and 120 characters. The assignee must look like a username: a short, lowercase name without spaces or quotes. The board also caps at 1,000 tasks:

if (title == null || title.isBlank() || title.length() > 120) {
    throw new IllegalArgumentException("title must be 1 to 120 characters");
}
if (assignee == null || !NAME.matcher(assignee).matches()) {
    throw new IllegalArgumentException("assignee must be a lowercase username like 'ana'");
}
// ...
Enter fullscreen mode Exit fullscreen mode

The due date gets its own check in the tool. A bad date produces a message that tells the model the exact format to use, instead of a stack trace.

We tried three bad calls. An empty title and an assignee that looks like an SQL injection string are both rejected with a message stating the rule. And next friday as a due date gets this result:

{
  "content": [{ "type": "text", "text": "due must look like 2026-10-23\r\ndue must look like 2026-10-23" }],
  "isError": true
}
Enter fullscreen mode Exit fullscreen mode

The model reads the rule and fixes its own call.

Before production

The demo skips four things that matter in production. First, the audience. Our token's aud is agent, the client, not our server. In production, a token should name the server it's meant for, and that server should reject any token that names someone else. Otherwise a token issued for one service can be replayed against another. The MCP specification requires this check, and the security starter can perform it.

Second, never pass a client's token through to another API. Third, keep tokens short-lived: an hour is fine for a demo, and for agents, minutes are better. Fourth, when a person is behind the client, use the OAuth login with PKCE (RFC 7636), the flow MCP clients use when you sign in from the app. Client credentials are for machines.

The boring parts save you too: rate limits, a log of every tool call, HTTPS, and a real database instead of a map in memory.

Connecting Claude Code

Now let's connect a real agent. We register the server in Claude Code as an HTTP MCP server, with our URL and the agent's token sent as an Authorization header.

Then we ask Claude Code, in plain English, to add a task for Ana to review the MCP security pull request, due Friday, and to show Ana's open tasks. It finds the create-task tool. Because changing the board is a real action, Claude Code asks first and shows the exact arguments, with Friday as a real date. After approval, it lists Ana's tasks and answers with a table, straight from our Java server.

Recap

Spring AI turns annotated methods into tools, resources and prompts and handles the protocol. The real work is making the server safe: OAuth 2 on the endpoint, a scope for every tool, and validation on every argument. The full code is linked above, one commit per step. Build it, break it, then connect your own services.

Further reading

More animated system design and security explainers on the jean code van debug YouTube channel. Java developer? Join the Java devs squad on daily.dev.

Top comments (0)