<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Model-Context-Protocol on Vitalii Sukach</title><link>https://sukachvitally.github.io/tags/model-context-protocol/</link><description>Recent content in Model-Context-Protocol on Vitalii Sukach</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 25 Sep 2026 14:46:28 +0000</lastBuildDate><atom:link href="https://sukachvitally.github.io/tags/model-context-protocol/index.xml" rel="self" type="application/rss+xml"/><item><title>Building an MCP Server in Java with Spring AI: Let Claude Code Use Your Database Without Seeing a Password</title><link>https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/</link><pubDate>Fri, 25 Sep 2026 14:46:28 +0000</pubDate><guid>https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/</guid><description>&lt;p>&lt;em>Four annotated methods, one security config, and no JSON-RPC code of your own.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-01.png">&lt;/p>
&lt;p>In my &lt;a href="https://medium.com/@sukach.v.v/mcp-the-protocol-that-finally-taught-ai-to-stop-being-useless-759de3745b79">previous article&lt;/a> I built an MCP server from scratch in Python. I wrote the JSON-RPC 2.0 message handling, declared tool schemas by hand and wired up the STDIO transport myself. It’s a good way to learn how the protocol works on the wire.&lt;/p>
&lt;p>Python isn’t the only option, though. Most of the systems I work on are Java and Spring, and that’s where the data lives. So this time I built the same kind of server in Java.&lt;/p></description><content:encoded><![CDATA[<p><em>Four annotated methods, one security config, and no JSON-RPC code of your own.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-01.png"></p>
<p>In my <a href="https://medium.com/@sukach.v.v/mcp-the-protocol-that-finally-taught-ai-to-stop-being-useless-759de3745b79">previous article</a> I built an MCP server from scratch in Python. I wrote the JSON-RPC 2.0 message handling, declared tool schemas by hand and wired up the STDIO transport myself. It’s a good way to learn how the protocol works on the wire.</p>
<p>Python isn’t the only option, though. Most of the systems I work on are Java and Spring, and that’s where the data lives. So this time I built the same kind of server in Java.</p>
<p>It turns out you don’t need to implement JSON-RPC yourself. Spring AI has a Boot starter for MCP servers. It handles the protocol, the transport, schema generation and tool registration. You write ordinary Spring beans and add a few annotations.</p>
<p>The full code is on GitHub: <a href="https://github.com/SukachVitally/mcp-demo">SukachVitally/mcp-demo</a>.</p>
<h3 id="why-bother-the-server-is-theboundary">Why bother: the server is the boundary</h3>
<p>The business logic here is deliberately boring. It’s a task list: you create tasks, move them through statuses, and finish or cancel them. Swap in your own domain; the MCP part stays the same.</p>
<p>The point is how Claude Code reaches the data:</p>
<pre tabindex="0"><code>Claude Code ──HTTP + API key──▶ MCP server (Spring) ──JDBC──▶ PostgreSQL
</code></pre><ul>
<li><strong>Claude never sees database credentials.</strong> The server holds the connection. Claude holds only an API key that works for the MCP endpoint and nothing else.</li>
<li><strong>Claude can only do what you expose.</strong> There’s no SQL console. If you don’t write a delete_task tool, tasks can&rsquo;t be deleted, however the prompt is phrased.</li>
<li><strong>Your validation and transactions still apply.</strong> Every call goes through the same service layer as the rest of your application.</li>
</ul>
<p>Giving an agent a database URL and a password is quick, but you can’t take it back. An MCP server gives the agent a small, typed and auditable API instead.</p>
<h3 id="what-werebuilding">What we’re building</h3>
<p>A PostgreSQL table of tasks. Each task has a name and one of four statuses: NEW, IN_PROGRESS, DONE or CANCELLED. The server exposes four tools:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-02.png"></p>
<p>Stack: Java 25, Spring Boot 4, Spring AI 2.0 MCP server, PostgreSQL 18 with Liquibase, and Spring Security.</p>
<h3 id="step-1-dependencies">Step 1: Dependencies</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/build.gradle"><em>build.gradle</em></a></p>
<pre tabindex="0"><code>dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-liquibase&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-webmvc&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    implementation platform(&#39;org.springframework.ai:spring-ai-bom:2.0.1&#39;)
    implementation &#39;org.springframework.ai:spring-ai-starter-mcp-server-webmvc&#39;
    implementation &#39;org.springaicommunity:mcp-server-security-spring-boot:0.1.14&#39;
    runtimeOnly &#39;org.postgresql:postgresql&#39;
}
</code></pre><p>Two lines do the MCP work:</p>
<ul>
<li>spring-ai-starter-mcp-server-webmvc is the MCP server over HTTP on Spring MVC. There&rsquo;s a -webflux variant for reactive apps, and a plain spring-ai-starter-mcp-server for stdio.</li>
<li>mcp-server-security-spring-boot from the <a href="https://github.com/spring-ai-community/mcp-security">spring-ai-community/mcp-security</a> project adds API-key and OAuth2 support on top of Spring Security. We&rsquo;ll come back to it.</li>
</ul>
<h3 id="step-2-configure-theserver">Step 2: Configure the server</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/resources/application.yaml"><em>application.yaml</em></a></p>
<pre tabindex="0"><code>spring:
  ai:
    mcp:
      server:
        protocol: STATELESS
        type: sync
        name: &#34;Task MCP demo&#34;
        instructions: &gt;-
          Manages a simple task list. Create tasks with add_task, look them up with get_task or
          get_task_list, and move them through the statuses NEW, IN_PROGRESS, DONE and CANCELLED
          with update_task_status.
        streamable-http:
          mcp-endpoint: /mcp
</code></pre><p>What these settings mean:</p>
<ul>
<li><strong>protocol: STATELESS</strong>: plain HTTP request and response, with no session kept between calls. Our tools never need to push anything back to the client, so we don&rsquo;t need sessions, and the server scales horizontally like any other REST service. Use STREAMABLE if you need server-to-client messages. The older SSE option is deprecated since Spring AI 2.0.</li>
<li><strong>type: sync</strong>: tools are ordinary blocking methods. With async, they return Reactor Mono/Flux types instead.</li>
<li><strong>instructions</strong>: sent to the client when it connects. It&rsquo;s the server&rsquo;s short explanation to the model of how the tools fit together. Treat it as part of the prompt.</li>
<li><strong>mcp-endpoint: /mcp</strong>: the URL that Claude Code will call.</li>
</ul>
<h3 id="step-3-business-logic-keep-itboring">Step 3: Business logic (keep it boring)</h3>
<p>Nothing here knows about MCP. It’s a normal JPA entity, repository and service:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/service/TaskService.java"><em>TaskService.java</em></a></p>
<pre tabindex="0"><code>@Service
@Validated
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TaskService {
    public static final int NAME_MAX_LEN
GTH = 255;
    private final TaskRepository repository;
    private final TaskMapper mapper;
    @Transactional
    public TaskDto create(@NotBlank @Size(max = NAME_MAX_LENGTH) String name) {
        var entity = new TaskEntity();
        entity.setName(name);
        entity.setStatus(TaskStatus.NEW);
        return mapper.toDto(repository.save(entity));
    }
    @Transactional
    public TaskDto updateStatus(Long id, @NotNull TaskStatus status) {
        var entity = findEntity(id);
        entity.setStatus(status);
        return mapper.toDto(entity);
    }
    // get(id), getList(), findEntity(id) throws TaskNotFoundException ...
}
</code></pre><p>The service returns a small record, not the entity:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/dto/TaskDto.java"><em>TaskDto.java</em></a></p>
<pre tabindex="0"><code>public record TaskDto(Long id, String name, TaskStatus status) {}
</code></pre><p>That matters more than it looks. Whatever a tool returns is serialized and handed to the model, so the DTO decides exactly which fields leave your system.</p>
<h3 id="step-4-taskmcptool-the-actual-mcpserver">Step 4: TaskMcpTool, the actual MCP server</h3>
<p>This is the whole MCP layer:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/mcp/TaskMcpTool.java"><em>TaskMcpTool.java</em></a></p>
<pre tabindex="0"><code>@Component
@RequiredArgsConstructor
public class TaskMcpTool {
    private final TaskService service;
    @McpTool(name = &#34;add_task&#34;, description = &#34;Create a new task with status NEW and return it&#34;)
    public TaskDto add(@McpToolParam(description = &#34;Task name, 1-255 characters&#34;) String name) {
        return service.create(name);
    }
    @McpTool(name = &#34;get_task&#34;, description = &#34;Get a task by its id. Fails if the task does not exist&#34;)
    public TaskDto get(@McpToolParam(description = &#34;Task id&#34;) Long id) {
        return service.get(id);
    }
    @McpTool(name = &#34;update_task_status&#34;,
            description = &#34;Change the status of a task and return the updated task. Fails if the task does not exist&#34;)
    public TaskDto updateStatus(@McpToolParam(description = &#34;Task id&#34;) Long id,
                                @McpToolParam(description = &#34;New task status&#34;) TaskStatus status) {
        return service.updateStatus(id, status);
    }
    @McpTool(name = &#34;get_task_list&#34;, description = &#34;List all tasks&#34;)
    public List&lt;TaskDto&gt; getList() {
        return service.getList();
    }
}
</code></pre><p>Only a thin adapter: each method delegates to the service. Most of the work happens in the annotations.</p>
<h3 id="how-the-tools-get-registered">How the tools get registered</h3>
<p>The class is a plain @Component. When the application starts, Spring AI&rsquo;s annotation scanner finds every bean method annotated with @McpTool and registers it as an MCP tool. You don&rsquo;t need a registry, a ToolCallback list or any manual wiring. To add a tool, write another annotated method.</p>
<h3 id="mcptool-the-name-and-description-the-modelsees">@McpTool: the name and description the model sees</h3>
<ul>
<li><strong>name</strong> is the tool id the model calls. I use snake_case verbs (add_task, get_task_list) because that&rsquo;s the convention across MCP servers, and models pick such names up reliably. Without a name, the Java method name is used, and add on its own tells the model very little.</li>
<li><strong>description</strong> is the most important string in the class. It&rsquo;s the only thing the model reads when deciding which tool to call and what to expect back. Say what the tool does, what it returns and when it fails (&ldquo;Fails if the task does not exist&rdquo;). A vague description gives you a model that guesses.</li>
</ul>
<h3 id="mcptoolparam-the-schema-is-generated-foryou">@McpToolParam: the schema is generated for you</h3>
<p>Each parameter gets a description, and Spring AI builds a JSON Schema from the Java type. Here’s what the server actually returns for update_task_status when Claude Code asks for tools/list:</p>
<pre tabindex="0"><code>{
  &#34;name&#34;: &#34;update_task_status&#34;,
  &#34;description&#34;: &#34;Change the status of a task and return the updated task. Fails if the task does not exist&#34;,
  &#34;inputSchema&#34;: {
    &#34;type&#34;: &#34;object&#34;,
    &#34;properties&#34;: {
      &#34;id&#34;:     { &#34;type&#34;: &#34;integer&#34;, &#34;format&#34;: &#34;int64&#34;, &#34;description&#34;: &#34;Task id&#34; },
      &#34;status&#34;: { &#34;type&#34;: &#34;string&#34;,
                  &#34;enum&#34;: [&#34;NEW&#34;, &#34;IN_PROGRESS&#34;, &#34;DONE&#34;, &#34;CANCELLED&#34;],
                  &#34;description&#34;: &#34;New task status&#34; }
    },
    &#34;required&#34;: [&#34;id&#34;, &#34;status&#34;]
  }
}
</code></pre><p>Things to notice:</p>
<ul>
<li><strong>Long became</strong> <strong>integer with</strong> <strong>int64 format.</strong> You don&rsquo;t write any schema by hand.</li>
<li><strong>Parameters are required by default</strong> (required = true). Set @McpToolParam(required = false) for optional ones.</li>
<li><strong>The</strong> <strong>TaskStatus enum became a JSON</strong> <strong>enum.</strong> The model sees the four allowed values up front, so it can&rsquo;t invent a status like DELETED. If it tries, the call is rejected by schema validation before your method runs:</li>
</ul>
<pre tabindex="0"><code>Tool (update_task_status) input validation failed: ... /status: does not 
have a value in the enumeration [&#34;NEW&#34;, &#34;IN_PROGRESS&#34;, &#34;DONE&#34;, &#34;CANCELLED&#34;]
</code></pre><p>Use enums for anything with a fixed set of values. You get validation and documentation in one place.</p>
<h3 id="return-values-anderrors">Return values and errors</h3>
<p>You return a normal Java object, and Spring AI serializes it to JSON as the tool result. <code>TaskDto</code> and <code>List&lt;TaskDto&gt;</code> just work.</p>
<p>Exceptions work too. When TaskService throws TaskNotFoundException, the client doesn&rsquo;t get a stack trace or a broken JSON-RPC response. It gets a tool result with isError: true and the exception message as the text:</p>
<pre tabindex="0"><code>{ 
  &#34;content&#34;: [{ &#34;type&#34;: &#34;text&#34;, &#34;text&#34;: &#34;Task with id -1 is not found&#34; }], 
  &#34;isError&#34;: true 
}
</code></pre><p>That’s the behaviour you want: the model reads the message, understands what went wrong and can recover, for example by calling get_task_list to find the right id. Write exception messages for the model to read, not just for your logs.</p>
<p>The same applies to Bean Validation. A blank name fails @NotBlank in the service and comes back to the model as an error result, not a 500.</p>
<h3 id="a-tip-add-behaviour-hints">A tip: add behaviour hints</h3>
<p>If you look at the full tools/list response, every tool reports &ldquo;readOnlyHint&rdquo;: false, &ldquo;destructiveHint&rdquo;: true, including get_task_list. Those are the defaults. Clients such as Claude Code can use these hints to decide how cautious to be, so it&rsquo;s worth being honest about read-only tools:</p>
<pre tabindex="0"><code>@McpTool(name = &#34;get_task_list&#34;, description = &#34;List all tasks&#34;,
        annotations = @McpTool.McpAnnotations(readOnlyHint = true, 
                                              destructiveHint = false))
</code></pre><p>These are hints, not enforcement. What actually protects your data is the fact that the tool only reads.</p>
<h3 id="step-5-securityconfig-briefly">Step 5: SecurityConfig, briefly</h3>
<p>By default, an HTTP MCP endpoint is an unauthenticated JSON-RPC endpoint. Anyone who can reach /mcp can call your tools. The mcp-security library closes it with a few lines:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/config/SecurityConfig.java"><em>SecurityConfig.java</em></a></p>
<pre tabindex="0"><code>@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ApiKeyEntityRepository&lt;ApiKeyEntityImpl&gt; apiKeyRepository) {
        return http
                .authorizeHttpRequests(auth -&gt; auth.anyRequest().authenticated())
                .with(mcpServerApiKey(), apiKey -&gt; apiKey.apiKeyRepository(apiKeyRepository))
                .build();
    }
    @Bean
    ApiKeyEntityRepository&lt;ApiKeyEntityImpl&gt; apiKeyRepository(@Value(&#34;${app.mcp.api-key.secret}&#34;) String secret) {
        var key = ApiKeyEntityImpl.builder()
                .name(&#34;demo key&#34;)
                .id(&#34;demo&#34;)
                .secret(secret)
                .build();
        return new InMemoryApiKeyEntityRepository&lt;&gt;(List.of(key));
    }
}
</code></pre><ul>
<li>mcpServerApiKey() plugs API-key authentication into the normal Spring Security filter chain.</li>
<li>The client sends an X-API-key header in the form <code>&lt;id&gt;.&lt;secret&gt;</code>, so <code>demo.&lt;your secret&gt;</code> here. Requests without it get 401.</li>
</ul>
<h3 id="step-6-run-it-and-connect-claudecode">Step 6: Run it and connect Claude Code</h3>
<p>Start PostgreSQL and the app:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/docker-compose.yaml"><em>docker-compose.yaml</em></a><em>,</em><a href="https://github.com/SukachVitally/mcp-demo/blob/main/.env.tpl"><em>.env.tpl</em></a><em>,</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/README.md"><em>README</em></a></p>
<pre tabindex="0"><code>cp .env.tpl .env          # sets MCP_API_KEY_SECRET
docker compose up -d
./gradlew bootRun
</code></pre><p>Register the server with Claude Code:</p>
<pre tabindex="0"><code>claude mcp add --transport http todos http://localhost:8080/mcp \
  --header &#34;X-API-key: demo.mcp-demo-test-secret&#34;
</code></pre><p>Then ask in plain language:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-03.png"></p>
<p>“Add todo with name test” becomes an add_task call. &ldquo;Update task with id 1 to DONE&rdquo; becomes update_task_status with a valid enum value. Claude never saw a JDBC URL, a username or a password.</p>
<p>To inspect the server without Claude, use the MCP Inspector (npx @modelcontextprotocol/inspector). Choose the Streamable HTTP transport and add the same header.</p>
<h3 id="testing">Testing</h3>
<p>Because the server speaks standard MCP, the tests can use the official Java MCP client against a real server started by @SpringBootTest, with PostgreSQL in Testcontainers:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/test/java/org/example/mcpdemo/mcp/TaskMcpToolTest.java"><em>TaskMcpToolTest.java</em></a></p>
<pre tabindex="0"><code>var transport = HttpClientStreamableHttpTransport
        .builder(&#34;http://localhost:&#34; + port)
        .httpRequestCustomizer((builder, method, endpoint, body, context) -&gt;
                builder.header(&#34;X-API-key&#34;, &#34;demo.&#34; + API_KEY_SECRET))
        .build();
client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(McpSchema.CallToolRequest.builder(&#34;get_task&#34;)
        .arguments(Map.of(&#34;id&#34;, -1L)).build());
assertThat(result.isError()).isTrue();
</code></pre><p>This covers the whole chain in one test: authentication, schema, serialization, error mapping and the database.</p>
<h3 id="takeaways">Takeaways</h3>
<ul>
<li><strong>You don’t write JSON-RPC in Java.</strong> spring-ai-starter-mcp-server-webmvc handles the protocol, the transport and tool discovery.</li>
<li><strong>An MCP tool is an annotated Spring bean method.</strong> @McpTool names and describes it, and @McpToolParam describes its inputs. The JSON Schema is generated from your Java types.</li>
<li><strong>Descriptions and error messages are prompts.</strong> The model reads them, so write them for the model.</li>
<li><strong>Use enums and DTOs</strong> to control what the model can send in and what it gets back.</li>
<li><strong>Secure the endpoint.</strong> API keys via mcp-security take ten lines, and OAuth2 is available when you need it.</li>
<li><strong>The MCP server is the security boundary.</strong> Claude Code works with your data through the operations you choose, never with your credentials.</li>
</ul>
<p>The Python version showed how MCP works on the wire. The Spring version shows how little code you need once a framework handles it. Take the repo, replace tasks with your own domain, and give your agent an API instead of a password.</p>
<p><strong>Links</strong></p>
<ul>
<li>Demo code: <a href="https://github.com/SukachVitally/mcp-demo">github.com/SukachVitally/mcp-demo</a></li>
<li>Spring AI MCP Server Boot Starter: <a href="https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html">docs.spring.io</a></li>
<li>MCP security for Spring: <a href="https://github.com/spring-ai-community/mcp-security/tree/main">github.com/spring-ai-community/mcp-security</a></li>
<li>Previous article, MCP in Python from scratch: <a href="https://medium.com/@sukach.v.v/mcp-the-protocol-that-finally-taught-ai-to-stop-being-useless-759de3745b79">MCP: The Protocol That Finally Taught AI to Stop Being Useless</a></li>
</ul>
]]></content:encoded></item></channel></rss>