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/mcpreturns 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>
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
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));
}
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))
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
}
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
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]
# ...
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>
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
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
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'
{
"access_token": "eyJraWQiOiJiOGEyYzJhOS02YjY1…",
"scope": "tasks.write tasks.read",
"token_type": "Bearer",
"expires_in": 3599
}
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(/* ... */) { /* ... */ }
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
}
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'");
}
// ...
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
}
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
- Model Context Protocol specification, revision 2025-06-18
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- MCP authorization (2025-06-18)
- Spring AI MCP server docs
- Spring AI community: MCP security
- RFC 7636: Proof Key for Code Exchange (PKCE)
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)