A small, deliberately illustrative demonstration of what a Model Context
Protocol (MCP) server adds to a plain language-model interaction. The domain
is a recipe assistant. A local language model (Ollama with llama3.2) already
knows countless recipes; what it does not know is your pantry inventory and
your dietary profile. With an MCP server attached, that gap closes — and the
user interface places both answers side by side so the difference is plainly
visible.
The project is structured so that each MCP role from the specification materialises as a separate process:
- the host (Vaadin UI),
- the client (the MCP client embedded in the Vaadin UI),
- the server (the MCP server module),
- a downstream domain system (the REST server holding the pantry and dietary profile in memory).
- Goals and non-goals
- Architecture at a glance
- Module overview
- Technology stack
- Prerequisites
- Build
- Running the demonstration
- Demonstration scenarios
- REST API reference
- MCP protocol surface
- Tool catalogue
- Project layout
- Configuration and conventions
- Troubleshooting
- Out of scope
- Licence
The implementation is pedagogical, not productive. Every moving part is small enough to read in one sitting, and the separation of concerns is visible in the deployment topology itself — three independent JVM processes that communicate over HTTP, even though a single-process design would technically be simpler.
Concretely, the project aims to:
- show, side by side, what an LLM produces without and with live access to user-specific data via MCP;
- expose a fully spec-compliant MCP
Streamable HTTPtransport that another MCP client (Claude Code, a CLI, …) could equally well speak to; - keep the moving parts to the bare minimum — JDK APIs first, only four external libraries beyond that.
The project deliberately does not aim to be production-ready. See Out of scope for what has been left out and why.
+---------------------+ +---------------------+ +---------------------+
| vaadin-ui :8888 | HTTP | mcp-server :8090 | HTTP | rest-server :8080 |
| | ----> | | ----> | |
| Vaadin + Jetty | MCP | JSON-RPC 2.0 | REST | JDK HttpServer |
| Ollama client | | Tool registry | | Pantry + Profile |
+----------+----------+ +---------------------+ +---------------------+
|
v HTTP (SSE)
+------------------+
| Ollama :11434 |
| model llama3.2 |
+------------------+
For every user question the Vaadin UI dispatches two chat requests to the Ollama server in parallel — one without any tool descriptions attached, and one with the eight MCP tools attached. The two streams populate the left and right answer panels respectively. The right-hand panel additionally shows the sequence of tool calls the model decided to make, with their arguments and a compact preview of each result.
The sidebar on the far right talks directly to the REST server, never
through MCP. That keeps the demonstration honest: edits in the sidebar appear
in the very next MCP-enabled chat response because the language model now
consults a freshly changed pantry through list_pantry_items and friends.
| Module | Port | Role |
|---|---|---|
common |
— | Shared records (PantryItem, DietaryProfile), shared Jackson ObjectMapper, port constants |
rest-server |
8080 | Plain HTTP/JSON CRUD over the in-memory pantry and dietary profile, built on com.sun.net.httpserver |
mcp-server |
8090 | MCP server (spec 2025-06-18, Streamable HTTP transport) that exposes eight tools over JSON-RPC 2.0 |
vaadin-ui |
8888 | Vaadin Flow UI on embedded Jetty, with a streaming Ollama client and an MCP client |
Each module has its own Main class; each module can be started in isolation
with ./mvnw exec:java -pl <module>.
| Concern | Choice |
|---|---|
| JDK | 26 |
| Build tool | Maven 4.0.0-rc-5 (multi-module, invoked via ./mvnw) |
| Group ID | com.svenruppert.mcp |
| Version | 1.0.0-SNAPSHOT |
| JSON | Jackson Databind 2.21.x |
| HTTP server | com.sun.net.httpserver.HttpServer (JDK) |
| HTTP client | java.net.http.HttpClient (JDK) |
| Web UI | Vaadin Flow 25.1.x on embedded Jetty 12 (Jakarta EE 11) |
| Language model | Local Ollama running llama3.2 |
| Ollama API surface | OpenAI-compatible /v1/chat/completions with stream: true |
| MCP transport | Streamable HTTP, MCP specification version 2025-06-18 |
| Streaming | Server-Sent Events from Ollama, pushed to the UI via Vaadin @Push (WebSocket transport) |
| Logging | com.svenruppert:core HasLogger (SLF4J under the bonnet) |
| Concurrency primitives | ConcurrentHashMap, AtomicReference, virtual threads |
Every data carrier is a Java record; collections held by the server are
guarded by ConcurrentHashMap so that the parallel left/right chats and the
sidebar can mutate the same state without locking ceremony. Each HTTP server
hands its requests off to Executors.newVirtualThreadPerTaskExecutor().
The complete external dependency list, beyond the JDK itself, is fixed to:
com.fasterxml.jackson.core:jackson-databind(+ JSR-310 + JDK 8 modules),com.vaadin:vaadin-core(which transitively pulls Vaadin Flow, Atmosphere, etc.),org.eclipse.jetty.ee11:jetty-ee11-*(servlet container + WebSocket),jakarta.servlet:jakarta.servlet-api(pulled in compile-scope because the embedded server needs the API at runtime; the Jetty BOM marks itprovided),com.svenruppert:core(theHasLoggerinterface),org.slf4j:slf4j-simple(the runtime backend for SLF4J).
Note on the logger artefact. The implementation prompt refers to a
com.svenruppert:logger-adapterartefact; the actual coordinate on Maven Central that providescom.svenruppert.dependencies.core.logger.HasLoggeriscom.svenruppert:core:06.02.01. That is what the POMs use.
You will need:
-
JDK 26 on
PATH(java -versionshould report 26), -
Node.js on
PATH(the Vaadin Maven plugin invokes Vite to bundle the frontend; Node is only required at build time, not at run time), -
Ollama installed locally, with the
llama3.2model pulled:ollama serve # starts the daemon (in its own terminal) ollama pull llama3.2 # one-off, fetches the model
Maven is not required separately — the project ships with a Maven
wrapper pinned to Apache Maven 4.0.0-rc-5. The first invocation of
./mvnw downloads the requested Maven distribution into
~/.m2/wrapper/dists/; subsequent invocations reuse it. If you prefer your
own Maven installation, any Maven 4 release candidate (or a recent Maven 3.9)
will work as well — simply call mvn instead of ./mvnw.
The application uses ports 8080, 8090, 8888 and 11434. Make sure they are free before starting.
From the project root:
./mvnw clean package # macOS / Linux
mvnw.cmd clean package # WindowsThis will:
- Download Maven 4.0.0-rc-5 on first run (cached under
~/.m2/wrapper/). - Compile every module against
--release 26. - Run the Vaadin Maven plugin (
prepare-frontend+build-frontend) for thevaadin-uimodule so that the production frontend bundle is generated intovaadin-ui/target/. The first build downloads Node packages and may take a few minutes; subsequent builds are nearly instantaneous. - Install each module's JAR into the local Maven repository so that
./mvnw exec:java -pl …can pick everything up.
Open three terminals at the project root and start the modules one by one, in this order:
# terminal 1 — REST server (port 8080)
./mvnw exec:java -pl rest-server
# terminal 2 — MCP server (port 8090)
./mvnw exec:java -pl mcp-server
# terminal 3 — Vaadin UI (port 8888)
./mvnw exec:java -pl vaadin-uiA fourth terminal should already be running ollama serve on port 11434.
Once all four processes report that they are listening, open http://localhost:8888/ in a browser. You should see the Recipe Assistant view with:
- an input area + an Enable MCP checkbox + a Send button at the top,
- two answer panels side by side (Without MCP on the left, With MCP on the right, the latter with a collapsible Tool Calls section above the streamed text),
- a Maintenance sidebar on the far right that lets you edit the pantry grid and the dietary profile directly.
Press Enter in the input field or click Send to dispatch a question.
To shut everything down, hit Ctrl-C in each terminal. All state is held in
memory and is reset to the seed data on every restart — by design, so that
demonstrations are reproducible.
Each of the following scenarios reproduces against the seeded data.
-
What could I cook tonight? The left answer is generic and either flags that it does not know your pantry or makes assumptions. The right answer names concrete ingredients actually in stock, respects the vegetarian diet, avoids nuts and keeps to low-sodium options.
-
Which foods are about to expire? The left answer cannot give a meaningful response. The right answer enumerates the soon-to-expire items precisely (powered by
list_expiring_items). -
I just used two tomatoes and some mozzarella. The right side invokes
consume_pantry_itemtwice. The sidebar grid updates to show the reduced quantities as soon as the response completes. -
What else can I prepare without going shopping? The right-hand answer differs noticeably from scenario 1, because the pantry state has changed in the meantime.
-
Change the diet to
veganin the sidebar, then ask scenario 1 again. The right-hand answer adapts immediately, confirming that the model reads the dietary profile throughget_dietary_profileon every turn.
The REST server speaks plain JSON over HTTP on port 8080. All error responses
follow the shape { "error": "human-readable description" }.
| Method | Path | Description | Status codes |
|---|---|---|---|
GET |
/pantry |
List every pantry item (sorted by name) | 200 |
GET |
/pantry/{name} |
Fetch a single item | 200, 404 |
POST |
/pantry |
Create a new item | 201, 400, 409 (already exists) |
PUT |
/pantry/{name} |
Replace an item | 200, 400, 404 |
DELETE |
/pantry/{name} |
Remove an item | 204, 404 |
PATCH |
/pantry/{name}/quantity |
Adjust quantity atomically; body { "delta": -2.0 } |
200, 400, 404, 409 (would < 0) |
GET |
/pantry/expiring?days=7 |
Items whose best-before date is within N days | 200, 400 |
GET |
/profile |
Read the dietary profile | 200 |
PUT |
/profile |
Replace the dietary profile | 200, 400 |
record PantryItem(
String name, // primary key, e.g. "Tomatoes"
double quantity,
String unit, // "g", "ml", "piece", "bunch"
Optional<LocalDate> bestBefore,
String category // "Pantry" or "Refrigerator"
) {}
record DietaryProfile(
String diet, // "omnivore", "vegetarian", "vegan", …
List<String> allergies,
List<String> medicalConstraints,
List<String> dislikes
) {}Fifteen pantry items are seeded on every start (flour, eggs, tomatoes, mozzarella, basil, olive oil, spaghetti, garlic, onions, rice, lentils, yogurt, carrots, lemons, parmesan), each with a realistic best-before date computed relative to today. The dietary profile is seeded as:
{
"diet": "vegetarian",
"allergies": [],
"medicalConstraints": ["low-sodium"],
"dislikes": ["nuts"]
}curl -s http://localhost:8080/pantry | head -c 200
curl -s "http://localhost:8080/pantry/expiring?days=7"
curl -s http://localhost:8080/profile
curl -s -X PATCH http://localhost:8080/pantry/Tomatoes/quantity \
-H 'Content-Type: application/json' -d '{"delta": -100}'The MCP server implements the Streamable HTTP transport from MCP
specification version 2025-06-18, with a single endpoint:
POST /mcp Content-Type: application/json
Each request produces exactly one JSON response — Server-Sent Events are not used on the protocol level (Ollama is the SSE source in this stack, not MCP).
| Method | Direction | Purpose |
|---|---|---|
initialize |
request | Negotiate protocol version and capabilities |
notifications/initialized |
notification | Confirm initialisation; server returns 202 |
tools/list |
request | Enumerate the eight tools |
tools/call |
request | Invoke a tool with named arguments |
ping |
request | Health check; always returns {} |
On initialize the server returns a fresh UUID in the Mcp-Session-Id
response header. Clients are expected to echo it on subsequent requests, but
the server treats unknown or missing IDs leniently because the demonstration
does not depend on session isolation.
| Code | Meaning |
|---|---|
-32700 |
Parse error |
-32600 |
Invalid request |
-32601 |
Method not found |
-32602 |
Invalid parameters |
-32603 |
Internal error |
Tool-level failures (a non-2xx response from the downstream REST server, an
out-of-range parameter, …) are not returned as JSON-RPC errors. Instead
they are wrapped in a successful tools/call response with isError: true,
as the MCP specification prescribes.
# initialize
curl -s -i -X POST http://localhost:8090/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"smoke","version":"0"}}}'
# tools/list
curl -s -X POST http://localhost:8090/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | python3 -m json.tool
# tools/call list_pantry_items
curl -s -X POST http://localhost:8090/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"list_pantry_items","arguments":{}}}' \
| python3 -m json.toolThe MCP server exposes eight tools. Their descriptions are intentionally verbose — the description string is the primary material the language model uses to decide whether and how to invoke a tool, so it pays to be generous.
| Tool | Purpose | Downstream REST call |
|---|---|---|
list_pantry_items |
Full inventory | GET /pantry |
get_pantry_item |
A single named item | GET /pantry/{name} |
add_pantry_item |
Create a new item | POST /pantry |
consume_pantry_item |
Reduce the quantity by a reported consumption | PATCH /pantry/{name}/quantity (negative delta) |
remove_pantry_item |
Delete an item entirely | DELETE /pantry/{name} |
list_expiring_items |
Items whose best-before date is within N days (default 7) | GET /pantry/expiring?days=N |
get_dietary_profile |
Read the dietary profile | GET /profile |
update_dietary_profile |
Replace the entire dietary profile | PUT /profile |
Each tool ships with a JSON-Schema inputSchema so that the language model
can validate the parameter shape it intends to emit.
recipe-assistant/
├── .mvn/wrapper/maven-wrapper.properties pins Maven 4.0.0-rc-5
├── mvnw, mvnw.cmd wrapper launchers
├── pom.xml parent POM (packaging=pom)
├── common/
│ ├── pom.xml
│ └── src/main/java/com/svenruppert/mcp/common/
│ ├── DietaryProfile.java
│ ├── Json.java shared ObjectMapper configuration
│ ├── PantryItem.java
│ └── Ports.java port + URL constants
├── rest-server/
│ ├── pom.xml
│ └── src/main/java/com/svenruppert/mcp/rest/
│ ├── Http.java request/response helpers
│ ├── Main.java bootstrap
│ ├── PantryHandler.java /pantry HttpHandler
│ ├── PantryRepository.java ConcurrentHashMap-backed store
│ ├── ProfileHandler.java /profile HttpHandler
│ ├── ProfileRepository.java AtomicReference-backed store
│ ├── RestServer.java wires Jetty/HttpServer + handlers
│ └── SeedData.java seeds 15 items + 1 profile
├── mcp-server/
│ ├── pom.xml
│ └── src/main/java/com/svenruppert/mcp/mcp/
│ ├── JsonRpc.java sealed Success / Error response types
│ ├── Main.java
│ ├── McpHandler.java /mcp HttpHandler (JSON-RPC dispatcher)
│ ├── McpServer.java
│ ├── RegisteredTool.java
│ ├── RestClient.java downstream client for the REST server
│ ├── ToolRegistry.java
│ ├── ToolResult.java
│ └── Tools.java the eight tool definitions
└── vaadin-ui/
├── pom.xml
└── src/main/
├── java/com/svenruppert/mcp/ui/
│ ├── AppShell.java @Push configuration
│ ├── Main.java sets the default locale + bootstraps Jetty
│ ├── MainView.java @Route("") — split layout + sidebar
│ ├── McpClient.java MCP protocol client
│ ├── OllamaClient.java streaming OpenAI-compatible client
│ ├── PantryClient.java REST client for the sidebar
│ ├── ProfileClient.java REST client for the sidebar
│ ├── Prompts.java the two system prompts
│ ├── Sidebar.java maintenance panel (grid + form)
│ ├── ToolBridge.java MCP <-> Ollama tool conversion
│ └── UiServer.java embedded Jetty bootstrap
├── resources/simplelogger.properties
└── webapp/WEB-INF/web.xml minimal Jakarta web.xml
- Every domain type is a record. Optional fields use
Optional<T>and Jackson is configured to omit absent values viaInclude.NON_ABSENT. - Mutable state is held in
ConcurrentHashMaporAtomicReference, never in plain fields. - No class declares a
static Loggerfield. Every class that emits log output implementscom.svenruppert.dependencies.core.logger.HasLoggerand callslogger().info(...), so messages are routed through SLF4J with the parameterised placeholder syntax ({}), not string concatenation. - HTTP-level errors are mapped to status codes; in-process errors prefer sealed result types over thrown exceptions.
The entire application — source, identifiers, log messages, UI strings, MCP
tool descriptions, system prompts, seed data — is in English. The JVM
default locale is forced to Locale.ENGLISH in every module's Main class so
that, for example, the HTML lang attribute Vaadin emits remains en even on
machines whose default locale is German or French.
Ollama responses are consumed line by line via
HttpResponse.BodyHandlers.ofInputStream(). Each data: { … } line is
decoded and either appended to the answer panel through UI.access(…)
(text deltas) or accumulated into a pending tool call (tool_calls deltas).
On finish_reason == "tool_calls" the accumulated calls are dispatched to
the MCP server, their results are appended as role: "tool" messages, and
the next streaming request is issued. The loop is bounded by five
iterations — beyond that the right-hand panel emits an apologetic message
and stops.
Two non-obvious decisions are worth flagging for anyone reading the code:
UiServeruses a plainServletContextHandlerrather thanWebAppContext. The latter installs aWebAppClassLoaderwhose server-class isolation hides everyorg.eclipse.jetty.*class from the webapp, which breaks Vaadin's own initialisers.- Vaadin 24/25 no longer auto-registers
VaadinServletvia aServletContainerInitializer. The bootstrap therefore registersVaadinServletmanually and explicitly adds the seven Vaadin SCIs (LookupServletContainerInitializer,RouteRegistryInitializer,VaadinAppShellInitializer,AnnotationValidator,ErrorNavigationTargetInitializer,WebComponentExporterAwareValidator,WebComponentConfigurationRegistryInitializer) viaServletContainerInitializerHolder, passing the relevant@HandlesTypesclasses inline. - Vaadin 25 targets Jakarta EE 11, so the Jetty modules used are
jetty-ee11-*(notee10). Theweb.xmldeclaresversion="6.1"and the matching XSD.
| Symptom | Likely cause and remedy |
|---|---|
Address already in use on 8080/8090/8888 |
Another process holds the port. Find it with lsof -i :PORT and stop it. |
Could not connect to Ollama on localhost:11434 |
ollama serve is not running, or it is bound to a different port. |
model 'llama3.2' not found |
Run ollama pull llama3.2 once. Other models may also work but were not validated. |
Browser shows 503 Service Unavailable |
The Vaadin context did not finish initialising. Check the UI terminal for stack traces. |
| Browser shows a directory listing instead of the UI | ./mvnw clean package was not run; the frontend bundle is missing from target/. |
| The right panel never produces tool calls | The language model decided no tool was needed. Try a question that explicitly mentions the pantry or the user's diet. |
| The right panel says Reached the maximum tool-call iteration limit | The model entered a tool-call loop. Reword the question or restart the chat. |
| The sidebar shows stale data after an MCP-driven write | Click Refresh beneath the pantry grid; the UI auto-refreshes after each MCP turn but a hard reload guarantees consistency. |
The following are intentionally left out and must not be retrofitted during the initial implementation, because their absence is part of the demonstrative clarity:
- Authentication, authorisation and TLS.
- Persistence across restarts. All state is reseeded from
SeedDataon everyMaininvocation. - Internationalisation of UI strings beyond English.
- Production-grade observability (metrics, distributed tracing, structured-event logging).
- Spring, Spring Boot, Jakarta CDI, or any other dependency-injection framework. The Jakarta Servlet API is permitted only as Vaadin's unavoidable transport layer.
This project accompanies a blog post in the Java Publications series. Use it freely for teaching, demonstration and your own learning. No warranty is expressed or implied.