I’ve written before about agentic AI and building with Spring AI. Both of those posts touched on a problem that keeps coming up once you move past toy demos: how does an AI application actually get access to your data and your tools, in a way that isn’t a bespoke integration for every single one? That’s the problem the Model Context Protocol (MCP) sets out to solve.

This post is in two parts. First, the theory — what MCP actually is, its architecture, and the concepts you need before anything else makes sense. Then a full hands-on tutorial: we’ll build a real MCP server in Java with Spring Boot, run it, call it with raw JSON-RPC over curl, and package it up with Docker.


Part 1: Understanding MCP Link to heading

What MCP is, and why it exists Link to heading

MCP is an open, model-agnostic standard for connecting AI applications to external systems — data sources, tools, and workflows. Anthropic’s own framing for it is a good one: MCP is like a USB-C port for AI applications. Before USB-C, every device had its own connector and its own cable. MCP does the same thing for AI integrations: instead of every AI application writing a bespoke integration for every tool and every tool writing a bespoke integration for every AI application, both sides implement MCP once and get compatibility with the entire ecosystem.

Concretely, MCP is what lets a coding assistant read your Sentry issues, a chat assistant query your company’s database, or an agent operate a 3D printer — without the assistant vendor and the tool vendor ever having agreed on anything beyond the protocol itself.

Architecture: hosts, clients, and servers Link to heading

MCP has three kinds of participants:

  • MCP Host — the AI application itself (Claude Desktop, Claude Code, VS Code, Cursor, or your own agent). The host is what the user actually interacts with.
  • MCP Client — a component the host creates, one per server, that maintains a dedicated connection to that server.
  • MCP Server — a program that exposes context (tools, data, prompts) to clients.

A host talking to three different servers instantiates three different clients, each with its own one-to-one connection. “Server” here just describes a role in the protocol, not a deployment style — an MCP server can run as a local subprocess on your machine or as a remote service somewhere else entirely.

%%{init: {"theme": "dark"}}%% graph TB subgraph Host["MCP Host (e.g. Claude Desktop, Claude Code, VS Code)"] C1["MCP Client 1"] C2["MCP Client 2"] end S1["MCP Server A — local, stdio
(e.g. filesystem access)"] S2["MCP Server B — remote, Streamable HTTP
(e.g. the TaskFlow server we'll build)"] C1 ---|"dedicated connection"| S1 C2 ---|"dedicated connection"| S2

Two layers: data and transport Link to heading

MCP cleanly separates what gets communicated from how it gets communicated:

  • Data layer — a JSON-RPC 2.0 based protocol defining the lifecycle (handshake), the primitives (tools, resources, prompts, and more), and notifications.
  • Transport layer — the actual communication channel: stdio for local processes, or Streamable HTTP for remote servers (HTTP POST for client-to-server messages, with optional Server-Sent Events for streaming responses back).

Because the data layer is transport-agnostic, the exact same JSON-RPC messages flow whether your server is a subprocess talking over stdin/stdout or a remote service on the other side of an HTTPS connection. That separation is what let me test everything in this post with plain curl against a Streamable HTTP server, and it’s exactly what we’ll do in the tutorial below.

%%{init: {"theme": "dark"}}%% graph LR subgraph Data["Data layer — JSON-RPC 2.0"] direction TB D1["Lifecycle: initialize → initialized"] D2["Server primitives: tools, resources, prompts"] D3["Client primitives: sampling, roots, elicitation"] D4["Notifications"] end subgraph Transport["Transport layer"] direction TB T1["stdio
local process pipes"] T2["Streamable HTTP
POST + optional SSE"] end Data --> T1 Data --> T2

The primitives Link to heading

Primitives are the actual nouns and verbs of MCP — the things a server can offer a client, and the things a client can offer back.

Server primitives (what a server exposes to the AI application):

  • Tools — executable functions the AI can invoke to take action (call an API, run a query, write a file).
  • Resources — data the AI can read for context (a file’s contents, a database record, a config value).
  • Prompts — reusable templates that structure an interaction (a system prompt, a set of few-shot examples).

Client primitives (what the host application exposes back to the server, so servers can build richer interactions):

  • Sampling — lets a server ask the host to run an LLM completion, without the server needing its own model or API key.
  • Roots — lets a server discover which directories or resources the client considers relevant/in-scope.
  • Elicitation — lets a server ask the user directly for more input or confirmation mid-operation.
%%{init: {"theme": "dark"}}%% graph TB Server["MCP Server"] -->|exposes| Tools["Tools
executable actions"] Server -->|exposes| Resources["Resources
contextual data"] Server -->|exposes| Prompts["Prompts
reusable templates"] Client["MCP Client / Host"] -->|exposes to server| Sampling["Sampling
ask the host's LLM to complete text"] Client -->|exposes to server| Roots["Roots
expose filesystem/resource boundaries"] Client -->|exposes to server| Elicitation["Elicitation
ask the user for more input"]

Each primitive type follows the same discovery pattern: a */list method to enumerate what’s available (tools/list, resources/list, prompts/list) and an execution/retrieval method to actually use it (tools/call, resources/read). This is deliberately dynamic — a client is expected to call list and work with whatever comes back, rather than hardcoding assumptions about what a given server offers.

The request lifecycle Link to heading

Every MCP session starts with a handshake: the client sends initialize with the protocol version it speaks and its own capabilities, the server responds with its protocol version, capabilities, and identity, and the client confirms with a one-way notifications/initialized. Only after that can tools, resources, and prompts be listed and used.

The sequence below isn’t illustrative pseudo-code — it’s the actual flow the tutorial’s server produces, which we’ll reproduce with real curl commands shortly.

%%{init: {"theme": "dark"}}%% sequenceDiagram participant Client as MCP Client participant Server as TaskFlow MCP Server Client->>Server: POST /mcp — initialize (protocolVersion, capabilities, clientInfo) Server-->>Client: 200 OK + Mcp-Session-Id header + result (serverInfo, capabilities) Client->>Server: POST /mcp — notifications/initialized Server-->>Client: 202 Accepted (no body — it's a notification) Client->>Server: POST /mcp — tools/list Server-->>Client: result: tools[] (add_task, list_tasks, complete_task) Client->>Server: POST /mcp — tools/call name=add_task Server-->>Client: result: content[] with the tool's text output

A quick note on versioning, since it matters if you go looking for MCP documentation yourself: the protocol evolves through dated revisions (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 at the time of writing), and the ecosystem — SDKs, hosts, and servers — doesn’t all move in lockstep. There’s a newer revision on the horizon that renames the handshake to a unified server/discover call and marks sampling as deprecated in favor of talking to LLM providers directly. None of that has landed in the SDKs or clients you’ll actually use today, so this post — theory and code both — is grounded in what’s currently implemented and, more importantly, what I could actually verify by running it.


Part 2: Building TaskFlow, an MCP server in Java Link to heading

Theory only gets you so far. Let’s build something real: TaskFlow, a small in-memory task manager exposed as an MCP server. Every piece of code and every request/response pair below is copied from a server I actually ran — locally and in Docker — while writing this post. The full source is on GitHub: umutdogan/taskflow-mcp.

By the end, TaskFlow will expose:

  • add_task — add a task with a title and priority
  • list_tasks — list tasks, optionally filtered by status
  • complete_task — mark a task done by id
  • tasks://all — a resource returning a JSON snapshot of every task

We’ll use Spring AI 2.0’s MCP Server Boot Starter, which builds tools and resources straight from annotated methods — no manual schema wiring required.

Project setup Link to heading

A standard Maven project, with Spring Boot’s parent POM and the Spring AI BOM for version management:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.0</version>
        <relativePath/>
    </parent>

    <groupId>com.umutdogan.ai</groupId>
    <artifactId>taskflow-mcp</artifactId>
    <version>1.0.0</version>
    <name>taskflow-mcp</name>
    <description>TaskFlow MCP server tutorial</description>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>2.0.1</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>

    <build>
        <finalName>taskflow-mcp</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

spring-ai-starter-mcp-server-webmvc gives us the Streamable HTTP transport on top of Spring MVC — the same remote-server style shown in the architecture diagram earlier. (Jackson’s ObjectMapper isn’t auto-configured without a full web starter pulled in, so I’m instantiating it directly in code rather than adding more dependencies than the tutorial needs.)

application.properties:

spring.application.name=taskflow-mcp

spring.ai.mcp.server.name=taskflow-mcp
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.protocol=STREAMABLE
spring.ai.mcp.server.annotation-scanner.enabled=true

server.port=8080

The domain: a task, and an in-memory store Link to heading

package com.umutdogan.ai.taskflow;

public record Task(int id, String title, String priority, boolean done) {
}
package com.umutdogan.ai.taskflow;

import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Service
public class TaskService {

    private final Map<Integer, Task> tasks = new ConcurrentHashMap<>();
    private final AtomicInteger nextId = new AtomicInteger(1);

    public Task addTask(String title, String priority) {
        int id = nextId.getAndIncrement();
        Task task = new Task(id, title, priority, false);
        tasks.put(id, task);
        return task;
    }

    public List<Task> listTasks(String status) {
        return tasks.values().stream()
                .filter(task -> matchesStatus(task, status))
                .sorted((a, b) -> Integer.compare(a.id(), b.id()))
                .toList();
    }

    public Optional<Task> completeTask(int id) {
        return Optional.ofNullable(tasks.computeIfPresent(id,
                (key, task) -> new Task(task.id(), task.title(), task.priority(), true)));
    }

    public List<Task> allTasks() {
        return listTasks(null);
    }

    private boolean matchesStatus(Task task, String status) {
        if (status == null || status.isBlank() || status.equalsIgnoreCase("all")) {
            return true;
        }
        boolean wantsDone = status.equalsIgnoreCase("done") || status.equalsIgnoreCase("complete");
        boolean wantsOpen = status.equalsIgnoreCase("open") || status.equalsIgnoreCase("pending");
        if (wantsDone) {
            return task.done();
        }
        if (wantsOpen) {
            return !task.done();
        }
        return true;
    }
}

Nothing MCP-specific yet — just a plain Spring service backed by a ConcurrentHashMap. That’s deliberate: the MCP layer should be a thin adapter in front of code you’d write anyway, not a framework your whole application has to bend around.

Exposing it as MCP: @McpTool and @McpResource Link to heading

This is the only part of the codebase that actually knows about MCP:

package com.umutdogan.ai.taskflow;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
public class TaskTools {

    private final TaskService taskService;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public TaskTools(TaskService taskService) {
        this.taskService = taskService;
    }

    @McpTool(name = "add_task", description = "Add a new task with a title and an optional priority (LOW, MEDIUM, HIGH; default MEDIUM)")
    public String addTask(
            @McpToolParam(description = "The task title", required = true) String title,
            @McpToolParam(description = "Priority: LOW, MEDIUM, or HIGH", required = false) String priority) {
        String effectivePriority = (priority == null || priority.isBlank()) ? "MEDIUM" : priority.toUpperCase();
        Task task = taskService.addTask(title, effectivePriority);
        return "Created task #%d: \"%s\" (priority: %s)".formatted(task.id(), task.title(), task.priority());
    }

    @McpTool(name = "list_tasks", description = "List tasks, optionally filtered by status: open, done, or all (default: all)")
    public String listTasks(
            @McpToolParam(description = "Filter: open, done, or all", required = false) String status) {
        List<Task> tasks = taskService.listTasks(status);
        if (tasks.isEmpty()) {
            return "No tasks found.";
        }
        return tasks.stream()
                .map(t -> "#%d [%s] %s (%s)".formatted(t.id(), t.done() ? "x" : " ", t.title(), t.priority()))
                .collect(Collectors.joining("\n"));
    }

    @McpTool(name = "complete_task", description = "Mark a task as complete by its id")
    public String completeTask(
            @McpToolParam(description = "The task id to complete", required = true) int id) {
        return taskService.completeTask(id)
                .map(t -> "Task #%d marked as complete.".formatted(t.id()))
                .orElse("No task found with id %d.".formatted(id));
    }

    @McpResource(uri = "tasks://all", name = "All Tasks",
            description = "A JSON snapshot of every task, open and completed",
            mimeType = "application/json")
    public String allTasksResource() throws Exception {
        return objectMapper.writeValueAsString(taskService.allTasks());
    }
}

A few things worth calling out:

  • @McpTool turns a plain method into a tool. Spring AI derives the JSON Schema for its parameters automatically from the method signature — you never write schema by hand.
  • @McpToolParam documents each parameter and marks whether it’s required. This description is what the model sees, so write it for an LLM deciding whether and how to call the tool, not for a human reading the source.
  • @McpResource takes a URI — here a fixed tasks://all, though it also supports templated URIs like config://{key} with the {key} segment bound automatically to a matching method parameter.
  • Tools return plain String. Under the hood that becomes a content array with a single text entry — the simplest and safest return shape for something meant to be read by a model.

And the application entry point:

package com.umutdogan.ai.taskflow;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TaskflowMcpApplication {
    public static void main(String[] args) {
        SpringApplication.run(TaskflowMcpApplication.class, args);
    }
}

Running it and talking to it directly Link to heading

Open a terminal in VS Code (Terminal → New Terminal, or Ctrl+`) and build and run the server there. That’s Ctrl, not Cmd, on macOS too — VS Code deliberately keeps its terminal shortcuts on the physical Control key on every platform, since `Cmd+`` is already taken by macOS itself (it cycles windows within the current app):

mvn clean package
java -jar target/taskflow-mcp.jar

That second command runs in the foreground and keeps the terminal attached to the server’s logs — this is deliberate, since it’s the easiest way to watch requests come in as you test the server in a moment. Once it’s up, the log confirms what got registered:

Registered tools: 3
Registered resources: 1

Leave that terminal running and open a second one for the actual testing (in VS Code, click the + in the terminal panel, or Ctrl+Shift+`) — the server is occupying the first one, so every curl command below goes in the new terminal instead.

Now the interesting part — talking to it with nothing but curl, so there’s no client library hiding what’s actually happening on the wire. First, the handshake:

macOS/Linux (bash/zsh):

curl -i -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": {"name": "curl-client", "version": "1.0.0"}
    }
  }'

Windows (PowerShell):

curl.exe -i -X POST http://localhost:8080/mcp `
  -H "Content-Type: application/json" `
  -H "Accept: application/json, text/event-stream" `
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": {"name": "curl-client", "version": "1.0.0"}
    }
  }'

Two things differ, and they’ll matter for every curl command in this post, not just this one:

  • curl.exe, not curl. PowerShell ships a built-in curl alias that actually runs Invoke-WebRequest — a completely different tool with different flags. It won’t understand -i the way you expect, and the output won’t look anything like what’s shown below. Calling curl.exe explicitly sidesteps the alias and runs the real thing.
  • Backtick (`) instead of backslash (\) for line continuation. That’s just how PowerShell spells “this line keeps going” — bash uses \, PowerShell uses `. If you’d rather not deal with it, every command in this post also works fine typed as a single line with no continuation characters at all.

The single-quoted JSON body itself needs no changes — both shells treat single quotes as literal, so the embedded double quotes inside the JSON pass through untouched either way.

Either shell gets you the same response:

HTTP/1.1 200
Mcp-Session-Id: a1812dd6-fdff-41f5-bacf-02c0d3337b3d
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"taskflow-mcp","version":"1.0.0"}}}

That Mcp-Session-Id header matters — every request after this one needs it. Copy it out of the response and keep it around for the rest of this section:

macOS/Linux (bash/zsh):

SESSION="a1812dd6-fdff-41f5-bacf-02c0d3337b3d"
echo "$SESSION"   # sanity check — should print the id straight back

Windows (PowerShell):

$SESSION = "a1812dd6-fdff-41f5-bacf-02c0d3337b3d"
echo $SESSION   # sanity check — should print the id straight back

Don’t skip that echo — it’s the fastest way to catch a copy-paste mistake (a missing character, a stray quote) before it turns into a confusing “Session ID missing” error three commands from now. From here on, every command is written as a single line with no continuation characters at all, specifically so it’s copy-paste portable between bash/zsh and PowerShell without edits — remember to swap in curl.exe on Windows, per the note above.

Now confirm the handshake with the one-way notifications/initialized:

curl -X POST http://localhost:8080/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "Mcp-Session-Id: $SESSION" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

Run that and your terminal will print… nothing. No output at all, just the prompt returning. That’s correct, not a bug: this message has no id, which in JSON-RPC means it’s a notification — the server has nothing to reply with, so it sends back 202 Accepted and an empty body. If you add -i to see the headers, that’s all you’ll get:

HTTP/1.1 202
Content-Length: 0

With the session confirmed, list the tools:

curl -X POST http://localhost:8080/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "Mcp-Session-Id: $SESSION" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

This time something prints, but it’s worth pausing on the shape of it, because it looks a little different from a typical JSON API response:

id:a1812dd6-fdff-41f5-bacf-02c0d3337b3d
event:message
data:{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add_task", ...

That’s not curl garbling the output — it’s genuinely what came back. We asked for Accept: application/json, text/event-stream, and Streamable HTTP is allowed to answer in either shape; here the server chose to frame its single response as one Server-Sent Event (id: / event: / data: lines), which is exactly the same framing it would use if a response needed to stream multiple events instead of one. The part you actually care about is the data: line — strip that prefix and it’s a normal JSON-RPC response with a full JSON Schema for each tool, generated straight from the @McpTool/@McpToolParam annotations. This is exactly what an LLM sees when deciding how to call add_task:

{"jsonrpc":"2.0","id":2,"result":{"tools":[
  {"name":"add_task","description":"Add a new task with a title and an optional priority (LOW, MEDIUM, HIGH; default MEDIUM)",
   "inputSchema":{"type":"object","properties":{
     "title":{"type":"string","description":"The task title"},
     "priority":{"type":"string","description":"Priority: LOW, MEDIUM, or HIGH"}},
     "required":["title"]}},
  {"name":"complete_task","description":"Mark a task as complete by its id",
   "inputSchema":{"type":"object","properties":{
     "id":{"type":"integer","format":"int32","description":"The task id to complete"}},
     "required":["id"]}},
  {"name":"list_tasks","description":"List tasks, optionally filtered by status: open, done, or all (default: all)",
   "inputSchema":{"type":"object","properties":{
     "status":{"type":"string","description":"Filter: open, done, or all"}},
     "required":[]}}
]}}

(From here on, the responses below are shown with that id:/event: wrapper already stripped, just the data: payload — you’ll still see the full framed version in your own terminal.)

Now call one:

curl -X POST http://localhost:8080/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "Mcp-Session-Id: $SESSION" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_task","arguments":{"title":"Write MCP blog post","priority":"high"}}}'
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Created task #1: \"Write MCP blog post\" (priority: HIGH)"}],"isError":false}}

isError is worth noticing even though it’s false here — every tool result carries it, so a client can tell a handled failure (the tool ran but reported a problem, isError: true with the problem described in content) apart from a transport-level failure (a JSON-RPC error object instead of result, which means the call never really completed).

Last, read the resource back as a structured JSON snapshot rather than the free-text summary list_tasks gives you:

curl -X POST http://localhost:8080/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "Mcp-Session-Id: $SESSION" -d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"tasks://all"}}'
{"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"tasks://all","mimeType":"application/json","text":"[{\"id\":1,\"title\":\"Write MCP blog post\",\"priority\":\"HIGH\",\"done\":false}]"}]}}

Every one of those requests and responses — SSE framing included — is copy-pasted from an actual run in a second terminal, next to a first one still running the server, not reconstructed from the spec.

Dockerizing TaskFlow Link to heading

One prerequisite before any of this: the docker command you’re about to run is a client — it needs an actual Docker daemon to talk to, and on macOS and Windows that daemon comes from Docker Desktop (or an equivalent like Colima or Rancher Desktop), not from the docker CLI alone. If it isn’t installed, install and open it first; if it’s installed but not running, launch it and wait for it to report itself ready (Docker Desktop’s whale icon settles once the daemon is up). Skip this and docker build/docker run will fail immediately with something like Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — a client-side error that has nothing to do with the Dockerfile itself, which is an easy thing to waste time on if you don’t know to look for it. (On Linux, the Docker Engine typically runs as a system service instead, so there’s no separate desktop app to open — sudo systemctl start docker if it’s ever not running.)

Before the Dockerfile itself, a word on where it goes, since that trips people up more than anything in it: it lives at the project root, as a plain file literally named Dockerfile — no extension, capital D, sitting right next to pom.xml:

taskflow-mcp/
├── Dockerfile
├── pom.xml
└── src/
    └── main/
        ├── java/com/umutdogan/ai/taskflow/
        │   ├── Task.java
        │   ├── TaskService.java
        │   ├── TaskTools.java
        │   └── TaskflowMcpApplication.java
        └── resources/
            └── application.properties

In VS Code: right-click the top-level folder in the Explorer panel (not inside src/) and choose New File…, then type Dockerfile and hit enter — VS Code recognizes the name without an extension and switches on Dockerfile syntax highlighting automatically (installing Microsoft’s official Docker extension gets you linting and hover docs on top of that, but it’s not required for any of this to work). Paste the contents below into it and save.

The location matters because of the COPY instructions inside — COPY pom.xml . and COPY src ./src are resolved relative to the build context, which is the directory you point docker build at (the last argument, ., in the command a bit further down). Put the Dockerfile anywhere else, or run docker build from a different folder, and those paths won’t resolve to anything.

A standard two-stage build — compile with Maven, run on a slim JRE:

# --- Build stage ---
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build

COPY pom.xml .
RUN mvn -q -B dependency:go-offline

COPY src ./src
RUN mvn -q -B clean package -DskipTests

# --- Runtime stage ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

RUN addgroup -S taskflow && adduser -S taskflow -G taskflow
COPY --from=build /build/target/taskflow-mcp.jar app.jar
USER taskflow

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Copying pom.xml before src lets Docker cache the dependency download layer separately from application code — change your Java and you don’t re-download the internet. Running as a non-root taskflow user rather than the container default root is a small habit worth keeping for anything you’d actually deploy.

One thing to do first: if the server from the earlier section is still running in your first terminal, stop it (Ctrl+C in that terminal, on both bash and PowerShell). The container is about to claim port 8080 too, and Docker will refuse to start if something’s already listening there — you’ll get a blunt ports are not available / address already in use error if you skip this. I hit exactly that error myself while double-checking this section, from a leftover local run I’d forgotten about.

With port 8080 free, run these from the project root, in either of your terminals:

docker build -t taskflow-mcp:tutorial .
docker run -d --rm -p 8080:8080 --name taskflow taskflow-mcp:tutorial

docker build reads the Dockerfile in the current directory (that trailing . is the build context — the project root, per the note above) and works through the two stages, printing each step as it goes; the first run downloads the Maven and JRE base images and takes a bit longer, later runs reuse cached layers and are much faster. docker run then starts a container from that image: -d runs it detached (in the background, handing your terminal back immediately instead of blocking like java -jar did), --rm deletes the container automatically once it stops so you don’t accumulate stopped containers from every test run, -p 8080:8080 maps the container’s port to the same port on your machine, and --name taskflow gives it a name you can refer to instead of a container ID.

Confirm it’s actually up before testing it:

docker ps --filter "name=taskflow"
CONTAINER ID   IMAGE                   COMMAND               CREATED         STATUS                   PORTS                                       NAMES
34ef212e7db1   taskflow-mcp:tutorial   "java -jar app.jar"   2 seconds ago   Up Less than a second   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   taskflow

And if you want to see the same Spring Boot startup log you saw running it locally — proof this is the same application, just wrapped in a container — docker logs shows it:

docker logs taskflow
...
o.s.a.m.s.c.a.McpServerAutoConfiguration : Registered tools: 3
o.s.a.m.s.c.a.McpServerAutoConfiguration : Registered resources: 1
o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'
c.u.ai.taskflow.TaskflowMcpApplication   : Started TaskflowMcpApplication in 2.341 seconds (process running for 2.912)

Now the exact same curl handshake from earlier, run against the container instead of the local JAR, produces the exact same shape of response — a new session id, but identical behavior:

HTTP/1.1 200
Mcp-Session-Id: 60b8299b-e9b5-49ca-a45b-f04bc10ffbd0
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"taskflow-mcp","version":"1.0.0"}}}

That’s the whole point of the transport layer being separate from the data layer: nothing about what you send changes when the server moves from java -jar on your laptop to a container anywhere else.

When you’re done, stop the container — --rm above means stopping it is also enough to clean it up entirely, no separate docker rm:

docker stop taskflow

Connecting a real client Link to heading

Talking to your server with curl is great for understanding the protocol, but you’ll want to actually use it from an AI application. A few options:

  • MCP Inspector — the official debugging tool. npx @modelcontextprotocol/inspector gives you a UI to connect to your server, browse its tools/resources, and call them interactively without writing any client code.

  • Claude Code — register it straight from the CLI, no config file editing required:

    claude mcp add --transport http taskflow http://localhost:8080/mcp

    Run /mcp inside Claude Code afterward to confirm it connected — it lists every configured server with its source and status:

    Manage MCP servers
    6 servers
    
      Local MCPs (/Users/umutdogan/.claude.json [project: /Users/umutdogan/Projects/taskflow-mcp])
    › taskflow · ✓ connected · 3 tools

    (If you’re using Claude Code through the Claude desktop app’s Code tab rather than a standalone terminal, the same claude mcp add command and /mcp check apply — it’s the same CLI and the same ~/.claude.json/.mcp.json config underneath either way.)

    From there TaskFlow’s tools are just things the assistant can decide to call on its own — no more curl needed. A real session, prompt by prompt:

    > Add a task: buy milk, high priority
    
      Called taskflow
    
      Task #1 "Buy milk" created with HIGH priority.
    
    > What are my open tasks?
    
      Called taskflow
    
      You have one open task: #1 Buy milk (HIGH priority).
    
    > Mark task 1 as done
    
      Called taskflow
    
      Task #1 "Buy milk" is marked as done.
    
    > Show me all tasks, including done ones
    
      Called taskflow
    
      #1 [x] Buy milk (HIGH) — done.

    No JSON-RPC in sight — the assistant reads your intent, picks add_task/list_tasks/complete_task, fills in the arguments, and reports back in plain English. That translation layer is the entire point of MCP: the same three @McpTool methods you wrote once are now callable from natural language, from curl, or from any other MCP-speaking client, unchanged.

  • Claude Desktop — add it to the JSON config file (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json). Since TaskFlow speaks Streamable HTTP rather than stdio, the entry is just a url, with no command/args:

    {
      "mcpServers": {
        "taskflow": {
          "url": "http://localhost:8080/mcp"
        }
      }
    }

    Fully quit and reopen Claude Desktop after saving — reloading the window isn’t enough for it to pick up config changes.

Wrapping up Link to heading

MCP’s value isn’t in any single primitive — it’s in the fact that “tool,” “resource,” and “prompt” mean the same thing to every host and every server that implements the protocol. Once you’ve built one MCP server, you understand the shape of all of them, and everything you write is usable from any MCP-compatible client without extra integration work on either side.

From here, a natural next step is adding a prompt (a reusable template, using @McpPrompt) or trying the stdio transport for a server meant to run as a local subprocess rather than a remote service — the code you write barely changes, only the transport configuration does.

The complete, runnable source for TaskFlow is on GitHub — clone it, run it, break it: github.com/umutdogan/taskflow-mcp.