A tiny but complete MCP 2026-07-28 server built with Mocapi, written for the "You down with MCP?" talk. It models a coffee shop and exercises every primitive the talk covers, plus the new stateless-spec features.
Because the protocol is stateless, it's a single plain Spring Boot app: no session store to stand up, no shared state between requests. Scale it by running more copies behind a load balancer.
Heads up: MCP 2026-07-28 is still a draft (targeted for release around July 2026). It isn't released in Mocapi yet, so its support currently lives on the
mcp-2026-07-28branch (it'll land onmainonce the spec is final). Tooling is also catching up — notably, the public MCP Inspector does not support this version yet. Drive the demo with the bundled storefront orcurl.
It ships with a browser storefront — The Mocapi Cafe — served by the app itself at
http://localhost:8080/. Every button is a real call to the /mcp endpoint, and a side-by-side
"wire log" prints the actual headers and JSON for each request and response. That's the easiest way
to drive the demo; raw curl (documented below) is the other.
| File | MCP concept | Talk slide |
|---|---|---|
OrderTools#placeOrder |
Tool — model-controlled action; returns a server-issued orderId (the stateless handle) |
Tools / Stateless |
OrderTools#orderInteractive |
Elicitation via Multi Round-Trip Requests — pauses mid-call to ask the human, then resumes | Elicitation / MRTR |
OrderTools#brew |
Progress notifications — streams notifications/progress on the SSE response, then returns the result |
Progress / Streaming |
MenuResources#menu |
Resource — app-controlled context; public + ttlMs so clients can cache it |
Resources / Deprecations(caching) |
MenuResources#drink |
Resource template — parameterized URI menu://drinks/{slug} |
Resources detail |
OrderResources#order |
Resource template order://{orderId} — reads back the handle place-order returned; non-cacheable (ttlMs 0) |
Resources / Stateless |
BaristaPrompts#recommend |
Prompt — user-controlled template (recommend-a-drink) |
Prompts detail |
The three control models line up with the overview slide: tools = model-controlled, resources = app-controlled, prompts = user-controlled.
- JDK 25 (Mocapi targets Java 25)
- Maven 3.9+
- Docker — optional, only for the Jaeger tracing demo (see Observability & ops)
MCP 2026-07-28 isn't released in Mocapi yet, so its support isn't on Maven Central — it currently
lives on the mcp-2026-07-28 branch (it'll land on main once the spec is final). Build that
branch into your local ~/.m2 first:
git clone https://github.com/callibrity/mocapi.git
cd mocapi
git checkout mcp-2026-07-28 # the unreleased 2026-07-28 work
mvn clean install -DskipTests # publishes 0.18.0-SNAPSHOT to ~/.m2This demo depends on 0.18.0-SNAPSHOT (see the mocapi.version property in pom.xml). If Maven
reports Could not resolve dependencies … mocapi-*:0.18.0-SNAPSHOT, you haven't run this step or you
built a branch without the 2026-07-28 work.
mvn spring-boot:runThe app starts on port 8080: the storefront is at http://localhost:8080/ and the stateless MCP
endpoint is at http://localhost:8080/mcp.
Open http://localhost:8080/. The cafe storefront is on the left; the live wire log — styled as a
receipt printer — is on the right. Every action is one real /mcp call, and the receipt prints the
headers, _meta, and the response. Now take the guided tour below.
Work through these in order. Each step pairs a click in the storefront with the MCP primitive behind it and the source that implements it — so you can watch it happen on the receipt (the wire log), then open the file and see how few lines it took. Hit clear on the wire log for a clean start, and keep the window wide enough to show the storefront and receipt side by side.
Everything you see was loaded over MCP — there's no REST API behind this page. Two receipts print
before you touch anything: discover tools and read menu. The menu board itself is a resource the
page read on startup. The browser client that makes these calls is one file:
src/main/resources/static/index.html — its mcp() helper
shows the exact headers and _meta envelope every request needs.
MCP's primitives are split by who is in control, and all three are on screen at once:
- Resources — app-controlled context (the menu board).
- Tools — model-controlled actions (the order counter).
- Prompts — user-controlled templates (the barista).
Keep that lens as you go; each step below is one of the three.
Click details on a drink (say, Caffe Latte). A resource is context the host chooses to pull in;
the model can't reach for it on its own. It opens in a modal labeled with the URI it came from.
- On the receipt:
resources/read, routed by the headerMcp-Name: menu://drinks/latte. The response carriesttlMs+cacheScope: public— the menu is cacheable, so clients needn't re-fetch it every turn. - In the code:
MenuResources#drink— a@McpResourceTemplateon the parameterized URImenu://drinks/{slug}. The whole-menu read on load isMenuResources#menu(@McpResource,menu://drinks) in the same file.
At the Order Counter pick latte / MEDIUM / OAT and click Place order. Tools are what the model
calls to do something. Notice what comes back: an orderId. That's the handle — there is no
session, so the id is the state the client carries forward.
- On the receipt:
tools/call place-order; thestructuredContentblock withorderIdandstatusUri. - In the code:
OrderTools#placeOrder— a@McpToolwhose parameters become the input schema (note theSize/Milkenums surface as dropdowns). It returns anOrderTicketrecord; Mocapi derives the output schema from it. - The storefront then auto-brews the order on that handle — watch the progress bar fill (that's
step 6).
place-orderandbrewstay separate tools, though: an agent orcurlcan place without brewing. - Bad input is rejected before the handler runs —
drinkis validated with Jakarta constraints; see The production extras.
Click status on the ticket that just appeared. The client hands that id back as a resource URI;
the server remembers nothing about you — it just resolves the handle.
- On the receipt:
resources/read order://ORD-…. This one is non-cacheable (ttlMs: 0) — the deliberate opposite of the cacheable menu. - In the code:
OrderResources#order— a@McpResourceTemplateonorder://{orderId}that looks the order up by its handle.
Click Order interactively. Sometimes a tool needs more from the human mid-call. The old protocol
held a connection open and waited; 2026-07-28 does a Multi Round-Trip Request instead — the server
pauses and returns.
- On the receipt:
resultType: input_required, the red ⏸ AWAITING CUSTOMER stamp, and a signedrequestStatetoken. No socket stays open; the paused state is encoded in that token. - Fill the form and click
Send & resume. The client re-issues the same call with the signed state and the answers; the original tool call picks up where it left off and finishes — fully stateless. The receipt printsresume MRTR (accept)and the ticket appears. - In the code:
OrderTools#orderInteractive— it callsctx.elicit(...)to describe the form. Read the method's comment: because the handler re-runs on resume, pre-elicitation work must be cheap and idempotent.
Type a mood (e.g. cozy) and click Recommend. Prompts are invoked by a person — think slash
command. The server hands back ready-made messages for the model to start from.
- On the receipt:
prompts/get recommend-a-drink; the${mood}placeholder came back filled in. - In the code:
BaristaPrompts#recommend— a@McpPromptthat compiles a${...}template once and renders it per call.
This already happened in step 2: placing an order auto-brews it, and that brew is a separate
tools/call. A long-running tool can report progress while it works; under Streamable HTTP those
updates arrive as notifications/progress events on the request's SSE response stream, ahead of
the final result — so the ticket's progress bar fills as the brew proceeds.
- On the receipt: a
brewrequest, then a run of↻ notifications/progresslines (1/4 Grinding beans…4/4 Finishing the pour), then the final result with the order flipped toREADY. - In the code:
OrderTools#brew—ctx.longProgress(total).emit(n, "step"). Progress only goes on the wire when the client sends aprogressTokenin_meta; otherwise the emitter is a silent no-op. - The one client wrinkle: reading an SSE stream needs POST + headers, which the browser's
native
EventSourcecan't do (it's GET-only). The storefront uses@microsoft/fetch-event-sourcefor this single call, loaded on demand from a CDN — the only spot the page touches the network at runtime. Every other call is a plain bufferedfetch.
Stateless core, handles instead of sessions, round-trips instead of held connections — scale it by
running more copies, because there's nothing to share. Sampling and server-side logging are gone in
this draft (see Notes). To prove it's plain HTTP underneath, drop to a terminal and run the
curl example below — then see The production extras for the validation,
tracing, and ops endpoints Mocapi layers on top.
The public MCP Inspector does not speak 2026-07-28. This protocol version is still a draft, and
the Inspector only supports the older stateful versions — it can't complete the headerless
server/discover + per-request _meta flow this server expects. Use the browser storefront or
curl until the Inspector ships 2026-07-28 support.
Streamable HTTP is now POST-only with required routing headers. Every request is self-contained — no session id.
curl -sS http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: place-order" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "place-order",
"arguments": { "drink": "latte", "size": "MEDIUM", "milk": "OAT" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "curl", "version": "1.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'Three things the server enforces — easy to get wrong by hand:
Acceptmust list bothapplication/jsonandtext/event-stream. Send only one and you get-32000 Not Acceptable._metagoes insideparams, not next to it. A top-level_metayields-32602 Missing required _meta envelope on request params.Mcp-Nameis required on every routed method, not justtools/call: it's the resource URI forresources/read(e.g.Mcp-Name: menu://drinks) and the prompt name forprompts/get.
The six primitives above are the protocol. Mocapi also ships the boring-but-essential production pieces as drop-in modules — this demo wires three. Each is a dependency plus a touch of config; no handler code changes.
Mocapi reads Jakarta Bean Validation constraints on handler parameters and turns a violation into a tool error, so your handler only ever sees valid input.
- Try it: re-run the
curlabove but change the drink to something that breaks the pattern — e.g."drink": "LATTE!". - What you get: a result with
isError: truewhose text is the constraint message —placeOrder.drink: must be a lowercase menu slug like 'latte' or 'cold-brew'. The handler body never ran. - In the code:
OrderTools#placeOrder—drinkcarries@NotBlank+@Pattern. Dependency:mocapi-jakarta-validation.
mocapi-otel emits a two-layer trace for every tool/prompt/resource call: an outer jsonrpc.server
span enriched with mcp.* tags, wrapping an inner mcp.handler.execution span with GenAI / resource
attributes. The demo already exports OTLP traces to http://localhost:4318.
- Try it: start Jaeger v2 (built on the OTel Collector — OTLP receivers on by default, plus a
trace UI), then run the demo:
docker run --rm --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/jaeger:latest
- What you get: at http://localhost:16686, pick service
mocapi-cafe→ Find Traces. Aplace-orderlooks like:Click the handler span to see its Tags — OTel GenAI/MCP semantic-convention attributes likemocapi-cafe http post /mcp ← Spring MVC SERVER span (the HTTP POST) └─ mocapi-cafe tools/call ← jsonrpc.server span (mcp.* tags) └─ mocapi-cafe place-order ← mcp.handler.execution span (the handler)gen_ai.tool.name,mcp.method.name,mcp.client.name,mcp.handler.kind. - Config: the tracing properties in
application.properties. Dependency:mocapi-otel. OTLP metrics are turned off there — Jaeger is traces-only and 404s on the metrics path; point at a metrics-capable backend and flip them on to get the handler-duration meters too.
mocapi-actuator adds a read-only Spring Boot Actuator endpoint: a snapshot of every registered tool,
prompt, resource, and template (with input-schema digests) without opening an MCP session — handy for
"what's deployed here?" checks.
- Try it:
curl -s localhost:8080/actuator/mcp | jq - What you get:
serverinfo, acountsblock, and arrays oftools/prompts/resources/resourceTemplates. Config:management.endpoints.web.exposure.include=health,info,mcp. Dependencies:mocapi-actuator+spring-boot-starter-actuator.
Not wired here, but a dependency away: MDC log correlation (mocapi-logging), audit logging
(mocapi-audit), argument/variable completion (completion/complete), and scope/role authorization
guards (mocapi-spring-security-guards + mocapi-oauth2).
- Sampling and MCP logging are gone in 2026-07-28 (SEP-2577), so there's no
ctx.sample(...)orctx.logger(...)here. For LLM calls, integrate your provider's API directly; for logs, use stderr or OpenTelemetry (mocapi-o11y). mocapi.mrtr.secretis left blank, so an ephemeral signing key is generated at startup — fine for a single node. Set a stable secret for multi-node.