<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Carmelo Buelvas · Backend & AI Engineering]]></title><description><![CDATA[Carmelo Buelvas · Backend & AI Engineering]]></description><link>https://cbuelvasc.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 20:01:47 GMT</lastBuildDate><atom:link href="https://cbuelvasc.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[MCP in Production, Part 6: Java vs Go, Measured]]></title><description><![CDATA[Same hexagon, same tools, same k6 scenario, two runtimes. Both servers handled the agent burst with zero failures and stayed far below the SLO. Go answered…]]></description><link>https://cbuelvasc.hashnode.dev/mcp-in-production-part-6-java-vs-go-measured</link><guid isPermaLink="true">https://cbuelvasc.hashnode.dev/mcp-in-production-part-6-java-vs-go-measured</guid><category><![CDATA[Java]]></category><category><![CDATA[golang]]></category><category><![CDATA[performance]]></category><category><![CDATA[mcp]]></category><dc:creator><![CDATA[Carmelo Buelvas Comas]]></dc:creator><pubDate>Sat, 14 Nov 2026 15:53:23 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<blockquote>
<p><strong>Same hexagon, same tools, same k6 scenario, two runtimes.</strong> Both servers handled the agent burst with <strong>zero failures</strong> and stayed far below the SLO. Go answered with a <strong>~5× lower p95</strong> (2.6 ms vs 13.8 ms), and the most interesting finding was <em>where</em> Java spends its time: not in the tool.</p>
</blockquote>
<p>⭐ <strong>Code:</strong> <a href="https://github.com/cbuelvasc/orders-mcp">orders-mcp (Java)</a> · <a href="https://github.com/cbuelvasc/orders-mcp-go">orders-mcp-go (Go)</a> · Part 6 of <strong>MCP in Production: Java &amp; Go</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>Java · Spring AI</th>
<th>Go · go-sdk</th>
</tr>
</thead>
<tbody><tr>
<td>Requests / failures</td>
<td>11,562 / <strong>0</strong></td>
<td>11,549 / <strong>0</strong></td>
</tr>
<tr>
<td>Throughput</td>
<td>96.3 req/s</td>
<td>~96 req/s</td>
</tr>
<tr>
<td>p95 <code>findOrder</code></td>
<td>13.93 ms</td>
<td><strong>2.49 ms</strong></td>
</tr>
<tr>
<td>p95 <code>listRecentOrders</code></td>
<td>13.38 ms</td>
<td><strong>2.73 ms</strong></td>
</tr>
<tr>
<td>p95 overall</td>
<td>13.82 ms</td>
<td><strong>2.62 ms</strong></td>
</tr>
<tr>
<td>Max</td>
<td>287.9 ms</td>
<td><strong>118.4 ms</strong></td>
</tr>
<tr>
<td>k6 VUs needed</td>
<td>up to 57</td>
<td><strong>up to 20</strong></td>
</tr>
<tr>
<td>Dropped iterations</td>
<td>9</td>
<td><strong>0</strong></td>
</tr>
</tbody></table>
<hr />
<h2>🧪 1. The setup</h2>
<p><img src="https://mermaid.ink/img/pako:eNpVUd1PwjAQ_1cu9UUTJmyECcTwoA8kgglK9MURc3RXaehWvBXQGP53L2NB7EO_7vfV3o_SPic1BGWc3-sVcoDpc1aCjEn6lqlsm6QYwzq9XXJ7xFhsbPkRIbPdoYsYA9WFuAOCjAcJ9E67pHPadoDps11lagFRBJm6I2RiCH5N5ZEPFVWV9SVs5P71JVMCHMFcEjzez6TIO-Ia-YA7BM8w9qLWBL2vg-b9bi5zHscwoW_tPK5rw2sx1M5SGd41Uy6rRSdZ4FocJulRY177zcZvl2dSOu7DzFfhg2n-NM3U1eIPLKLtggJbXTVhZ-cptBYqe0GsaNs8fATj_xCEMaPBEuunHLWr8O1IHIx1bnhBZFJjWlVg-arhRXfZT0zaHKO9zcNqmGy-zpmTtKEaMl19c6Ka3oA6S9UCVRAXaHNp-o-SdEXd_pwMbl1Qh8MvPpOkgw?type=png&amp;bgColor=white&amp;width=1600" alt="Benchmark setup" /></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Machine</td>
<td>MacBook Pro 16" (2019) · Intel Core i7 6-core 2.6 GHz (12 threads) · 16 GB DDR4</td>
</tr>
<tr>
<td>OS</td>
<td>macOS Tahoe 26.7</td>
</tr>
<tr>
<td>Java</td>
<td>OpenJDK 25.0.5 (Temurin) · Spring Boot 4.1.2 · Spring AI 2.0.2 · default JVM flags</td>
</tr>
<tr>
<td>Go</td>
<td>Go 1.25 · official MCP Go SDK v1.8.0</td>
</tr>
<tr>
<td>Dependencies</td>
<td>PostgreSQL 17, Keycloak 26.4, Prometheus, Grafana in Docker</td>
</tr>
<tr>
<td>Load</td>
<td>the <strong>same</strong> k6 script for both: one agent (token + MCP session) per VU, 70% <code>findOrder</code>, 30% <code>listRecentOrders</code></td>
</tr>
<tr>
<td>Thresholds</td>
<td><code>http_req_failed &lt; 1%</code> · p95 <code>findOrder &lt; 150 ms</code> · p95 <code>listRecentOrders &lt; 200 ms</code></td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ <strong>Read this as a laptop benchmark, not a lab result.</strong> One run per server, server and k6 on the same machine, Docker sharing the CPU. Good enough to see the shape of the difference, not to quote to the decimal.</p>
</blockquote>
<hr />
<h2>⚡ 2. Latency</h2>
<p><img src="https://mermaid.ink/img/pako:eNptkDFrwzAQhf_KccXQQhycuE3iDFkyFLq0dOgSZZDsMxHIlpEvrY3xf69UZUtve-_ddwdvwtJWhHvAJJl0q3kPk0C-UENf0mmpDPUC_8xhPF6k45vqjOWjNdZ9SEPMFGyBD7nareuNwNlPkoh2GMsApYpYihb8sGZDfrUrXsBIprYcoSMHbK2Bx6Z_AnHNMrUFY3-8rXtQ4b4TGPkhlYM3TwJr3VbvrvJLN-JNfkuBC_gnerUxMLrnTyqp5XvoPgvUOb4d41uBje8DMkjTA6w2MVPSwWmVL4t8Aevlc7EAL_JdENv87G9jQ66RugrVxXJD4xXV8moY5_kXmk95Ow?type=png&amp;bgColor=white&amp;width=1600" alt="p95 latency per tool" /></p>
<p>Both servers pass the thresholds with <strong>10× headroom</strong>: the SLO is 150 ms and the worst p95 is 14 ms. For an agent waiting on an LLM that takes seconds per turn, either number is invisible.</p>
<p>Still, Go's p95 is <strong>~5× lower</strong> and its worst request (118 ms) is less than half of Java's (288 ms). The JVM's max is very likely the first seconds of the run, while the JIT is still compiling the hot paths.</p>
<hr />
<h2>🔬 3. Where Java spends the time</h2>
<p>This is the part I didn't expect. The <code>mcp.tool.duration</code> timer from part 4 measures <strong>only the tool</strong>: the use case and the JDBC query. Prometheus says its p95 was between <strong>2 and 5.5 ms</strong> during the runs. k6, measuring the whole HTTP round trip, says <strong>13.8 ms</strong>.</p>
<p><img src="https://mermaid.ink/img/pako:eNp9kUtPAjEQgP_KpByFWBY2PGKIspgYIkpgEw-uh9JOQ-M-sO1qkPDfHXYlrBd7mKaTr99MZg5MFgrZGJhOiy-5FdbD4yrJgc7da8KSUg17kqLucniI4yVY_CjR-YS9QaczgekZUhQVQWuUpTV-D9qkHi2Q0-Q3G3s9mb_EoPBUD67gU6RGCY9nT9T0SOzCIlqCtyJ3u8L6-v_6-amzWkaQlJxvBuDQOVPkl6c1JP0WnpJn7aypReRJqZFruF3IXVwUaeUtHYIU7tTVfDaNqtxuFJI3CPpDCOjm3V4ImSNrPZpZJb__23OfZkPd5g4rriad36dIPE0jHbeU1BIHbedt8Y7jVhDIMMQmOP0FNeqevIA6HCHfNMHoP5C1gWVoM2EUrfbA_BazaskKtShTz47HH46In8A?type=png&amp;bgColor=white&amp;width=1600" alt="Where Java spends the time" /></p>
<p>So <strong>most of Java's latency is outside the hexagon</strong>: the security filter chain, the MCP transport and serialization. The domain and the database are fast in both languages. That's a useful result in itself:</p>
<ul>
<li>if you want to optimize the Java server, <strong>profile the edge, not the domain</strong>;</li>
<li>the hexagon did its job: the core is cheap, the adapters are where the framework cost lives;</li>
<li>this is exactly why part 4 records a <strong>per-tool timer</strong> <em>and</em> why k6 measures end to end. With only one of them, you'd draw the wrong conclusion.</li>
</ul>
<hr />
<h2>🧵 4. Concurrency: VUs as a symptom</h2>
<p><code>ramping-arrival-rate</code> sends requests at a fixed rate, so <strong>throughput is the same by design</strong> (~96 req/s for both). What changes is how many virtual users k6 needs to keep that rate:</p>
<p><img src="https://mermaid.ink/img/pako:eNotUMFqwzAM_RWhkVsDoWPZ6GGXHgaDwi7rpepBqVVi6sTF0YpDyL_PnvcuQu_pPXha8OKN4A6wqhY7Wt3BQqi9DHLkYLlzMhH-kXHe9xz0f7s7r3vvfPhiJ6qSacKn5-5te20J14SqojHOl2yqO1GmERLUqpN0euAItxaO3xOMIkYMqIfeuzR7gYkHAQ7BPthB4Jxf7LHmaCc4EX7ygwk3KerDE56LPBeZMOUSQgN1_Q5tU8SOA5xeXjewbc7JiIOEga3JhUrl_AcjV_5xiuv6C7jVWdc?type=png&amp;bgColor=white&amp;width=1600" alt="VUs needed" /></p>
<p>Slower responses keep each VU busy longer, so k6 had to spin up <strong>57 VUs for Java vs 20 for Go</strong>, and still dropped 9 iterations on Java while it scaled up. In production terms: the same agent traffic keeps <strong>~3× more requests in flight</strong> against the Java server.</p>
<hr />
<h2>🧠 5. Startup and memory</h2>
<table>
<thead>
<tr>
<th></th>
<th>Java</th>
<th>Go</th>
</tr>
</thead>
<tbody><tr>
<td>Startup to <em>ready</em></td>
<td><strong>5.7 s</strong> (<code>application_ready_time_seconds</code>)</td>
<td>not measured in this run</td>
</tr>
<tr>
<td>JVM memory used, idle</td>
<td>~146 MB (heap 59 + non-heap 87)</td>
<td>not measured in this run</td>
</tr>
<tr>
<td>JVM memory used, peak</td>
<td>~170 MB</td>
<td>not measured in this run</td>
</tr>
<tr>
<td>Threads</td>
<td>30 live threads</td>
<td>goroutines, not measured</td>
</tr>
<tr>
<td>GC</td>
<td>240 pauses, 1 s in total since start</td>
<td>—</td>
</tr>
<tr>
<td>CPU during the burst</td>
<td>peak ~6% of the machine</td>
<td>—</td>
</tr>
</tbody></table>
<p>The Java numbers come from Micrometer (<code>jvm_memory_used_bytes</code>), which counts heap and non-heap but <strong>not the full process footprint</strong> (RSS is higher). I didn't capture Go's startup and memory in this round; I'll add them in a follow-up, measured the same way for both, including RSS.</p>
<p>What I can say from the run: at ~100 req/s <strong>neither server was stressed</strong>. Java peaked at ~6% CPU with a ~70 MB heap on a 4 GB max. This scenario measures latency under a realistic agent burst, <strong>not the breaking point</strong>.</p>
<hr />
<h2>⚖️ 6. So, Java or Go for an MCP server?</h2>
<p><img src="https://mermaid.ink/img/pako:eNpdktFP2zAQxv-Vk3lhUtCyVl1LNYGyUTFQETDQ9rDw4Njn1sKxK98FFlX93-cmoWLzg2VZ332_787eChU0ijkI48KrWsvI8Pi19JDW_bYUv9aSoZbMGAnqQPylih_PTIjAa0tw8-0OCOMLxvNS7IYyODmBUizDKxKDk4xetRlQLZ0DEwJvovWc9UYySYgTtdnAMVmNSkbKBk-HRB9KkfzO4PL2dynKRs_GOu1qfAqXoRRP_yAXfyyx9St42CNWgCpQS4z13lA10XLbY0O1B8jKunQF0mtglDXQs3WOBuB18bPokKPPnyZwLV8klE2eV9M39-Lqf36xQn_oGHSorU9nDVWbpoWwXN509OMUJnhNsME0xib6tx4XV4_fFz966DSfwMKmsjiHKvAaZMRhZBEqTI_VeT4sb7sUfQ7i1mEaFZjUyPwIczM1MiOO4RnnR3leXFzM3iv3PQ5aY8wY84PWzE5no_y9tg83qLUyCqcH9WikJhMUGYgaYy2tTh9qK1K-uvtaGo1sHIvd7i_7s8k1?type=png&amp;bgColor=white&amp;width=1600" alt="Java or Go decision" /></p>
<p>My take after building both:</p>
<ul>
<li><strong>Go</strong> wins on raw latency and efficiency, and it makes everything explicit. Great when the MCP server is a small, independently deployed edge service.</li>
<li><strong>Java</strong> costs ~11 ms more per call here, and in exchange you get Spring Security's OAuth 2.1 support (including RFC 9728 out of the box), <code>@McpTool</code>, Micrometer and the rest of the ecosystem your team already runs in production.</li>
<li><strong>The architecture mattered more than the language.</strong> Same ports, same contract tests, same metric names: that's what made this comparison possible at all.</li>
</ul>
<hr />
<h2>💡 Lessons learned</h2>
<ol>
<li><strong>Benchmark the load generator too.</strong> My first Go run had 0.84% failures, caused by k6 sharing one MCP session across VUs, not by the server.</li>
<li><strong>Measure inside and outside.</strong> The per-tool timer and the k6 round trip told two different, both true, stories.</li>
<li><strong>Arrival-rate tests hide throughput differences on purpose.</strong> Look at VUs, dropped iterations and latency instead, or run a stress test to find the ceiling.</li>
<li><strong>Warm-up is real on the JVM.</strong> Report max latency with that in mind, or run a warm-up phase before measuring.</li>
<li><strong>Below the SLO, the decision is about the team and the platform</strong>, not about milliseconds.</li>
</ol>
<hr />
<h2>🚀 What's next</h2>
<ul>
<li>A <strong>stress scenario</strong> that pushes both servers toward their limit (thousands of req/s) to find where each one breaks.</li>
<li><strong>Startup and RSS</strong> measured side by side, plus a Java run with <strong>GraalVM native image</strong> and virtual threads to see how much of the gap closes.</li>
</ul>
<p>⭐ Code: <a href="https://github.com/cbuelvasc/orders-mcp">orders-mcp</a> · <a href="https://github.com/cbuelvasc/orders-mcp-go">orders-mcp-go</a></p>
<hr />
<p>👋 I'm <strong>Carmelo Buelvas</strong>, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on <a href="https://www.linkedin.com/in/cbuelvasc/">LinkedIn</a>.</p>
]]></content:encoded></item><item><title><![CDATA[The Same MCP Server in Go: Hexagonal Architecture with the Official Go SDK]]></title><description><![CDATA[Same server, same tools, same scopes, same metrics, different language. I rebuilt the hexagonal MCP server from parts 1–4 in Go, with the official MCP Go…]]></description><link>https://cbuelvasc.hashnode.dev/the-same-mcp-server-in-go-hexagonal-architecture-with-the-official-go-sdk</link><guid isPermaLink="true">https://cbuelvasc.hashnode.dev/the-same-mcp-server-in-go-hexagonal-architecture-with-the-official-go-sdk</guid><category><![CDATA[Go Language]]></category><category><![CDATA[golang]]></category><category><![CDATA[mcp]]></category><category><![CDATA[Hexagonal Architecture]]></category><category><![CDATA[Java]]></category><dc:creator><![CDATA[Carmelo Buelvas Comas]]></dc:creator><pubDate>Fri, 13 Nov 2026 23:14:39 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<blockquote>
<p><strong>Same server, same tools, same scopes, same metrics, different language.</strong> I rebuilt the hexagonal MCP server from parts 1–4 in <strong>Go</strong>, with the <strong>official MCP Go SDK</strong>, to see what changes and what doesn't when the architecture stays the same.</p>
</blockquote>
<p>⭐ <strong>Code:</strong> <a href="https://github.com/cbuelvasc/orders-mcp-go">github.com/cbuelvasc/orders-mcp-go</a> · Java version: <a href="https://github.com/cbuelvasc/orders-mcp">orders-mcp</a> · Part 5 of <strong>MCP in Production: Java &amp; Go</strong></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Java (parts 1–4)</th>
<th>Go (this part)</th>
</tr>
</thead>
<tbody><tr>
<td>Tool declaration</td>
<td><code>@McpTool</code> on a method</td>
<td><code>mcp.AddTool(server, &amp;mcp.Tool{...}, handler)</code></td>
</tr>
<tr>
<td>Tool input schema</td>
<td><code>@McpToolParam</code></td>
<td>struct tags <code>json</code> + <code>jsonschema</code></td>
</tr>
<tr>
<td>Bearer token on <code>/mcp</code></td>
<td>Spring Security resource server</td>
<td><code>auth.RequireBearerToken</code> (SDK)</td>
</tr>
<tr>
<td>Scope per tool</td>
<td><code>@PreAuthorize</code></td>
<td><code>require(scopes, ScopeRead)</code></td>
</tr>
<tr>
<td>Wiring</td>
<td>dependency injection</td>
<td>explicit, in <code>main.go</code></td>
</tr>
<tr>
<td>Persistence</td>
<td><code>JdbcClient</code> + Flyway</td>
<td><code>pgx</code> + embedded migrations (same SQL)</td>
</tr>
<tr>
<td>Contract test</td>
<td>abstract JUnit class</td>
<td><code>contract.RunOrderRepositoryContract(t, ...)</code></td>
</tr>
<tr>
<td>Architecture rules</td>
<td>ArchUnit</td>
<td><code>go/parser</code> over the imports</td>
</tr>
<tr>
<td>Metrics</td>
<td>Micrometer</td>
<td><code>client_golang</code>, <strong>same names</strong></td>
</tr>
</tbody></table>
<hr />
<h2>⬡ Same hexagon, different idioms</h2>
<img src="https://mermaid.ink/img/pako:eNplU9Fu2jAU_RXLfWmlZoEgKENTJQpbF4kChW59aPbg2DfBarAt22lBVf99jgMLYX6w5eNz7j3Xvv7AVDLAI4SzQr7TDdEWzVaJQG6YMs01URs01fyNi_wlwUnJhj1wM-sOjjAynEGC_9SiaoxjRx3HaJyDsK2T5-937ugZUhSiB5nyohGCYGdpf8KO5FL4tFEaddFEahghY4lgRDNU8FQTvUdSFPtWFsY1UMulQE93DRrPL12oWKSyFAwpqa35lurw9gcXbKEZaJSUnU56g2bc2BVQ593D5ohPiKBQeMylu2oir39PXGSi1JfHEvR-DfqNU_DBK7DWHdCW0eni4eUywUxuCRee3zLiN_H0uF1bYkuT4KuTCItfT1VVi9I2ZTWBVqCk4VbqfdtwPEdBcFv59quz0SrGgy7y2cMs7yuzvgWYm2l3iJbS2FzD-nHmk6p819hzDRAEyN0LI8qCDrkIt1R5Xi4Dw14T7BPF85rveuM_gQZTlyPAhhtr1ZnGmWxrZGlDVZsyB-7y_tBXdl_AsalQxotidAGQDbLs2lgtX2F00UuHUTY4bIN3zuxmFKndqd5VVUszyHr05p8063-FTnrKdLd6oDKaUWioUUT7fcDXCG9Bu5dn7vt9YLuBrf-IDDJSFhZ_fv4FqXQeSQ?type=png&amp;bgColor=white&amp;width=1600" alt="Hexagonal architecture of the Go version" style="display:block;margin:0 auto" />

<p>The core uses <strong>only the standard library</strong>. Not by discipline: a test fails if anyone imports something else.</p>
<hr />
<h2>🧱 1. The domain: values, not annotations</h2>
<p>Value objects are structs with an unexported field and a constructor that validates, so an invalid <code>OrderID</code> simply cannot exist:</p>
<pre><code class="language-go">// OrderID is a value object: it can only hold a valid id like ORD-1042.
type OrderID struct{ value string }

// ParseOrderID normalizes and validates an order id.
func ParseOrderID(raw string) (OrderID, error) {
	v := strings.ToUpper(strings.TrimSpace(raw))
	if !orderIDFormat.MatchString(v) {
		return OrderID{}, fmt.Errorf("%w: invalid order id %q, expected format ORD-&lt;digits&gt;", ErrInvalidInput, v)
	}
	return OrderID{v}, nil
}

func (id OrderID) String() string { return id.value }
</code></pre>
<p>The business rule lives in the aggregate. <code>Cancel</code> returns a <strong>new</strong> <code>Order</code> (value receiver), the same immutability idea as the Java record:</p>
<pre><code class="language-go">// Cancel applies the business rule: only orders that have not shipped can be cancelled.
// It returns a new Order; the receiver is not modified.
func (o Order) Cancel(now time.Time) (Order, error) {
	if !o.Status.IsCancellable() {
		return Order{}, fmt.Errorf("%w: order %s cannot be cancelled because it is %s",
			ErrCannotBeCancelled, o.ID, o.Status)
	}
	o.Status = StatusCancelled
	o.UpdatedAt = now
	return o, nil
}
</code></pre>
<p>Ports are plain interfaces. In Go they usually sit next to the code that <strong>uses</strong> them, which here is the domain itself:</p>
<pre><code class="language-go">// --- Inbound ports (driving side): what the adapters can ask the core to do ---

// FindOrder returns one order or ErrOrderNotFound.
type FindOrder interface {
	FindByID(ctx context.Context, id OrderID) (Order, error)
}

// ListRecentOrders returns the newest orders of a customer. The limit is clamped by the core.
type ListRecentOrders interface {
	ListRecent(ctx context.Context, customer CustomerID, limit int) ([]Order, error)
}

// CancelOrder cancels an order that has not shipped yet.
type CancelOrder interface {
	Cancel(ctx context.Context, id OrderID) (Order, error)
}

// --- Outbound port (driven side): what the core needs from the outside world ---

// OrderRepository is implemented by PostgreSQL in production and by an in-memory fake in tests.
type OrderRepository interface {
	FindByID(ctx context.Context, id OrderID) (Order, bool, error)
	FindRecentByCustomer(ctx context.Context, customer CustomerID, limit int) ([]Order, error)
	Save(ctx context.Context, order Order) error
}
</code></pre>
<p>Errors are values. The adapters translate them with <code>errors.Is</code>: <code>ErrOrderNotFound</code> becomes a 404 in REST and a readable tool error in MCP.</p>
<hr />
<h2>🤖 2. The MCP adapter, split in two</h2>
<p>This is the design choice I like most in the Go version:</p>
<img src="https://mermaid.ink/img/pako:eNplkVtPAyEQhf_KhL5o0mLTpqluTBPrPTbR6PbJ9YGFoUvKwgpsrWn636Vb1-s8QDg5nPlgNoRbgSQBIrV94wVzAdJpZiCWr_OFY1UBTxd30iE-ZyRYqz1d2AQqzZSBa5uRl717V7NouVJG3DuBDrK638_HMFM-PCJHExrZt_o5Mxx1o53m7mjiua0QeIF82VpWTCvBgrKmVUoMTnH_1RWN-A8bKTy6FbqGNAqQRyhlFr9gp9FW8oqeCZHGZx1QSg9hPWxYHDLhweErvVwHx2hql2hujbT_G0-h15vAbH-YpzE0q8XxEOMqkMHcqAABffBNsLE7ni7E_SZNH2Lcz-s-vGtsvxuk0jrpCC45jrs-uMiQdAYDPhrhH_unVaIc8m-rHJ1gPyddICW6kikRx7whocCyGbhAyWodyHb7AdrbpBA?type=png&amp;bgColor=white&amp;width=1600" alt="Tool logic separated from the SDK binding" style="display:block;margin:0 auto" />

<p><code>tools.go</code> is plain Go: inputs, outputs, scope checks, validation and metrics. The JSON schema the model sees is generated from struct tags:</p>
<pre><code class="language-go">type FindOrderInput struct {
	OrderID string `json:"orderId" jsonschema:"Order id, format ORD-&lt;digits&gt;, e.g. ORD-1042"`
}
</code></pre>
<pre><code class="language-go">func (t *Tools) CancelOrder(ctx context.Context, scopes []string, in CancelOrderInput) (OrderView, error) {
	return observe(t.metrics, "cancelOrder", func() (OrderView, error) {
		if err := require(scopes, ScopeWrite); err != nil {
			return OrderView{}, err
		}
		id, err := domain.ParseOrderID(in.OrderID)
		if err != nil {
			return OrderView{}, err
		}
		o, err := t.cancel.Cancel(ctx, id)
		if err != nil {
			return OrderView{}, err
		}
		return toView(o), nil
	})
}
</code></pre>
<p><code>server.go</code> is the only file that knows the SDK. Each tool is one <code>mcp.AddTool</code> call:</p>
<pre><code class="language-go">	mcp.AddTool(server, &amp;mcp.Tool{
		Name: "findOrder",
		Description: "Returns status, total amount and last update of ONE order. " +
			"Use it when the user mentions a specific order id like ORD-1042.",
	}, func(ctx context.Context, req *mcp.CallToolRequest, in FindOrderInput) (*mcp.CallToolResult, OrderView, error) {
		out, err := t.FindOrder(ctx, scopes(req), in)
		return nil, out, err
	})
</code></pre>
<pre><code class="language-go">// scopes reads the scopes that auth.RequireBearerToken attached to the request.
func scopes(req *mcp.CallToolRequest) []string {
	if req == nil || req.Extra == nil || req.Extra.TokenInfo == nil {
		return nil
	}
	return req.Extra.TokenInfo.Scopes
}
</code></pre>
<p>In Java, <code>@PreAuthorize</code> made the scope check invisible and declarative. In Go it is one explicit line. Less magic, and it is unit-tested without starting a server.</p>
<hr />
<h2>🔐 3. Security: the SDK does the plumbing, we verify the token</h2>
<img src="https://mermaid.ink/img/pako:eNp9Ultr2zAU_isH9SVljZt4uYxQ-pBgyC6Q1DZtIR5DsY4WU9tKj6WtJeS_V5YdN2xjehCC8930SQeWKoFsBkzm6ne646ThW5iUYFcY3G0Slhjx6SPaXQwnsF5FMVwX6f5mS9e3c-SEBF8e4oR9h37_FsK5ZXCjd16IzyYjbCCxesLSUXo_Vb8ST5eW0JrMLRES9gspk689O3BC91ZnZWrtrxG4WYbkFMLIH08gMYPBdgpZVZ2O-LI_HatU7fEvBxficynVIarn1bG1WlqrSBPygm9zhGUcr2HHS5EjdRpLh4w3tYrKT2OXh_DZC140ca8z8BqDjh033QR30WK1Dg4Jo6acngtqr3zsCneIJm9i_Olg3IZcrMLAPYa_9YewUHR2vz9ZEzFqWUEYWpKuIyORoplLnJWVkTJLMyz1P7ryrMpoMIQPQFgpQyn-KFBzwTW3sp6Vfdy8-7TESr_a7uqUILM8n12IVKY4vao02VZmF76fjsd4jrXZWqhE9NHvoChHdp1DH_8HZFfACqSCZ8J-4wPTOyzchxYouck1Ox7fALjq41s?type=png&amp;bgColor=white&amp;width=1600" alt="Security flow in the Go version" style="display:block;margin:0 auto" />

<p>The SDK's <code>auth.RequireBearerToken</code> handles the HTTP side, including the <code>401</code> with <code>resource_metadata</code>. We plug in a verifier:</p>
<pre><code class="language-go">	requireToken := sdkauth.RequireBearerToken(
		func(ctx context.Context, token string, _ *http.Request) (*sdkauth.TokenInfo, error) {
			claims, err := verifier.Verify(ctx, token)
			if err != nil {
				return nil, sdkauth.ErrInvalidToken
			}
			return &amp;sdkauth.TokenInfo{Scopes: claims.Scopes, Expiration: claims.ExpiresAt}, nil
		},
		&amp;sdkauth.RequireBearerTokenOptions{ResourceMetadataURL: metadataURL},
	)
</code></pre>
<p>The verifier itself (<code>internal/platform/auth</code>) uses only the standard library, so every check is visible:</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td><code>alg</code> must be <code>RS256</code></td>
<td>never accept <code>none</code> or HMAC with a public key</td>
</tr>
<tr>
<td><code>kid</code> → key from JWKS (cached, refreshed on unknown <code>kid</code>)</td>
<td>key rotation</td>
</tr>
<tr>
<td>signature (<code>rsa.VerifyPKCS1v15</code>)</td>
<td>authenticity</td>
</tr>
<tr>
<td><code>iss</code></td>
<td>token minted by <em>our</em> authorization server</td>
</tr>
<tr>
<td><code>exp</code> / <code>nbf</code> with leeway</td>
<td>lifetime</td>
</tr>
<tr>
<td><code>scope</code> (RFC 9068) or <code>scp</code></td>
<td>per-tool authorization</td>
</tr>
</tbody></table>
<blockquote>
<p>💡 For production you can swap it for a vetted JOSE library behind the same <code>Verify</code> function. Writing it once is the best way to know what those libraries check for you.</p>
</blockquote>
<hr />
<h2>🧪 4. The same contract, in Go</h2>
<p>The contract test is just a function that receives a factory. The in-memory fake and PostgreSQL (Testcontainers, behind a build tag) both run it:</p>
<pre><code class="language-go">func RunOrderRepositoryContract(t *testing.T, newRepo func(t *testing.T) domain.OrderRepository)
</code></pre>
<pre><code class="language-bash">make test          # unit + in-memory contract + architecture (no Docker)
make integration   # same contract against real PostgreSQL
</code></pre>
<hr />
<h2>🛡️ 5. Architecture rules without a framework</h2>
<p>No ArchUnit in Go, and you don't need it: <code>go/parser</code> can read the imports of every file.</p>
<pre><code class="language-go">func TestDomainIsPureGo(t *testing.T) {
	check(t, "domain must only use the standard library", under("internal/domain"),
		func(imp string) bool { return !isStdlib(imp) })
}

func TestApplicationOnlyKnowsTheDomain(t *testing.T) {
	check(t, "app may only import the domain", under("internal/app"),
		func(imp string) bool { return !isStdlib(imp) &amp;&amp; imp != module+"/internal/domain" })
}

func TestOnlyTheMcpAdapterAndMainKnowTheSDK(t *testing.T) {
	check(t, "MCP SDK outside its adapter",
		func(pkg string) bool { return pkg != "internal/adapter/in/mcp" &amp;&amp; pkg != "cmd/server" },
		func(imp string) bool { return strings.HasPrefix(imp, "github.com/modelcontextprotocol/go-sdk") })
}
</code></pre>
<hr />
<h2>📊 6. Built to be compared</h2>
<p><img src="https://mermaid.ink/img/pako:eNpNzz0LwkAMBuC_EjIpKFaFoiKCn5OTjp5DmkvpoddKvOIg_ndPq2KGkOFJwntHrqzgBDA_VzcuSANsd6aEWPODQVMPskEf9uQFSLlwQTjUKgaP0O3OYPE2djS0sdt-0sgrVxe5fs3y3zBTY7wEdQxlnH9y1XxM6fPxlMZTUpK66kvWHzJOTZ1LksOGnEImJRee9DTNtDdrXV4x0nbcwQ6gF_XkbAx5x1CIf8e1klN9Dvh4PAFY4FGe?type=png&amp;bgColor=white&amp;width=1600" alt="Built to be compared" /></p>
<p>The Go server exposes <code>mcp_tool_duration_seconds</code>, <code>mcp_tool_errors_total</code> and <code>http_server_requests_seconds</code>, <strong>the same names Micrometer produces in Java</strong>. The Grafana dashboard from part 4 works unchanged (only the JVM panels became heap and goroutines), and the k6 scenario is the same: same arrival rate, same thresholds, one agent (token + session) per virtual user. The only extra knob is <code>MCP_HOST</code>: the Go SDK's Streamable HTTP handler protects against DNS rebinding and only accepts <code>localhost</code> in the <code>Host</code> header, so if k6 runs in a container (<code>host.docker.internal</code>) you override the header.</p>
<hr />
<h2>💡 Lessons learned</h2>
<ol>
<li><strong>The architecture translated 1:1. The idioms didn't.</strong> Ports, adapters and contract tests mean the same thing; annotations became explicit code.</li>
<li><strong>Explicit wiring is a feature.</strong> <code>main.go</code> shows the whole hexagon in one screen. In Spring that picture is spread across <code>@Configuration</code> classes.</li>
<li><strong>Keep the SDK at the edge.</strong> Splitting <code>tools.go</code> from <code>server.go</code> made the tool logic testable with zero dependencies.</li>
<li><strong>Errors as values map cleanly to protocols.</strong> One <code>errors.Is</code> switch per adapter: HTTP status codes for REST, readable messages for the model.</li>
<li><strong>Same metric names make comparisons honest.</strong> Decide them once, in the first language.</li>
</ol>
<hr />
<h2>🚀 Next in the series</h2>
<p><strong>Part 6:</strong> Java vs Go, measured: the same k6 run against both servers, with p95/p99 latency per tool, throughput, memory and startup time.</p>
<p>⭐ Code: <a href="https://github.com/cbuelvasc/orders-mcp-go">github.com/cbuelvasc/orders-mcp-go</a></p>
<hr />
<p>👋 I'm <strong>Carmelo Buelvas</strong>, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on <a href="https://www.linkedin.com/in/cbuelvasc/">LinkedIn</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Observing AI Agents: Grafana Dashboards and k6 Load Tests for MCP]]></title><description><![CDATA[Agents don't behave like humans: they call tools in bursts, in loops and in chains. If you can't see latency, errors and auth failures per tool, you can't…]]></description><link>https://cbuelvasc.hashnode.dev/observing-ai-agents-grafana-dashboards-and-k6-load-tests-for-mcp</link><guid isPermaLink="true">https://cbuelvasc.hashnode.dev/observing-ai-agents-grafana-dashboards-and-k6-load-tests-for-mcp</guid><category><![CDATA[observability]]></category><category><![CDATA[Grafana]]></category><category><![CDATA[k6]]></category><category><![CDATA[#prometheus]]></category><category><![CDATA[mcp]]></category><dc:creator><![CDATA[Carmelo Buelvas Comas]]></dc:creator><pubDate>Fri, 13 Nov 2026 22:36:53 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<blockquote>
<p><strong>Agents don't behave like humans: they call tools in bursts, in loops and in chains.</strong> If you can't see latency, errors and auth failures <strong>per tool</strong>, you can't run an MCP server in production.</p>
</blockquote>
<p>⭐ <strong>Code:</strong> <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a> · Part 4 of the series <strong>MCP in Production: Java &amp; Go</strong></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Choice</th>
</tr>
</thead>
<tbody><tr>
<td>Metrics</td>
<td>Micrometer: timer + error counter per tool</td>
</tr>
<tr>
<td>Percentiles</td>
<td>Histogram buckets (p95/p99) + SLO buckets</td>
</tr>
<tr>
<td>Storage</td>
<td>Prometheus</td>
</tr>
<tr>
<td>Dashboard</td>
<td>Grafana, provisioned as code</td>
</tr>
<tr>
<td>Load</td>
<td>k6 with a real OAuth 2.1 token</td>
</tr>
</tbody></table>
<hr />
<h2>🔭 The pipeline</h2>
<img src="https://mermaid.ink/img/pako:eNpdkVFLwzAQx7_KkWfXrYp1iAxxjD3MYhm-mSG35OrK2qZcE2WMfXdvmWxqHhI48v_9Lpe9Ms6SugdV1u7LbJA9PC91C7IW2ZtWOlxnmMI2e1jzcLIO3Psd4Ae1HjxjWVZGqxUMBqDVEyETg3dbarWS2gTyaSEMx5a4HzSmi5DH3HSvztUS_BFNo8iOb6zsNk1hQTtTO9xGdiJsU1eifDdMVs4K614MiRgW2YkhppMxF1ZeGXYNeeIoFHHiRZjYwOgr1_6tErPj_txNnkfQ7Nj5EI0P6B0PuwjcULhcnJ2MxfJ398aMoTjfjaLeMHYE9Em8g9tLvljG_PxfHmEug8UWY_glzi4-T4fRaH0Hx9EdIeoKVEPcYGXl__ZKhE38SUslhtqrw-Eb8KGZTg?type=png&amp;bgColor=white&amp;width=1600" alt="Observability pipeline for MCP tools" style="display:block;margin:0 auto" />

<hr />
<h2>📐 1. Designing the metrics</h2>
<p>Two meters, both tagged by <strong>tool</strong>:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Type</th>
<th>Tags</th>
<th>Answers</th>
</tr>
</thead>
<tbody><tr>
<td><code>mcp.tool.duration</code></td>
<td>Timer (histogram)</td>
<td><code>tool</code></td>
<td>How fast is each tool? How many calls?</td>
</tr>
<tr>
<td><code>mcp.tool.errors</code></td>
<td>Counter</td>
<td><code>tool</code>, <code>exception</code></td>
<td>Which tool fails, and why?</td>
</tr>
</tbody></table>
<p>They are recorded in the MCP adapter, the only place that knows what a "tool" is:</p>
<pre><code class="language-java">/** Timer + error counter per tool -&gt; Prometheus -&gt; Grafana. */
private &lt;T&gt; T observe(String tool, Supplier&lt;T&gt; call) {
    try {
        return meterRegistry.timer("mcp.tool.duration", "tool", tool).record(call);
    } catch (RuntimeException e) {
        meterRegistry.counter("mcp.tool.errors", "tool", tool,
                "exception", e.getClass().getSimpleName()).increment();
        throw e;
    }
}
</code></pre>
<p>Percentiles need histogram buckets, and SLOs need buckets at the thresholds you care about:</p>
<pre><code class="language-yaml">management:
  metrics:
    distribution:
      percentiles-histogram:
        mcp.tool.duration: true
      slo:
        mcp.tool.duration: 50ms, 100ms, 250ms
</code></pre>
<hr />
<h2>📊 2. The dashboard, as code</h2>
<p>Grafana is provisioned from the repo (<code>docker/grafana</code>), so the dashboard is versioned with the service.</p>
<img src="https://mermaid.ink/img/pako:eNp9kl9LwzAUxb_KJSAoOtraTqgPgoo-yETZii_Ohyy5Wwv5R5o6x9h3N5lbIXSYp8Dv3Hvuyc2WMM2R3AJZCr1mNbUOqoe5An_abrGy1NQw1evsc07uHVBYCaoYzsnXnyacWYCV1gIYFaKFBNqYX3tuyjEI6lCxTQxzD5-s1RYsdY2OYeHh2aFtpzhayNIUZAvns8nbRa9FxYcjB9d3X-H8ZFHXKjvMk4ApS1hsTkhC8WOwTdqB4KRbiPFMG9FZjNNXfcC-E1wC_jA0Pq6KtSFvkWZ-siLNQStIJDP_G4eSaadcI-OtVGMPXj5eoUZqYnLjyaT5RnC1RcrboUHYOIxGd_t3PF7y46UgV0AkWkkb7r_Olrga5f4TcVzSTjiy2_0C9IiurQ?type=png&amp;bgColor=white&amp;width=1600" alt="Dashboard layout" style="display:block;margin:0 auto" />

<p>The PromQL behind the key panels:</p>
<table>
<thead>
<tr>
<th>Panel</th>
<th>Query</th>
</tr>
</thead>
<tbody><tr>
<td>p95 by tool</td>
<td><code>histogram_quantile(0.95, sum by (le, tool) (rate(mcp_tool_duration_seconds_bucket[5m])))</code></td>
</tr>
<tr>
<td>Calls/s by tool</td>
<td><code>sum by (tool) (rate(mcp_tool_duration_seconds_count[1m]))</code></td>
</tr>
<tr>
<td>Error ratio</td>
<td><code>sum(rate(mcp_tool_errors_total[5m])) / sum(rate(mcp_tool_duration_seconds_count[5m]))</code></td>
</tr>
<tr>
<td>SLO under 100 ms</td>
<td><code>sum(rate(mcp_tool_duration_seconds_bucket{le="0.1"}[5m])) / sum(rate(mcp_tool_duration_seconds_count[5m]))</code></td>
</tr>
<tr>
<td>Auth failures</td>
<td><code>sum by (status) (rate(http_server_requests_seconds_count{uri="/mcp", status=~"401|403"}[1m]))</code></td>
</tr>
</tbody></table>
<blockquote>
<p>💡 The <strong>401/403 panel</strong> is the one people forget. A spike there usually means an agent with an expired token or a client asking for the wrong scope, and it looks like an outage from the agent's side.</p>
</blockquote>
<hr />
<h2>⚡ 3. Load testing like an agent</h2>
<p>The k6 script does what an autonomous agent does: gets a token with <code>client_credentials</code>, opens an MCP session, and calls tools in a burst. <strong>Each virtual user is one agent</strong>, with its own token and its own session:</p>
<pre><code class="language-javascript">// Per-VU state: every virtual user is one agent with its own token and MCP session.
let agent = null;
let nextId = 1;

function connect() {
  // OAuth 2.1 client_credentials: what an autonomous agent would do
  const tokenRes = http.post(TOKEN_URL, {
    grant_type: 'client_credentials',
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    scope: 'orders:read',
  }, { tags: { tool: 'token' } });
  if (!check(tokenRes, { 'got token': (r) =&gt; r.status === 200 })) return null;
  const token = tokenRes.json('access_token');
  // ... initialize the MCP session, keep Mcp-Session-Id
  return { token, session };
}

export default function () {
  if (!agent) agent = connect();          // once per VU, on its first iteration
  if (!agent) { sleep(1); return; }
  // ... tools/call with nextId++ so every request has its own JSON-RPC id
}
</code></pre>
<blockquote>
<p>⚠️ <strong>Lesson from a real run:</strong> my first version opened <strong>one</strong> session in <code>setup()</code> and shared it across all VUs, with fixed JSON-RPC ids. Under the burst, about 0.8% of calls failed with <em>duplicate in-flight request ID</em>: the server (correctly) refuses two concurrent requests with the same id in the same session. One session per VU plus incrementing ids took failures to <strong>0</strong>. A bonus: the token no longer travels through <code>setup()</code>, so it doesn't end up in the <code>--summary-export</code> file.</p>
</blockquote>
<pre><code class="language-javascript">export const options = {
  scenarios: {
    agents: {
      executor: 'ramping-arrival-rate',
      startRate: 10, timeUnit: '1s',
      preAllocatedVUs: 50, maxVUs: 200,
      stages: [
        { duration: '30s', target: 50 },
        { duration: '1m', target: 200 },   // burst: agents chaining calls
        { duration: '30s', target: 0 },
      ],
    },
  },
  thresholds: {
    http_req_failed: ['rate&lt;0.01'],
    'http_req_duration{tool:findOrder}': ['p(95)&lt;150'],
    'http_req_duration{tool:listRecentOrders}': ['p(95)&lt;200'],
  },
};
</code></pre>
<p><strong>Why</strong> <code>ramping-arrival-rate</code><strong>?</strong> Agents don't wait for the previous response before deciding to call again across conversations: load arrives at a rate, not per user. An arrival-rate executor models that and exposes queueing that a fixed number of virtual users hides.</p>
<p><strong>Why thresholds per tool?</strong> Because a slow <code>listRecentOrders</code> and a fast <code>findOrder</code> average out to "fine" while one tool is actually breaking its SLO.</p>
<hr />
<h2>▶️ 4. Run it yourself</h2>
<pre><code class="language-bash">./gradlew bootRun            # app + Postgres + Keycloak + Prometheus + Grafana
k6 run k6/mcp-load-test.js   # in another terminal
open http://localhost:3000   # Dashboards › MCP › Orders MCP · Tools
</code></pre>
<img src="https://mermaid.ink/img/pako:eNplkLFuwzAMRH-F0OwgttuojYdMBTw4QIdOBrKwElMLlSWHkoYiyL9XsYou4UAQvHcHglehvCbRgQh0SeQUvRn8YpxPDnItyNEos6CLMAIGGH16VAZ5l06pldjAt3wE-qLr1yedu1IIPeMZHRZ03BwOg-yyFTi5shvk3zLfskBT3-ObfQttXYNCa8M2FLDfZHDsYNnv4PMHove2AmL2nK3R-Ao-ju__mYWNE1OYvNXr1S_1Drbr8KxEBWImntHo_JSriBPN63s0nTHZKG63X7xWXeI?type=png&amp;bgColor=white&amp;width=1600" alt="Running the load test" style="display:block;margin:0 auto" />

<p>I'll share my own numbers in the Java vs Go comparison (part 6). Run it on your machine and tell me what you get.</p>
<hr />
<h2>💡 Lessons learned</h2>
<img src="https://mermaid.ink/img/pako:eNpVj0FLw0AUhP_K8M4t3dhSRaRQq9BDoKCCB9fDy-7bpDTpwualIqX_3aUQ0TnM6ZsZ5kwueqF7UGjjl2s4KcoXe0TW-sOSHfzd3GUP4dYOQUzAG9eovqExtpY-MZ2u8DiSPrvzBtt9r7FO3PWThyrNVseo4JMkrqUfQ5t_9RzwWu5QDe4g-ss8_S32hcE7q2uwMMVsYeYj9XylbpZcoIzswXkMiVUyQBNQJ6njvc8vz6SNdNe_XgIPrdLl8gPtCFEy?type=png&amp;bgColor=white&amp;width=1600" alt="Lessons learned" style="display:block;margin:0 auto" />

<ol>
<li><strong>The tool is your unit of observability</strong>, the same way the endpoint is for REST.</li>
<li><strong>Averages lie.</strong> Enable histograms and look at p95/p99 per tool.</li>
<li><strong>Put SLO buckets where your promise is</strong> (100 ms here), so "are we OK?" is one number.</li>
<li><strong>Auth failures are part of reliability</strong> for agent traffic.</li>
<li><strong>Dashboards belong in the repo</strong>, provisioned, reviewed and versioned like code.</li>
</ol>
<hr />
<h2>🚀 Next in the series</h2>
<p><strong>Part 5:</strong> the same hexagonal MCP server in <strong>Go</strong>, with the official MCP Go SDK. Same tools, same scopes, same metrics, so part 6 can compare them fairly.</p>
<p>⭐ Code: <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a></p>
<hr />
<p>👋 I'm <strong>Carmelo Buelvas</strong>, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on <a href="https://www.linkedin.com/in/cbuelvasc/">LinkedIn</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Swapping Adapters: PostgreSQL Without Touching the Core]]></title><description><![CDATA[Hexagonal architecture is a promise: you can replace an adapter without touching the core. In this part I keep that promise by swapping the in-memory…]]></description><link>https://cbuelvasc.hashnode.dev/swapping-adapters-postgresql-without-touching-the-core</link><guid isPermaLink="true">https://cbuelvasc.hashnode.dev/swapping-adapters-postgresql-without-touching-the-core</guid><category><![CDATA[Java]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Hexagonal Architecture]]></category><category><![CDATA[Testcontainers]]></category><category><![CDATA[mcp]]></category><dc:creator><![CDATA[Carmelo Buelvas Comas]]></dc:creator><pubDate>Fri, 13 Nov 2026 22:36:23 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<blockquote>
<p><strong>Hexagonal architecture is a promise: you can replace an adapter without touching the core.</strong> In this part I keep that promise by swapping the in-memory repository for <strong>PostgreSQL</strong>, and I prove it with a <strong>contract test</strong> that both adapters must pass.</p>
</blockquote>
<p>⭐ <strong>Code:</strong> <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a> · Part 3 of the series <strong>MCP in Production: Java &amp; Go</strong></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Choice</th>
</tr>
</thead>
<tbody><tr>
<td>Database</td>
<td>PostgreSQL 17</td>
</tr>
<tr>
<td>Access</td>
<td><code>JdbcClient</code> (plain SQL, no ORM)</td>
</tr>
<tr>
<td>Migrations</td>
<td>Flyway (schema + demo seed)</td>
</tr>
<tr>
<td>Tests</td>
<td>Contract test + Testcontainers</td>
</tr>
<tr>
<td>Local dev</td>
<td><code>compose.yaml</code> started by <code>./gradlew bootRun</code></td>
</tr>
</tbody></table>
<hr />
<h2>🔁 The swap</h2>
<p><img src="https://mermaid.ink/img/pako:eNqNkdFqwjAUQH_lEl-UKbPpxCKzsAmTDceqezR7aJMbW5Y2kqZIEf99se0Gjo3tPuTlHMLh3iPhWiCZAZFKH3gaGwurDSvATVklOxPvU7hHqQ1uGYnO2GMVHXs-ZeSt9c6z8Bx2IKEeLJzsIIxGIURe34EXI9BscK_LzGpTR9pYJwwa43nbZ-SxGOWYO3SbmOswj_eMDLrfsRDfcu6kRfNZ419m0F8y6F8Z0fLcwSoR-MK93Asg0qXdGXxdr5qqJ5HwhcqwsHAFD6o-xPUPle2uYD4P286u3dYK3ZJAZkrNeoJLjtNhaY1-x1mPUj6Z4IVJ_2tGy87EsaQSv8wxDW74lAyB5GjyOBPuxkdiU8ybawuUcaUsOZ0-AC08nkU?type=png&amp;bgColor=white&amp;width=1600" alt="Swapping the in-memory adapter for PostgreSQL" /></p>
<p>What changed per layer for the persistence swap:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Files changed</th>
</tr>
</thead>
<tbody><tr>
<td><code>domain</code></td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><code>application</code></td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><code>adapter/in</code> (MCP, REST)</td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><code>adapter/out</code></td>
<td>new <code>JdbcOrderRepository</code></td>
</tr>
<tr>
<td><code>resources</code></td>
<td>two Flyway migrations</td>
</tr>
</tbody></table>
<p>The only port change was a new <code>save</code> method, and that came from the <code>cancelOrder</code> feature in part 2, not from the database.</p>
<hr />
<h2>🧾 1. The port is the contract</h2>
<pre><code class="language-java">/** Outbound port: implemented by driven adapters (PostgreSQL in production, in-memory in tests). */
public interface OrderRepositoryPort {

    Optional&lt;Order&gt; findById(OrderId id);

    List&lt;Order&gt; findRecentByCustomer(CustomerId customerId, int limit);

    Order save(Order order);
}
</code></pre>
<h2>🧪 2. A contract test every adapter must pass</h2>
<p>This is the key idea of the post. Instead of testing each adapter in its own way, I write the expectations <strong>once</strong>, as an abstract test against the port:</p>
<pre><code class="language-java">/**
 * Contract for the outbound port. EVERY adapter (in-memory, PostgreSQL, ...) must pass it.
 * This is what makes swapping adapters safe.
 */
public abstract class OrderRepositoryContract {

    protected abstract OrderRepositoryPort repository();

    private static final Instant NOW = Instant.parse("2026-11-13T12:00:00Z");

    @Test
    void savesAndFindsAnOrder() {
        var order = order("ORD-9001", "CUS-99", OrderStatus.PAID, NOW);

        repository().save(order);

        assertThat(repository().findById(order.id())).contains(order);
    }

    @Test
    void returnsEmptyForUnknownOrders() {
        assertThat(repository().findById(OrderId.of("ORD-9999999"))).isEmpty();
    }

    @Test
    void listsTheMostRecentOrdersOfOneCustomerNewestFirst() {
        repository().save(order("ORD-9101", "CUS-98", OrderStatus.PAID, NOW.minus(3, ChronoUnit.HOURS)));
        repository().save(order("ORD-9102", "CUS-98", OrderStatus.PAID, NOW.minus(1, ChronoUnit.HOURS)));
        repository().save(order("ORD-9103", "CUS-98", OrderStatus.PAID, NOW.minus(2, ChronoUnit.HOURS)));
        repository().save(order("ORD-9104", "CUS-97", OrderStatus.PAID, NOW));

        var recent = repository().findRecentByCustomer(CustomerId.of("CUS-98"), 2);

        assertThat(recent).extracting(o -&gt; o.id().value()).containsExactly("ORD-9102", "ORD-9103");
    }

    // ...more scenarios in the repo
}
</code></pre>
<p><img src="https://mermaid.ink/img/pako:eNpd0EtLAzEQAOC_MsSLQkutRa1FerCCrFisdW-mh2wysaF5yCSLlNL_7nRRDzuHgTnMN4-D0MmgmIGwPn3rraIC9YOMwLGoP6SQrZlOkLOxCK9kkNb4lbIrifaLFAspXe4bGs3PVZO7Cgrmki-k2PwxMBzOoVqyVsUlBu7sQTV3dIhsr27UGILz3mXUKZrcd1ZPp7WeTaN7SFX_Eryw4azHUyBUHlYpl0_C97cXcBEek94h_au57D2ebMszZ2cW7UTfDviStEMur-_wshEDEAEpKGf4UwdRthi6nxm0qvVFHI8_HUhtqA?type=png&amp;bgColor=white&amp;width=1600" alt="One contract test, two adapters" /></p>
<p>The in-memory version moved to <code>src/test</code>: it is now a <strong>test double</strong> that is guaranteed to behave like production, because it passes the same contract.</p>
<hr />
<h2>🐘 3. The PostgreSQL adapter</h2>
<p>Plain SQL with <code>JdbcClient</code>. The mapping between rows and the <code>Order</code> aggregate lives here and only here. No JPA entities leaking into the domain.</p>
<pre><code class="language-java">/**
 * Driven adapter for PostgreSQL. Plain SQL with JdbcClient: no ORM entities leaking into the domain.
 * The mapping between rows and the Order aggregate lives here and only here.
 */
@Repository
public class JdbcOrderRepository implements OrderRepositoryPort {

    private static final String COLUMNS = "id, customer_id, status, total, currency, updated_at";

    private final JdbcClient jdbc;

    public JdbcOrderRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Override
    public Optional&lt;Order&gt; findById(OrderId id) {
        return jdbc.sql("SELECT " + COLUMNS + " FROM orders WHERE id = :id")
                .param("id", id.value())
                .query(JdbcOrderRepository::toOrder)
                .optional();
    }

    @Override
    public List&lt;Order&gt; findRecentByCustomer(CustomerId customerId, int limit) {
        return jdbc.sql("""
                        SELECT %s FROM orders
                        WHERE customer_id = :customerId
                        ORDER BY updated_at DESC
                        LIMIT :limit
                        """.formatted(COLUMNS))
                .param("customerId", customerId.value())
                .param("limit", limit)
                .query(JdbcOrderRepository::toOrder)
                .list();
    }

    @Override
    public Order save(Order order) {
        jdbc.sql("""
                        INSERT INTO orders (id, customer_id, status, total, currency, updated_at)
                        VALUES (:id, :customerId, :status, :total, :currency, :updatedAt)
                        ON CONFLICT (id) DO UPDATE
                        SET status = EXCLUDED.status, total = EXCLUDED.total, updated_at = EXCLUDED.updated_at
                        """)
                .param("id", order.id().value())
                .param("customerId", order.customerId().value())
                .param("status", order.status().name())
                .param("total", order.total())
                .param("currency", order.currency())
                .param("updatedAt", Timestamp.from(order.updatedAt()))
                .update();
        return order;
    }

    private static Order toOrder(ResultSet rs, int rowNum) throws SQLException {
        return new Order(
                OrderId.of(rs.getString("id")),
                CustomerId.of(rs.getString("customer_id")),
                OrderStatus.valueOf(rs.getString("status")),
                rs.getBigDecimal("total"),
                rs.getString("currency"),
                rs.getTimestamp("updated_at").toInstant());
    }
}
</code></pre>
<p>The <code>ON CONFLICT ... DO UPDATE</code> upsert makes <code>save</code> work for both new and existing orders.</p>
<hr />
<h2>📜 4. Flyway migrations</h2>
<pre><code class="language-sql">-- V1__create_orders.sql
CREATE TABLE orders
(
    id          VARCHAR(20)    PRIMARY KEY,
    customer_id VARCHAR(20)    NOT NULL,
    status      VARCHAR(20)    NOT NULL CHECK (status IN ('CREATED', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED')),
    total       NUMERIC(12, 2) NOT NULL CHECK (total &gt;= 0),
    currency    CHAR(3)        NOT NULL,
    updated_at  TIMESTAMPTZ    NOT NULL
);

-- Supports listRecentOrders: WHERE customer_id = ? ORDER BY updated_at DESC LIMIT ?
CREATE INDEX idx_orders_customer_recent ON orders (customer_id, updated_at DESC);
</code></pre>
<pre><code class="language-sql">-- V2__seed_demo_orders.sql
-- Demo data: ORD-1000 .. ORD-1059 for customers CUS-1 .. CUS-6
INSERT INTO orders (id, customer_id, status, total, currency, updated_at)
SELECT 'ORD-' || n,
       'CUS-' || (n % 6 + 1),
       (ARRAY ['CREATED', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED'])[n % 5 + 1],
       15 + (n % 17) * 7.5,
       'USD',
       now() - ((1060 - n) || ' hours')::interval
FROM generate_series(1000, 1059) AS n;
</code></pre>
<p>The index matches the query of <code>listRecentOrders</code> exactly: filter by customer, order by date, take N.</p>
<hr />
<h2>🐳 5. Testcontainers: the real thing in tests</h2>
<pre><code class="language-java">/** A real PostgreSQL in Docker for integration tests. Spring Boot wires the DataSource automatically. */
@TestConfiguration(proxyBeanMethods = false)
public class PostgresTestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer("postgres:17-alpine");
    }
}
</code></pre>
<pre><code class="language-java">/** Same contract as the in-memory fake, now against real PostgreSQL + Flyway migrations. */
@SpringBootTest
@Import(PostgresTestcontainersConfig.class)
class JdbcOrderRepositoryIT extends OrderRepositoryContract {

    @Autowired
    JdbcOrderRepository repository;

    @Override
    protected OrderRepositoryPort repository() {
        return repository;
    }

    @Test
    void flywaySeedsTheDemoData() {
        assertThat(repository.findById(OrderId.of("ORD-1042"))).isPresent();
    }
}
</code></pre>
<p><code>@ServiceConnection</code> wires the DataSource to the container automatically. Flyway runs the same migrations as production, so the test also checks the schema.</p>
<hr />
<h2>🚀 6. Local dev with one command</h2>
<p><img src="https://mermaid.ink/img/pako:eNplkMFqwzAMhl9F-LTBvDYbjG6MwkhGD9shZLBL3YNjK2lIHAXFJozSd59XBmszHXSQPunXr4MwZFE8gag6msxes4f3QvUQI3v93Cqhgl3d25jNw6MKd8ul_auUJdwuata2wwlKIl-EXokdSLmGLI3D48BNX8uflrRkWmRpyA00YsR-VdITnm-2V-diyQoi5mvGUYnrS_YtPb_LJgm0-GU60u2_rcWFAxOXMjn0ewzjnN3MWA3RWaV7PQdf8jySxBZ5lM4MzyUv1pn2-oMCGwQdPMmpYbRxUtyAcMhONzZ--SCitDv922KlQ-fF8fgNsP96TA?type=png&amp;bgColor=white&amp;width=1600" alt="Local dev stack started by bootRun" /></p>
<p><code>spring-boot-docker-compose</code> (a <code>developmentOnly</code> dependency) reads <code>compose.yaml</code>, starts the containers and creates the connection details for PostgreSQL. No URLs or passwords in <code>application.yml</code>.</p>
<hr />
<h2>🛡️ 7. Guarding the new boundary</h2>
<p>One more ArchUnit rule: SQL is only allowed in the JDBC adapter.</p>
<pre><code class="language-java">@ArchTest
static final ArchRule onlyTheJdbcAdapterKnowsSql = noClasses()
        .that().resideOutsideOfPackage("..adapter.out.jdbc..")
        .should().dependOnClassesThat().resideInAnyPackage("org.springframework.jdbc..", "java.sql..");
</code></pre>
<hr />
<h2>💡 Lessons learned</h2>
<p><img src="https://mermaid.ink/img/pako:eNpVj7EKg0AMhl8lZFZK61JKEaqOHYp063WIdxEFvStnjg7iu_ewWGiGfwjfl_DPqJ1hPAG2g3vrjrzAtVYW4lweClUwx4xjmpbh5rxMQJ7Pjd_l2lnxpGVS-IQ0zaH445ngzpOAdAwbmqyidd8tGXoJ-00vN93E1FkGNdMAVbE6vQWJ136_qhU-NIc9lM4zBCsu6I5NBDABHNmP1JtYbMb4a1wrGm4pDILL8gEXAFA-?type=png&amp;bgColor=white&amp;width=1600" alt="Lessons learned" /></p>
<ol>
<li><strong>A port without a contract test is only a hope.</strong> The contract is what makes the swap safe.</li>
<li><strong>Keep fakes honest.</strong> If the fake passes the same contract, unit tests stay fast and trustworthy.</li>
<li><strong>Plain SQL is fine.</strong> For a read-heavy MCP server, <code>JdbcClient</code> keeps the adapter small and the queries obvious.</li>
<li><strong>Model the index after the tool.</strong> The tool's query defines the access pattern.</li>
</ol>
<hr />
<h2>🚀 Next in the series</h2>
<p><strong>Part 4:</strong> observing AI agents in production: Grafana dashboards, SLOs and k6 load tests with real OAuth tokens.</p>
<p>⭐ Code: <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a></p>
<hr />
<p>👋 I'm <strong>Carmelo Buelvas</strong>, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on <a href="https://www.linkedin.com/in/cbuelvasc/">LinkedIn</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Securing MCP Tools with OAuth 2.1: Scopes per Tool in Spring AI]]></title><description><![CDATA[An AI agent calling your tools is a client like any other, and it needs a token. With Spring Security 7 your MCP server becomes an OAuth 2.1 Resource…]]></description><link>https://cbuelvasc.hashnode.dev/securing-mcp-tools-with-oauth-2-1-scopes-per-tool-in-spring-ai</link><guid isPermaLink="true">https://cbuelvasc.hashnode.dev/securing-mcp-tools-with-oauth-2-1-scopes-per-tool-in-spring-ai</guid><category><![CDATA[Java]]></category><category><![CDATA[spring-boot]]></category><category><![CDATA[mcp]]></category><category><![CDATA[oauth]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Carmelo Buelvas Comas]]></dc:creator><pubDate>Fri, 13 Nov 2026 22:35:53 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<blockquote>
<p><strong>An AI agent calling your tools is a client like any other, and it needs a token.</strong> With Spring Security 7 your MCP server becomes an OAuth 2.1 Resource Server, and <strong>each tool declares the scope it needs</strong>.</p>
</blockquote>
<p>⭐ <strong>Code:</strong> <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a> · Part 2 of the series <strong>MCP in Production: Java &amp; Go</strong></p>
<table>
<thead>
<tr>
<th>What</th>
<th>How</th>
</tr>
</thead>
<tbody><tr>
<td>Role of the MCP server</td>
<td>OAuth 2.1 Resource Server (JWT)</td>
</tr>
<tr>
<td>Discovery</td>
<td>RFC 9728 Protected Resource Metadata + <code>WWW-Authenticate</code></td>
</tr>
<tr>
<td>Authorization</td>
<td><code>@PreAuthorize</code> per <code>@McpTool</code> (scopes)</td>
</tr>
<tr>
<td>Authorization server</td>
<td>Keycloak demo realm (any OIDC server works)</td>
</tr>
<tr>
<td>New tool</td>
<td><code>cancelOrder</code>, the first tool that <strong>writes</strong></td>
</tr>
</tbody></table>
<hr />
<h2>🧨 Why this matters</h2>
<p>In part 1 every agent could call every tool. That was fine for reading orders. It is <strong>not</strong> fine the moment a tool changes data: an agent that only needs to answer "where is my order?" must never be able to cancel one.</p>
<p>So this part adds two things:</p>
<ol>
<li><strong>Authentication</strong>: no token, no tools.</li>
<li><strong>Least privilege per tool</strong>: read tools need <code>orders:read</code>, write tools need <code>orders:write</code>.</li>
</ol>
<hr />
<h2>🔄 How an MCP client gets in</h2>
<p>The MCP authorization spec builds on standard OAuth. The interesting part is <strong>discovery</strong>: the client doesn't need to be configured with the authorization server; the MCP server tells it.</p>
<img src="https://mermaid.ink/img/pako:eNqNUl1rwkAQ_CtLnpSa-tEiJaBgrRQqolDBFyFs79Z6mNyl96FQ8b93Y6pVfOk9BI7MzM7M3j4SRlKUQOToK5AW9KLw02K-1MAHgzc65B9kq3uB1iuhCtQeBoAOlkE-PRB_ZbsLk-EMRKZI-1v0pEQbK8m6OBfFLWB8lpNHuTYMgl8bq77RK6PBkd2ebAzifn-SwGz6Pocmq0FNG_BmQ7peASYxIwYJPLbacAeLxSIuxdiYEugpgWdCSxYsOROsoDQnjxI99pbRMnRanW7zfkdZFm-02emm4RrWcWGNJ-FJxidaOZoJl5ZeR-zo39wrr3gZN63iOnbvhCnIpS4UhbEs8TdvnFShOQfvznmoHbG9qubEEsrfPsanGUKQc2nFqr0t5vUr996YzDUFZhmslJbTUocdXC3iXF5V1CnDkb_FTHGNBKzcALEmsYH34XQ2Si8sXafmPkLmowZEOdkcleS3uI94V_nxVUpaYfn_cPgBJXHhYA?type=png&amp;bgColor=white&amp;width=1600" alt="OAuth 2.1 discovery flow for an MCP client" style="display:block;margin:0 auto" />

<hr />
<h2>⬡ Where security lives in the hexagon</h2>
<p>Security is an <strong>infrastructure concern</strong>. The domain still doesn't know that tokens exist.</p>
<img src="https://mermaid.ink/img/pako:eNpVklFr2zAUhf_KRX1JWJvYbtqMMMqytIGNmaRJYYF5DEX3mIgpVpDljrb0v1eR05DeBz2I73wcifsilGWIEYnS2P9qI52nn4uiojCLu_vfhSga_nyJcHJ6Td5aU_eVNObL2vVvvkE6OPrx66EQf-ji4oam7wkOJ5AWTYmkpCVU47R_mmrj4SYbqasoeJRGs_TYK6hokmQ9pEGSBltbYRql-fLUymlCOfzGMtUHbXR9nTuMm3Dv9DM6tbI7dI-ifBlN49tgyidzkix3oUkMzhzD5Wr3sH_cMTG-jYl5JyS-V2vbVEw76_ye6LbIPBKT2eIu9svWWUoT6xCtncqSZkiSIetptq_2sU-PCjFILgtBvaBZtY5rHhCj0uDTT2jRlD6RQ20bp_B3Cx8e4eV7vGVr_2QQK1GpjRmdsSoVhue1d_YfRmdZpq6ucMquDmAJZMiOIMpBGHFOYgu3lZrDjrwIv8E2bgujlI3x4vX1DUCmsiU?type=png&amp;bgColor=white&amp;width=1600" alt="Where security lives in the hexagon" style="display:block;margin:0 auto" />

<hr />
<h2>🛠️ 1. Dependencies</h2>
<p>Spring Boot 4 has a dedicated starter for the resource server:</p>
<pre><code class="language-toml"># gradle/libs.versions.toml
spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server" }
</code></pre>
<pre><code class="language-yaml"># application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${ISSUER_URI:http://localhost:8180/realms/orders}

orders:
  mcp:
    security:
      resource: ${MCP_RESOURCE_URL:http://localhost:8080/mcp}
      authorization-servers:
        - ${ISSUER_URI:http://localhost:8180/realms/orders}
      scopes: [orders:read, orders:write]
</code></pre>
<hr />
<h2>🛡️ 2. The filter chain</h2>
<pre><code class="language-java">/**
 * The MCP server is an OAuth 2.1 Resource Server:
 * - every request to /mcp and /api needs a valid Bearer token (JWT)
 * - each tool checks its own scope with @PreAuthorize (method security)
 * - Spring Security 7 serves the Protected Resource Metadata (RFC 9728) natively;
 *   we only customize it and point 401 responses to it
 */
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
@EnableConfigurationProperties(McpSecurityProperties.class)
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, McpSecurityProperties props) throws Exception {
        var entryPoint = new ResourceMetadataEntryPoint(props.resourceMetadataUrl());
        return http
                .authorizeHttpRequests(auth -&gt; auth
                        .requestMatchers("/.well-known/oauth-protected-resource/**").permitAll()
                        .requestMatchers("/actuator/health/**", "/actuator/prometheus").permitAll()
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth -&gt; oauth
                        .jwt(jwt -&gt; { })
                        .authenticationEntryPoint(entryPoint)
                        .protectedResourceMetadata(metadata -&gt; metadata
                                .protectedResourceMetadataCustomizer(builder -&gt; {
                                    builder.resource(props.resource())
                                            .bearerMethod("header")
                                            .tlsClientCertificateBoundAccessTokens(false);
                                    props.authorizationServers().forEach(builder::authorizationServer);
                                    props.scopes().forEach(builder::scope);
                                })))
                .exceptionHandling(ex -&gt; ex.authenticationEntryPoint(entryPoint))
                .sessionManagement(session -&gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -&gt; csrf.disable()) // no browser sessions, bearer tokens only
                .build();
    }
}
</code></pre>
<p>Three decisions worth calling out:</p>
<ul>
<li><strong>Stateless + no CSRF</strong>: MCP clients send bearer tokens, not cookies.</li>
<li><strong>Metadata is public</strong>: the client must be able to read it <em>before</em> it has a token.</li>
<li><code>@EnableMethodSecurity</code>: authorization happens per tool, not per URL. Every tool lives behind the same <code>/mcp</code> endpoint, so URL rules can't tell tools apart.</li>
</ul>
<hr />
<h2>🧭 3. Discovery: RFC 9728 metadata</h2>
<pre><code class="language-java">/**
 * 401 + WWW-Authenticate with resource_metadata, as required by the MCP authorization spec.
 * That header is how an MCP client discovers where to get a token.
 */
class ResourceMetadataEntryPoint implements AuthenticationEntryPoint {

    private final String resourceMetadataUrl;

    ResourceMetadataEntryPoint(String resourceMetadataUrl) {
        this.resourceMetadataUrl = resourceMetadataUrl;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setHeader(HttpHeaders.WWW_AUTHENTICATE,
                "Bearer resource_metadata=\"%s\"".formatted(resourceMetadataUrl));
    }
}
</code></pre>
<p>The metadata document itself needs <strong>no controller</strong>: Spring Security 7 ships an <code>OAuth2ProtectedResourceMetadataFilter</code> that answers <code>/.well-known/oauth-protected-resource</code>. The <code>protectedResourceMetadata(...)</code> customizer above adds the authorization server and the scopes, and turns off <code>tls_client_certificate_bound_access_tokens</code>, which the filter advertises as <code>true</code> by default.</p>
<blockquote>
<p>⚠️ Lesson learned the hard way: my first version had its own <code>@RestController</code> for this endpoint. The tests failed because the built-in filter answered first, without <code>authorization_servers</code>. When the framework already implements a spec, configure it instead of competing with it.</p>
</blockquote>
<p>What the client sees:</p>
<pre><code class="language-bash">curl -i -X POST http://localhost:8080/mcp
# HTTP/1.1 401
# WWW-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource/mcp"

curl http://localhost:8080/.well-known/oauth-protected-resource/mcp
# {"resource":"http://localhost:8080/mcp",
#  "authorization_servers":["http://localhost:8180/realms/orders"],
#  "scopes_supported":["orders:read","orders:write"],
#  "bearer_methods_supported":["header"]}
</code></pre>
<blockquote>
<p>💡 The Spring AI community project <strong>mcp-security</strong> packages these ideas too. With Spring Security 7, the building blocks are already in the framework.</p>
</blockquote>
<hr />
<h2>🔐 4. Scopes per tool</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Scope</th>
<th>Side effects</th>
</tr>
</thead>
<tbody><tr>
<td><code>findOrder</code></td>
<td><code>orders:read</code></td>
<td>none</td>
</tr>
<tr>
<td><code>listRecentOrders</code></td>
<td><code>orders:read</code></td>
<td>none</td>
</tr>
<tr>
<td><code>cancelOrder</code></td>
<td><code>orders:write</code></td>
<td><strong>cancels an order</strong></td>
</tr>
</tbody></table>
<p>Read tools:</p>
<pre><code class="language-java">    @PreAuthorize(READ)
    @McpTool(name = "findOrder", description = """
            Returns status, total amount and last update of ONE order.
            Use it when the user mentions a specific order id like ORD-1042.
            """)
    public OrderView findOrder(
            @McpToolParam(description = "Order id, format ORD-&lt;digits&gt;, e.g. ORD-1042", required = true)
            String orderId) {
        return observe("findOrder", () -&gt; OrderView.from(findOrder.findById(OrderId.of(orderId))));
    }
</code></pre>
<p>And the new write tool. Note the description: it tells the model to <strong>ask the user for confirmation</strong> first.</p>
<pre><code class="language-java">    @PreAuthorize(WRITE)
    @McpTool(name = "cancelOrder", description = """
            Cancels ONE order that has not shipped yet. This action cannot be undone.
            Only call it after the user explicitly confirmed the order id to cancel.
            """)
    public OrderView cancelOrder(
            @McpToolParam(description = "Order id to cancel, format ORD-&lt;digits&gt;", required = true)
            String orderId) {
        return observe("cancelOrder", () -&gt; OrderView.from(cancelOrder.cancel(OrderId.of(orderId))));
    }
</code></pre>
<p>The business rule ("you can't cancel an order that already shipped") lives in the <strong>aggregate</strong>, not in the adapter:</p>
<pre><code class="language-java">    /** Business rule: only orders that have not left the warehouse can be cancelled. */
    public Order cancel(Instant now) {
        if (!status.isCancellable()) {
            throw new OrderCannotBeCancelledException(id, status);
        }
        return new Order(id, customerId, OrderStatus.CANCELLED, total, currency, now);
    }
</code></pre>
<img src="https://mermaid.ink/img/pako:eNqrVkrOT0lVslJQKi5JLEl1yUxML0rM1S0zislTAIJorVgFXV07BecgV8cQVxeIIJQDlghw9ISKglhgoWAPz4AAmFooByzh4urjGeYahM0YZ0c_Z1cfH1cXK4XkxLzk1Bz_opTUIoWYUiNzA1M084lQi2wrlI2h2CzFRElHQSk3tSg3MTMFGALVSiUZqbngsEhJTUsszSlRqq0FAONbUC8?type=png&amp;bgColor=white&amp;width=1600" alt="Order lifecycle and cancelOrder rule" style="display:block;margin:0 auto" />

<hr />
<h2>🔑 5. A demo authorization server</h2>
<p>The repo ships a Keycloak realm with two clients, which makes the difference between scopes very visible:</p>
<table>
<thead>
<tr>
<th>Client</th>
<th>Scopes it can get</th>
<th>Can cancel?</th>
</tr>
</thead>
<tbody><tr>
<td><code>orders-readonly</code></td>
<td><code>orders:read</code></td>
<td>⛔ 403</td>
</tr>
<tr>
<td><code>orders-agent</code></td>
<td><code>orders:read</code> + <code>orders:write</code> (optional)</td>
<td>✅</td>
</tr>
</tbody></table>
<pre><code class="language-bash">./gradlew bootRun   # starts Postgres, Keycloak, Prometheus and Grafana from compose.yaml
TOKEN=$(./scripts/get-token.sh orders-readonly orders:read)
curl http://localhost:8080/api/orders/ORD-1042 -H "Authorization: Bearer $TOKEN"
</code></pre>
<hr />
<h2>🧪 6. Testing the rules</h2>
<p>No real tokens needed: <code>@WithMockUser</code> sets the authorities, and a real HTTP call checks the 401.</p>
<pre><code class="language-java">@Test
@WithMockUser(authorities = "SCOPE_orders:read")
void aReaderCannotCancelOrders() {
    assertThatThrownBy(() -&gt; tools.cancelOrder("ORD-1040"))
            .isInstanceOf(AuthorizationDeniedException.class);
}

@Test
void mcpEndpointRejectsAnonymousCallsAndPointsToTheMetadata() throws Exception {
    var response = http(HttpRequest.newBuilder(uri("/mcp"))
            .POST(HttpRequest.BodyPublishers.ofString("{}"))
            .header("Content-Type", "application/json"));

    assertThat(response.statusCode()).isEqualTo(401);
    assertThat(response.headers().firstValue("WWW-Authenticate"))
            .hasValueSatisfying(h -&gt; assertThat(h).startsWith("Bearer resource_metadata="));
}
</code></pre>
<hr />
<h2>💡 Lessons learned</h2>
<img src="https://mermaid.ink/img/pako:eNo1z8GKwjAQBuBXGeZs0bqHFVkEtcdFYS97sHuYJhMabDMlSVlK8d2Nkc5hYOD_fpgZlWjGPaDp5F-15CN8_9QO0hxvNdaj3n3otHVZwkUgyp3d6qvx64N7XdKFGv-gKA5wWuIqbUMGro4hKBk4xwf2oGigxnY2Tgs6Z7T93DTw623kdyVQuGdkJCFxxvqeohW3sOrNmm0JlfRkHYRIU8gmsBpT1VQYz5wArgB7TgVWpz9njC33-WPNhsYu4uPxBKSPVgw?type=png&amp;bgColor=white&amp;width=1600" alt="Lessons learned" style="display:block;margin:0 auto" />

<ol>
<li><strong>URL rules are not enough for MCP.</strong> All tools share <code>/mcp</code>, so authorization must be per method.</li>
<li><strong>Discovery is part of the contract.</strong> The 401 header and the metadata let any compliant client find its way.</li>
<li><strong>Separate read and write scopes from day one.</strong> It is the cheapest protection against an over-eager agent.</li>
<li><strong>Tool descriptions are also a safety control.</strong> "Only call it after the user confirmed" is guidance the model actually reads.</li>
</ol>
<hr />
<h2>🚀 Next in the series</h2>
<p><strong>Part 3:</strong> replacing the in-memory repository with <strong>PostgreSQL</strong> (JdbcClient + Flyway + Testcontainers) without touching the core.</p>
<p>⭐ Code: <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a></p>
<hr />
<p>👋 I'm <strong>Carmelo Buelvas</strong>, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on <a href="https://www.linkedin.com/in/cbuelvasc/">LinkedIn</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Production-Ready MCP Server with Spring AI]]></title><description><![CDATA[An MCP server is just another driving adapter. If your service follows hexagonal architecture, exposing it to AI agents is a new entry door, not a rewrite.]]></description><link>https://cbuelvasc.hashnode.dev/building-a-production-ready-mcp-server-with-spring-ai</link><guid isPermaLink="true">https://cbuelvasc.hashnode.dev/building-a-production-ready-mcp-server-with-spring-ai</guid><category><![CDATA[Java]]></category><category><![CDATA[spring-boot]]></category><category><![CDATA[mcp]]></category><category><![CDATA[Hexagonal Architecture]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Carmelo Buelvas Comas]]></dc:creator><pubDate>Fri, 13 Nov 2026 21:49:05 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<blockquote>
<p><strong>An MCP server is just another driving adapter.</strong> If your service follows hexagonal architecture, exposing it to AI agents is a new entry door, not a rewrite.</p>
</blockquote>
<p>⭐ <strong>Full code:</strong> <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a></p>
<table>
<thead>
<tr>
<th>What</th>
<th>Choice</th>
</tr>
</thead>
<tbody><tr>
<td>Language</td>
<td>Java 25 (LTS)</td>
</tr>
<tr>
<td>Framework</td>
<td>Spring Boot 4.1 · Spring Framework 7 · Spring AI 2.0</td>
</tr>
<tr>
<td>MCP</td>
<td><code>@McpTool</code> annotations · Streamable HTTP on <code>/mcp</code></td>
</tr>
<tr>
<td>Build</td>
<td>Gradle 9.9 · Kotlin DSL · version catalog</td>
</tr>
<tr>
<td>Architecture</td>
<td>Hexagonal (Ports &amp; Adapters), enforced with ArchUnit</td>
</tr>
<tr>
<td>Production</td>
<td>Micrometer + Prometheus + Grafana · k6</td>
</tr>
</tbody></table>
<hr />
<h2>🧩 The big picture</h2>
<p>The <strong>Model Context Protocol (MCP)</strong> is an open standard that lets an AI client (Claude, Copilot, Cursor or your own agent) <strong>discover and call tools</strong> exposed by a server. It does for agents what REST did for services.</p>
<p>The question I wanted to answer: <em>where does MCP fit in a well-designed backend?</em></p>
<img src="https://mermaid.ink/img/pako:eNp1k91u4jAQhV_FSm9AApUGQbtoVS2ErjYSfwW6vWj2wrHHYK2xI9tpQVXffR0nbUirzUUij8-ZfOMZvwZEUQhGKGBCvZA91hbN1olE7jF5utM426NIcJDWPCVBktObPrg3vRqiqebPXO6Q4RSS4E9pKp5x7KTjGI13zvY91Ze3rUjgnEIHRSrjQtl2Q_94N3GGR0jRJZqrlIs6HUiayE84v-CId0p6nDANr9A4ywQn2HIlEVG6CUO5BuK3tpM6Gi9azh_LVOWSokxpazzoTy7pUlPQDwYibMAHZ9zYNRBXjN8y1Z77TbvOuPkduYxecJ-DPm1AP3PSZJku50-tJJiqA-bSp_Z6lOS9XnqN_GJjsc1NErTPfMuHbYG7zG3NW9vXkCnDrdKnlYs3qeIF6nZvCzj_dQANYh902f9_2LFkGr93nhad7xHfeZBfGz-dFOXFsnuAg8NB2pGV_V8pY3caNvczJOFY9L_9tcNuZLpdlATzaIUwxZkF7d0_5iTbKiWSwPPGi1LuxqbUr-8226ZhDcZGSlqthAD9yefqLX0VZeWsVNPJxxnYk4D3aUOMCzG6AGBDxjrGZf4Lo4t-ehOyYbXsvnBq96MwO577XVGllQHrk-sPKxt8g156rnTNqaSUMAK1NAzJYABBBzli7UaHuvv6Gtg9HPzNpcBwLmzw9vYPmr4ukQ?type=png&amp;bgColor=white&amp;width=1600" alt="Hexagonal architecture: the MCP adapter is just another driving adapter" style="display:block;margin:0 auto" />

<p>The MCP adapter and the REST adapter sit <strong>side by side</strong>. Both call the same inbound ports. The domain doesn't know AI exists.</p>
<hr />
<h2>🔄 What happens during a tool call</h2>
<img src="https://mermaid.ink/img/pako:eNp1Ut9v2jAQ_ldOfqJqodB10xRNkRhhGtMoKECf8nLYl2HJiTPbGeoQ__suCXRCtHmw5O_H3XcXH4S0ikQEwtPvmkpJicZfDousBP6wDrasiy257l6hC1rqCssAY0APWa0-fyA-1egTjGcwMZrKcC2eN-KFU-Tmslpba_yXrbuPe_PJElBhFcjdXLs2jeubLlXr3HiaoKdrWfpaPKXKeh2se1la90aM5DWzajLLx6zOaZhDggG3be3OM-7H8TwCXeqg0ei_p6bzPuPjCDy5P-SYzi3cgsQKt9qwlPyFPTRz3hvtw6U9P090Bw2ZkuSdtYCH3i38WC2ewMsdFehPS3mygcA2Pdm-3hEU_NcMOELlIfBdkZdOV0HbstsslopBqZmA_U7LXZuGD45rzBsxG_h_sl4mFmnSHw0fHzJxc44fx5su_deXmeq1wpk6sRtm03fZlNkkgtX053SyhsFg0MFJv3M5uz_ruiat-1y4i9lCz5r2l8s8gA8Yah9BJlbfZ8vlNMnEHQ8V0EQwehgOPg7hyIgoyBWoFT_2g-CdFe2zV5RjbYI4Hv8BQJ_3xg?type=png&amp;bgColor=white&amp;width=1600" alt="Sequence of an MCP tool call through the hexagon" style="display:block;margin:0 auto" />

<hr />
<h2>📁 Project structure</h2>
<pre><code class="language-text">src/main/java/dev/cbuelvasc/orders/
├── domain/                     ← pure Java, no frameworks
│   ├── model/                  Order, OrderId, CustomerId, OrderStatus
│   ├── exception/              OrderNotFoundException
│   └── port/
│       ├── in/                 FindOrderUseCase, ListRecentOrdersUseCase
│       └── out/                OrderRepositoryPort
├── application/                OrderQueryService (no Spring annotations)
└── infrastructure/
    ├── adapter/
    │   ├── in/mcp/             OrderMcpTools, OrderView   ← AI entry door
    │   ├── in/rest/            OrderController            ← human entry door
    │   └── out/memory/         InMemoryOrderRepository
    └── config/                 UseCaseConfig
</code></pre>
<hr />
<h2>🛠️ 1. Gradle: Kotlin DSL + version catalog</h2>
<p>All versions live in one place:</p>
<pre><code class="language-toml"># gradle/libs.versions.toml
[versions]
java = "25"
spring-boot = "4.1.2"
spring-ai = "2.0.2"
archunit = "1.5.0"

[libraries]
spring-ai-bom = { module = "org.springframework.ai:spring-ai-bom", version.ref = "spring-ai" }
spring-ai-mcp-server-webmvc = { module = "org.springframework.ai:spring-ai-starter-mcp-server-webmvc" }
spring-boot-starter-webmvc = { module = "org.springframework.boot:spring-boot-starter-webmvc" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }
micrometer-prometheus = { module = "io.micrometer:micrometer-registry-prometheus" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" }

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
</code></pre>
<pre><code class="language-kotlin">// build.gradle.kts
import org.springframework.boot.gradle.plugin.SpringBootPlugin

plugins {
    java
    alias(libs.plugins.spring.boot)
}

group = "dev.cbuelvasc"
version = "0.1.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(libs.versions.java.get())
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // BOMs as Gradle platforms (no dependency-management plugin needed)
    implementation(platform(SpringBootPlugin.BOM_COORDINATES))
    implementation(platform(libs.spring.ai.bom))

    // Driving adapters
    implementation(libs.spring.ai.mcp.server.webmvc)   // MCP (AI agents)
    implementation(libs.spring.boot.starter.webmvc)    // REST (humans)

    // Observability
    implementation(libs.spring.boot.starter.actuator)
    implementation(libs.micrometer.prometheus)

    testImplementation(platform(SpringBootPlugin.BOM_COORDINATES))
    testImplementation(libs.spring.boot.starter.test)
    testImplementation(libs.archunit.junit5)
    testRuntimeOnly(libs.junit.platform.launcher)
}

tasks.withType&lt;JavaCompile&gt;().configureEach {
    options.compilerArgs.add("-parameters")
}

tasks.test {
    useJUnitPlatform()
}
</code></pre>
<p>No <code>io.spring.dependency-management</code> plugin: the Spring Boot and Spring AI BOMs are imported as native Gradle <strong>platforms</strong>.</p>
<pre><code class="language-yaml"># application.yml
spring:
  application:
    name: orders-mcp
  ai:
    mcp:
      server:
        name: orders-mcp-server
        version: 0.1.0
        type: SYNC
        protocol: STREAMABLE   # SSE | STREAMABLE | STATELESS -&gt; endpoint: /mcp
  threads:
    virtual:
      enabled: true

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  metrics:
    tags:
      application: ${spring.application.name}
</code></pre>
<hr />
<h2>⬡ 2. The core: domain and ports</h2>
<p>Pure Java. No Spring, no MCP, no JPA. Value objects validate themselves, so a malformed id never reaches the repository:</p>
<pre><code class="language-java">/** Value object: an order id always has the format ORD-&amp;lt;digits&amp;gt;. */
public record OrderId(String value) {

    private static final Pattern FORMAT = Pattern.compile("ORD-\\d{1,10}");

    public OrderId {
        Objects.requireNonNull(value, "order id is required");
        value = value.strip().toUpperCase();
        if (!FORMAT.matcher(value).matches()) {
            throw new IllegalArgumentException("Invalid order id '%s'. Expected format ORD-&lt;digits&gt;".formatted(value));
        }
    }

    public static OrderId of(String value) {
        return new OrderId(value);
    }
}
</code></pre>
<pre><code class="language-java">// domain/port/in
/** Inbound port: driving adapters (MCP, REST) depend on this, never on the implementation. */
public interface FindOrderUseCase {

    /** @throws dev.cbuelvasc.orders.domain.exception.OrderNotFoundException if it does not exist */
    Order findById(OrderId id);
}

public interface ListRecentOrdersUseCase {

    int MAX_LIMIT = 20;

    /** Newest first. The limit is clamped to [1, MAX_LIMIT] by the core. */
    List&lt;Order&gt; listRecent(CustomerId customerId, int limit);
}

// domain/port/out
/** Outbound port: implemented by driven adapters (in-memory today, JPA tomorrow). */
public interface OrderRepositoryPort {

    Optional&lt;Order&gt; findById(OrderId id);

    List&lt;Order&gt; findRecentByCustomer(CustomerId customerId, int limit);
}
</code></pre>
<pre><code class="language-java">// application
/**
 * Application service. No Spring annotations: it is wired in infrastructure/config,
 * so the core stays framework-agnostic (verified by HexagonalArchitectureTest).
 */
public class OrderQueryService implements FindOrderUseCase, ListRecentOrdersUseCase {

    private final OrderRepositoryPort repository;

    public OrderQueryService(OrderRepositoryPort repository) {
        this.repository = Objects.requireNonNull(repository);
    }

    @Override
    public Order findById(OrderId id) {
        return repository.findById(id).orElseThrow(() -&gt; new OrderNotFoundException(id));
    }

    @Override
    public List&lt;Order&gt; listRecent(CustomerId customerId, int limit) {
        // Business rule lives in the core, so it protects EVERY adapter (REST, MCP, ...)
        int safeLimit = Math.clamp(limit, 1, MAX_LIMIT);
        return repository.findRecentByCustomer(customerId, safeLimit);
    }
}
</code></pre>
<hr />
<h2>🤖 3. The MCP adapter with <code>@McpTool</code></h2>
<p>Spring AI 2.0 brings annotation-based MCP. This is the <strong>only</strong> class that knows about MCP, and it depends on ports, not on the service:</p>
<pre><code class="language-java">/**
 * Driving adapter for AI agents. This is the ONLY class that knows about MCP.
 * It depends on inbound ports, never on the application service or the repository.
 */
@Component
public class OrderMcpTools {

    private final FindOrderUseCase findOrder;
    private final ListRecentOrdersUseCase listRecentOrders;
    private final MeterRegistry meterRegistry;

    public OrderMcpTools(FindOrderUseCase findOrder,
                         ListRecentOrdersUseCase listRecentOrders,
                         MeterRegistry meterRegistry) {
        this.findOrder = findOrder;
        this.listRecentOrders = listRecentOrders;
        this.meterRegistry = meterRegistry;
    }

    @McpTool(name = "findOrder", description = """
            Returns status, total amount and last update of ONE order.
            Use it when the user mentions a specific order id like ORD-1042.
            """)
    public OrderView findOrder(
            @McpToolParam(description = "Order id, format ORD-&lt;digits&gt;, e.g. ORD-1042", required = true)
            String orderId) {
        return observe("findOrder", () -&gt; OrderView.from(findOrder.findById(OrderId.of(orderId))));
    }

    @McpTool(name = "listRecentOrders", description = """
            Lists the most recent orders of ONE customer, newest first.
            Use it when the user asks about the latest orders of a customer.
            """)
    public List&lt;OrderView&gt; listRecentOrders(
            @McpToolParam(description = "Customer id, format CUS-&lt;digits&gt;, e.g. CUS-7", required = true)
            String customerId,
            @McpToolParam(description = "How many orders to return, from 1 to 20", required = false)
            Integer limit) {
        int requested = limit == null ? 5 : limit;
        return observe("listRecentOrders", () -&gt; listRecentOrders
                .listRecent(CustomerId.of(customerId), requested)
                .stream()
                .map(OrderView::from)
                .toList());
    }

    /** Timer + error counter per tool -&gt; Prometheus -&gt; Grafana. */
    private &lt;T&gt; T observe(String tool, Supplier&lt;T&gt; call) {
        try {
            return meterRegistry.timer("mcp.tool.duration", "tool", tool).record(call);
        } catch (RuntimeException e) {
            meterRegistry.counter("mcp.tool.errors", "tool", tool,
                    "exception", e.getClass().getSimpleName()).increment();
            throw e; // Spring AI turns it into an MCP error result the model can read
        }
    }
}
</code></pre>
<pre><code class="language-java">/**
 * What the model sees. The adapter translates the domain into a small,
 * model-friendly shape: only the fields it needs, nothing sensitive.
 */
public record OrderView(String orderId,
                        String status,
                        boolean isFinal,
                        BigDecimal total,
                        String currency,
                        Instant updatedAt) {

    static OrderView from(Order order) {
        return new OrderView(
                order.id().value(),
                order.status().name(),
                order.status().isFinal(),
                order.total(),
                order.currency(),
                order.updatedAt());
    }
}
</code></pre>
<p>The adapter <strong>translates</strong> the domain into a small, model-friendly shape. No <code>ToolCallbackProvider</code> boilerplate: the <code>@McpTool</code> methods are discovered automatically. The core is wired in one place:</p>
<pre><code class="language-java">/** Wires the framework-agnostic core into Spring. */
@Configuration(proxyBeanMethods = false)
class UseCaseConfig {

    @Bean
    OrderQueryService orderQueryService(OrderRepositoryPort orderRepository) {
        return new OrderQueryService(orderRepository);
    }
}
</code></pre>
<hr />
<h2>🧪 4. Guarding the hexagon with ArchUnit</h2>
<p>Hexagonal only works if the rules hold. Let the build enforce them:</p>
<pre><code class="language-java">/** The hexagon is only real if a test enforces it. */
@AnalyzeClasses(packages = "dev.cbuelvasc.orders", importOptions = ImportOption.DoNotIncludeTests.class)
class HexagonalArchitectureTest {

    @ArchTest
    static final ArchRule domainIsPureJava = noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat().resideInAnyPackage(
                    "org.springframework..", "jakarta..", "io.micrometer..", "io.modelcontextprotocol..");

    @ArchTest
    static final ArchRule applicationDoesNotDependOnFrameworks = noClasses()
            .that().resideInAPackage("..application..")
            .should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta..");

    @ArchTest
    static final ArchRule coreDoesNotKnowInfrastructure = noClasses()
            .that().resideInAnyPackage("..domain..", "..application..")
            .should().dependOnClassesThat().resideInAPackage("..infrastructure..");

    @ArchTest
    static final ArchRule onlyTheMcpAdapterKnowsMcp = noClasses()
            .that().resideOutsideOfPackage("..adapter.in.mcp..")
            .should().dependOnClassesThat().resideInAnyPackage(
                    "org.springframework.ai..", "io.modelcontextprotocol..");

    @ArchTest
    static final ArchRule adaptersTalkToPortsNotToTheService = noClasses()
            .that().resideInAPackage("..adapter.in..")
            .should().dependOnClassesThat().resideInAPackage("..application..");
}
</code></pre>
<hr />
<h2>🔐 5. Production-ready: defense in depth</h2>
<img src="https://mermaid.ink/img/pako:eNpNkstu2zAQRX9lwGxa1A-JkuLWMAw0QBdGXLg1iiJA1QUtjmy2FCmQVIIgyL93TD1SLrTQ3Ht4Z4YvrLIS2RpYre1TdREuwI-70gCd45fvv0pWdvJjhvSV6S0Ea7VfVkJrqJ1tQJzRhJL9hvl8C_u09-1TsqVQdklyWsHhcxcuwBfp5uSW2zsUDh1x_qKBD-Ar26KPlY3aWifR-bVDITdLtZ24fOBy4vKRexQBQatGBWXOkRDDeIjplIFT53zwEyQbIBlBshGyM20X4FFoJUVQ1kTO4RpjJ6-_OwR7-oNVmEGlRdO-ixe-n6D5AM0Jmo_QPQofoHXqUWk8Y2Ree5pbo5_7EUKtKNyEKQZMQZhixHxVRjVCg-0ChXxL9lPh0wyMhW-73Ug43MdN8VVSwBF9a41HugkaWq4mUWnG1cB8AZQ1SZd5kpUMFuR-6M23MifztVuU0dMPfXDwT6N6HGRfUCaO763Yl3141ggP1KjW65sakSOf-eBo8esbrHM6_wsP94NSVnWFq0nJeVUUyGbAGnSNUJKe6gsLF2zio5VYi04H9vr6D71_2LQ?type=png&amp;bgColor=white&amp;width=1600" alt="Defense in depth for MCP tool calls" style="display:block;margin:0 auto" />

<p>Key ideas:</p>
<ul>
<li>🔑 <strong>OAuth 2.1</strong> is what the MCP spec recommends for HTTP transports (next step in the repo roadmap).</li>
<li>🧨 <strong>Treat every tool argument as untrusted.</strong> Prompt injection is real. Value objects like <code>OrderId</code> give you validation for free.</li>
<li>✋ <strong>Write tools</strong> (refunds, cancellations) need dedicated scopes and human confirmation in the client.</li>
</ul>
<hr />
<h2>📊 6. Observability and load testing</h2>
<img src="https://mermaid.ink/img/pako:eNo9kE9LAzEQxb_KkLO1VXGtRYq3HjRQZG_Gw-wka4P5s8wmSCn97o6RGphAMm9-7zEnRdk6tQE1hvxNB-QCr28mgZz-3ahnTVOfcwDCEIz6gMViC1pLR3viHF1x_DTwclt8dAyTVBG5KP8YWreJvQzsm_zg6nzh7OTXVLu-s3ITrWHHOGLCBpwe7yFgcYmOYOpqNTyAY848X16_ieZl9Onf7KVrwNsOb-Cra5Sh8lyOgJ8uFSiCHz01-2vx79UVKIkd0VtZwUlJutiWYd2INRR1Pv8AEzVb4g?type=png&amp;bgColor=white&amp;width=1600" alt="Observability pipeline: Micrometer, Prometheus, Grafana and k6" style="display:block;margin:0 auto" />

<p>Every tool call records <code>mcp.tool.duration</code> and <code>mcp.tool.errors</code>, tagged by tool, exposed on <code>/actuator/prometheus</code>. The repo includes a <strong>k6</strong> script that ramps to 200 tool calls per second:</p>
<pre><code class="language-bash">k6 run k6/mcp-load-test.js
</code></pre>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>p95 / p99 latency</td>
<td>Agents chain calls, so latency adds up</td>
</tr>
<tr>
<td>Error rate by tool</td>
<td>Bad descriptions show up as bad calls</td>
</tr>
<tr>
<td>Calls per conversation</td>
<td>Detects agents stuck in loops</td>
</tr>
</tbody></table>
<hr />
<h2>💡 Lessons learned</h2>
<img src="https://mermaid.ink/img/pako:eNpVj8FKQzEQRX9lmHVLFTciUnjaLgQX0i59LsaZhAaSjEwSREr_3ZDyQGdxd2fuuWdkFYcPgD7qN5_IKrwe5gz9pvcZ5yb3d9KTRWDnClv4qkFzefy0zXY67KGeHExvLzN-wHq9hacF4p6ePBwTxbgCr9yKk4FV1VgW4PlfCzHszdTK-Ju6WxwIU4aWxVmplGVhd39Zub2Bo-Nmof5AuApmraBDmGKHcAWYnCUK0iefsXekMV6cpxYrXi6_zPJaLQ?type=png&amp;bgColor=white&amp;width=1600" alt="Lessons learned" style="display:block;margin:0 auto" />

<ol>
<li><strong>Descriptions are the API.</strong> Vague descriptions lead to wrong tool calls.</li>
<li><code>findOrder</code> <strong>beats</strong> <code>queryDatabase</code><strong>.</strong> Small, focused tools are safer and easier for the model.</li>
<li><strong>Readable errors</strong> like <code>Order not found: ORD-1042</code> let the agent recover.</li>
<li><strong>Hexagonal pays off.</strong> Adding MCP meant <strong>one new adapter</strong>. The domain didn't change at all.</li>
</ol>
<hr />
<h2>🚀 What's next</h2>
<p>Next post: <strong>the same hexagonal MCP server in Go</strong> with the official MCP Go SDK, with a side-by-side comparison of developer experience, memory footprint and latency under k6.</p>
<p>⭐ Code: <a href="https://github.com/cbuelvasc/orders-mcp">github.com/cbuelvasc/orders-mcp</a></p>
<hr />
<p>👋 I'm <strong>Carmelo Buelvas</strong>, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on <a href="https://www.linkedin.com/in/cbuelvasc/">LinkedIn</a>.</p>
]]></content:encoded></item></channel></rss>