Every month I pick one thing I’ve only been reading about and make it real in my own code. In September it was the Model Context Protocol (MCP). I’d seen it everywhere: Claude Desktop, IDE plugins, agent frameworks. On paper I understood it as “USB-C for AI tools”, but as a Java developer I kept asking the obvious question:
I already know how to expose a capability. It’s called a
@RestController. What does MCP give me that REST doesn’t?
So I gave myself a concrete goal. In runs-app, my running tracker (Spring Boot 4 + React 19), you can already ask an AI to analyze your recent runs. I wanted a “Generate Diagram” button that turns that analysis into a dashboard image. The image would be drawn by a separate service that speaks MCP. I also wanted the same capability available to a chat model, through LiteLLM, with a local Ollama model as a fallback.
That took three weeks of evenings (Sep 7–26), plus one more evening to wire up LiteLLM. This post covers what I built, where REST and MCP each fit, and how LiteLLM fits in.
The 30-second version of MCP
MCP is JSON-RPC with a client side and a server side:
- An MCP server exposes tools (functions with a name, a description and a JSON Schema for the inputs). It can also expose resources and prompts.
- An MCP client connects, calls
initialize, askstools/list, and then callstools/callwith arguments. - The transport is either stdio (a local process) or Streamable HTTP: one HTTP endpoint (
/mcp) that you POST JSON-RPC to, with Server-Sent Events when it needs to stream.
Notice the last point. MCP over Streamable HTTP is still HTTP. It isn’t a competitor to HTTP. It’s a specific contract layered on top of it. Keeping that in mind made most of my confusion go away.

1. diagram-maker: a brand-new MCP server (started Sep 14)
This is a new Spring Boot 4.1 / Java 21 service with spring-ai-starter-mcp-server-webmvc:
spring: ai: mcp: server: name: diagram-maker protocol: STREAMABLE streamable-http: mcp-endpoint: /mcp
The tools are ordinary Spring beans:
@McpTool(name = "generate_analysis_diagram", description = "Generates a dashboard image from a stored runs-ai-analyzer document. " + "The documentId identifies the completed analysis. The returned object contains artifact metadata.")public RunDiagramResult generateAnalysisDiagram( @McpToolParam(description = "Document ID of the completed analysis", required = true) UUID documentId, @McpToolParam(description = "Type of diagram to generate. Default is SUMMARY.", required = false) DiagramType diagramType, @McpToolParam(description = "Output image format. Default is PNG.", required = false) ImageFormat format) { ... }
The tool fetches the analysis from runs-ai-analyzer and uses XChart to draw a dashboard: a summary, a key-metrics chart, insights, risk flags and a confidence score. It saves the file and returns {uid, fileName, filePath, contentType, sizeBytes}.

2. runs-ai-analyzer: the MCP client
spring-ai-starter-mcp-client plus YAML gives you an injectable McpSyncClient:
spring:
ai:
mcp:
client:
enabled: ${DIAGRAM_MAKER_MCP_ENABLED:false}
type: SYNC
request-timeout: 60s
streamable-http:
connections:
diagram-maker:
url: ${diagram-maker.base-url}
endpoint: /mcp
3. runs-app: the button
RagProxyResource got two pass-through routes (generate and download). The AI Insights page (ai-insights.tsx) got a Generate Diagram / Regenerate Diagram button with loading and error states and an inline preview with a download link.
REST vs MCP: a Java developer’s view, hop by hop through runs-app
This is the section I wish I’d read first. Rather than comparing the two in the abstract, here’s every hop in the feature above, which protocol it uses, and why.
The same idea in both styles
Server side: one is a resource URL for a known caller, the other is a described function for a caller that discovers it.
// REST: diagram-maker's DiagramArtifactController
@RestController
@RequestMapping("/api/diagrams")
class DiagramArtifactController {
@GetMapping("/{fileName}")
ResponseEntity<Resource> download(@PathVariable String fileName) { ... }
}
// MCP: diagram-maker's DiagramMakerMcpTools
@McpTool(name = "generate_analysis_diagram", description = "Generates a dashboard image from ...")
RunDiagramResult generateAnalysisDiagram(@McpToolParam(...) UUID documentId, ...) { ... }
Client side: one is a URL and a verb, the other is a tool name and an arguments map.
// REST: runs-ai-analyzer's DiagramArtifactClient
restClient.get()
.uri(uriBuilder -> uriBuilder.path("/api/diagrams/{fileName}").build(fileName))
.retrieve()
.toEntity(byte[].class);
// MCP: runs-ai-analyzer's DiagramMakerMcpClient
client.callTool(new McpSchema.CallToolRequest("generate_analysis_diagram", arguments, null));
Both endpoints sit on top of the same @Service classes (DashboardImageService, DiagramStorageService). That’s the most important design rule I took away: keep business logic in a service, and treat @RestController and @McpTool as two thin adapters over it.
Every hop, and why
| # | Hop in runs-app’s feature | Protocol | Why |
|---|---|---|---|
| 1 | React → runs-app /api/rag/analysis/{id}/diagram |
REST | The browser is the caller. It knows exactly which endpoint it wants and needs status codes for the loading and error UI. |
| 2 | runs-app → runs-ai-analyzer /api/v1/analysis/{id}/diagram |
REST | Service-to-service with a fixed contract. It’s a plain proxy, and OpenAPI/Swagger already documents it. |
| 3 | runs-ai-analyzer → diagram-maker /mcp |
MCP | My choice, and REST would have worked here too. I picked MCP so the button and the chat model use one contract for “make a diagram”. |
| 4 | PNG download (diagram-maker → analyzer → runs-app → browser) | REST | Bytes, Content-Type, Content-Disposition and caching are exactly what HTTP resources are for. The MCP tool returns only metadata. |
| 5 | Chat client → LiteLLM → diagram-maker | MCP | Here the model decides which tool to call and what to pass. That only works if the capability can describe itself, and this is where MCP is worth the extra machinery. |
| 6 | runs-app Garmin import → eventstracker | RabbitMQ | Neither one. It’s async and fire-and-forget. Not everything is request/response. |
What actually differs
| REST | MCP | |
|---|---|---|
| Who is the caller? | Code written by a developer who read your docs | A model (or code) that learns what’s available at runtime via tools/list |
| Contract | URL + verb + DTO, documented with OpenAPI for humans | Tool name + JSON Schema + a description that is effectively a prompt |
| Shape | Many resource URLs | One endpoint, JSON-RPC methods, a session (Mcp-Session-Id) |
| Errors | HTTP status codes | A successful response with isError: true, so you map it yourself (runs-ai-analyzer turns it into 502 Bad Gateway) |
| Binary data | First-class | Possible, but awkward. Return a reference and serve bytes over REST |
| Caching/proxies/CDNs | Built-in HTTP semantics | Opaque POSTs |
| Testing | curl, MockMvc, RestAssured, Swagger UI |
An initialize handshake first (see gotchas), MCP Inspector |
| Reuse by AI tools | Someone has to hand-write a tool definition for each endpoint | Claude Desktop, IDEs and LiteLLM can use it as-is |
Three things I had wrong
- “MCP replaces REST.” No. Four of the six hops in this feature are REST or messaging, and that’s correct.
- “MCP needs an LLM.” No. Hop 3 is a plain Java service calling a tool with no model involved. MCP is just a typed, discoverable RPC contract, and the LLM is one possible caller.
- “An LLM can just call my REST API.” Only if someone writes a tool definition that describes it. MCP is that tool definition, standardized and served by the service itself.
My rule of thumb now: if the caller is code that already knows what it wants, use REST. If the caller is a model that has to figure out what it can do, use MCP. If you have both kinds of caller, put the logic in a @Service and add a thin adapter for each.
LiteLLM: now wired into docker-compose
LiteLLM is an OpenAI-compatible proxy. Clients talk to one URL (:4000), and LiteLLM routes the request to whichever model is configured, handling fallbacks and keys in one place. It can also act as an MCP gateway: you register MCP servers in its config, and it re-exposes their tools behind the same key.
When I first drafted this post, LiteLLM was only a config file and a plan. Here’s what’s actually running now.
litellm_config.yaml
model_list:
- model_name: default
litellm_params:
model: openai/gpt-4o
api_key: os.environ/OPENAI_API_KEY
- model_name: fallback
litellm_params:
model: ollama_chat/qwen2.5:3b
api_base: os.environ/OLLAMA_API_BASE
router_settings:
fallbacks: [{ default: [fallback] }]
mcp_servers:
diagram_maker:
url: "http://diagram-maker:8091/mcp"
transport: "http"
description: "Generates run-analysis dashboard images for runs-app from runs-ai-analyzer data"
docker-compose.yml (next to diagram-maker)
litellm:
image: ghcr.io/berriai/litellm:v1.103.1 # pinned, never :latest
ports: ["4000:4000"]
command: ["--config", "/app/config.yaml", "--port", "4000"]
volumes:
- ./litellm_config.yaml:/app/config.yaml:ro
environment:
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:?Set LITELLM_MASTER_KEY in .env}
- OLLAMA_API_BASE=${OLLAMA_API_BASE:?Set OLLAMA_API_BASE in .env}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
depends_on:
diagram-maker:
condition: service_healthy
Secrets come only from a git-ignored .env, and a committed .env.example documents them. The :? syntax makes Compose refuse to start if a required value is missing, instead of quietly falling back to a default.
Proof it works
I ran this locally with no OpenAI key on purpose:
GET /v1/models→["default", "fallback"]. Without the master key →401.- MCP
tools/listagainsthttp://localhost:4000/mcp/→ both diagram-maker tools, renameddiagram_maker-generate_analysis_diagramanddiagram_maker-generate_run_diagram. - A chat completion on model
defaultcame back fromollama_chat/qwen2.5:3b, with headersx-litellm-model-group: fallbackandx-litellm-attempted-fallbacks: 1. The fallback worked. - The full chat path: I asked “Generate a SUMMARY PNG dashboard for analysis document 81f8e7ec-…” with
tools: [{type: "mcp", server_url: "litellm_proxy/mcp/diagram_maker", require_approval: "never"}]. The 3-billion-parameter local model picked the right tool and passed the right UUID. diagram-maker fetched the real analysis of my last 8 runs from runs-ai-analyzer and saved a 73,850-byte PNG (the dashboard image above). That’s the same diagram the runs-app button produces, this time requested in plain English.
The layering lesson
| Layer | Question it answers | In my stack |
|---|---|---|
| Model gateway | Which LLM answers this prompt, and what if it’s down? | LiteLLM model_list + fallbacks |
| Tool protocol | What can be done, and how is it called? | MCP (diagram-maker), also exposed through LiteLLM’s MCP gateway |
| App integration | How does a user click trigger it? | Spring REST + React in runs-app |
My first architecture diagram had “LiteLLM routes to diagram-maker” as if diagram-maker were a model. It isn’t. LiteLLM routes prompts to models and proxies tool calls to MCP servers. Those are two separate jobs that happen to share a port. Once I separated them, the button path became simpler. runs-ai-analyzer already knows the documentId, so it calls the tool directly, and no model or LiteLLM is needed in the middle.
The same “primary + local fallback” idea also exists in Java. runs-ai-analyzer’s analysis path uses Spring AI, so I built the pattern natively: AiProviderChain tries Anthropic → ACG-sandbox Ollama → local Ollama, each tier behind its own Resilience4j circuit breaker. A 401/403 opens that provider’s breaker for 30 minutes, and an AiProvidersHealthIndicator reports every tier in /actuator/health. Same idea, two implementations: LiteLLM for anything that speaks the OpenAI API, and in-process Java where Spring AI owns the call.
Gotchas (so they don’t bite you)
- The Streamable HTTP handshake is strict. Send
Accept: application/json, text/event-stream, callinitialize, and reuse theMcp-Session-Idit returns on every call after that. - The annotations are
@McpTool, not@Tool. Spring AI MCP Server 2.0.1 usesorg.springframework.ai.mcp.annotation.McpTool/@McpToolParam, not the generic@Toolthat most tutorials show. - Spring Boot 4.1 moved things around. Jackson now lives under
tools.jackson.*,UriComponentsBuilder.fromHttpUrlis gone (usefromUriString), and there’s noRestTemplateBuilderon the classpath. - My tests weren’t running. Surefire had
<skipTests>${skipITs}</skipTests>, so./mvnw testsilently skipped unit tests. - Return metadata from tools, serve bytes over REST. It keeps MCP responses small, and the PNG gets proper HTTP headers.
- Tool parameters are untrusted input, possibly written by an LLM.
DiagramStorageServicerejects paths that escape the output root (../etc/passwd), and the download side checkstoRealPath()so a symlink can’t get out either. - Fail fast on dead fallbacks. Spring AI’s default retry template backs off for minutes. For the sandbox tier I set retries to 0 and the connect timeout to 3s.
- Check your fallback model actually exists. My original config pointed at
llama3.2:3b, but the VM’s Ollama only hasqwen2.5:3b. The fallback would have failed exactly when it was needed. RunGET /api/tagsbefore trusting it. - Use
ollama_chat/, notollama/, in LiteLLM. The chat route uses Ollama’s/api/chat, which supports native tool calling. - Inside Compose,
localhostis the container itself. LiteLLM reaches diagram-maker ashttp://diagram-maker:8091/mcp, using the service name. - Pin the LiteLLM image. Versions 1.82.7/1.82.8 on PyPI were compromised in March 2026 (a credential-stealing
.pthpayload). The official Docker image wasn’t affected because it pins its dependencies. I pinnedv1.103.1and checked that the image contains nolitellm_init.pth. Also, the image has nocurl/wget, so the Compose healthcheck uses its own Python. - Gateways rename your tools. Through LiteLLM,
generate_analysis_diagrambecomesdiagram_maker-generate_analysis_diagram. Write descriptions that still make sense to a model that only sees the prefixed name.
What’s next
- A chat box in runs-app’s AI Insights page that talks to LiteLLM, so “draw my last week” works next to the button. Same tool, different caller, which is the reason to use MCP in the first place.
- Move the LiteLLM service into the VM’s main stack next to runs-app and runs-ai-analyzer.
- Set
@McpToolhints (the tool currently advertises itself as destructive and non-idempotent by default), and put generated diagrams on a Docker volume so they survive container restarts. - Fix the key-metrics chart: calories (~5,900) dwarf distance (~28 mi) on one axis, so each metric needs its own scale.
Takeaway: for a Java developer, MCP clicked once I stopped treating it as “an AI thing” and started treating it as a second adapter over the same @Service, written for callers that discover instead of callers that already know. REST still does most of the work in runs-app. MCP makes the same capability usable by a model. LiteLLM decides which model is on the other end, and falls back to a 3B model on my home server when the primary is unavailable.

You must be logged in to post a comment.