Most AI agent projects do not fail because the model is not smart enough. They fail because the demo was never an architecture.
A prototype that calls one LLM with one prompt looks great in a screen recording. Then you add a second tool, a real user base, and a finance team that reads the invoice — and the whole thing falls over. Latency spikes. Token spend triples. Nobody can explain why the agent did what it did last Tuesday.
I have spent the last year building agentic features on top of Spring Boot backends and Next.js frontends. This post is the architecture I keep coming back to in 2026, why the ecosystem shifted underneath us, and the specific decisions that separate a demo from something you can bill customers for.
What Actually Changed in 2026
Three shifts matter for anyone building this stack right now.
MCP stopped being a novelty. The Model Context Protocol went from "interesting Anthropic spec" to the default integration layer for tool access. Reporting on enterprise adoption puts roughly 28% of Fortune 500 companies running MCP somewhere in their stack inside eighteen months, and the 2026 roadmap is explicitly about production readiness: stateless transports, horizontal scaling, and capability discovery without a live connection.
Spring caught up fast. Spring AI 2.0-M6 landed annotation-based MCP APIs — @McpTool, @McpToolParam, McpSyncRequestContext — directly in core. If you already run a Spring Boot backend, exposing your existing service layer to an agent is now a matter of annotations rather than a bespoke integration.
The frontend got cheaper to run. Next.js 16 made Turbopack the default for dev and production builds, and the React team shipped Server Components payload deserialization that is reportedly up to 350% faster. Streaming agent output — the single most latency-sensitive UI pattern there is — got materially better without any change on your side.
The through-line: the plumbing is now standard. Which means the differentiator moved from "can you wire an LLM to a database" to "can you run it reliably and afford it."
The Architecture
Here is the shape I use for production agentic features:
Next.js 16 (App Router)
│ streamed UI, View Transitions, RSC
▼
Next.js Route Handler ── thin edge: auth, rate limit, SSE passthrough
│
▼
Spring Boot Orchestrator ── the actual brain
├── Model client (provider-agnostic)
├── MCP client → internal MCP servers (orders, billing, CRM)
├── Redis → session state, tool-result store, quotas
└── Trace sink → every step, every token, every dollar
The important boundary is the one people skip: the Next.js layer holds no agent logic. It authenticates, enforces a per-user budget, and pipes bytes. Every decision the agent makes lives in a backend you can test, trace, and deploy independently of your marketing site.
Exposing Your Domain as MCP Tools
The old way was hand-rolling a JSON schema for every function and keeping it in sync with your service methods by hope. Spring AI's annotation API generates the schema from the method signature.
@Service
public class OrderTools {
private final OrderRepository orders;
public OrderTools(OrderRepository orders) {
this.orders = orders;
}
@McpTool(
name = "get_order_status",
description = "Look up the current fulfilment status of a customer order."
)
public OrderStatus getOrderStatus(
@McpToolParam(description = "The order id, e.g. ORD-10432")
String orderId,
McpSyncRequestContext context
) {
String tenantId = context.request().meta().get("tenantId").toString();
return orders.findByIdAndTenant(orderId, tenantId)
.map(OrderStatus::from)
.orElseThrow(() -> new ToolExecutionException(
"No order %s found for this account".formatted(orderId)
));
}
}
Three things in that snippet are doing real work.
The description strings are prompt engineering, not documentation. The model picks tools based on them. "Look up the current fulfilment status of a customer order" gets called correctly; "Order endpoint" does not.
The tenant comes from request context, never from a model argument. This is the single most important security rule in agentic backends. If the LLM can pass you a tenant id, the LLM can be talked into passing you someone else's. Identity is ambient; parameters are untrusted input.
The error message is written for the model to read. A raw NoSuchElementException produces a stack trace the agent cannot recover from. A sentence produces a retry or a sensible message to the user.
Wire it up with the starter:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
spring:
ai:
mcp:
server:
name: orders-mcp
version: 1.4.0
protocol: STREAMABLE
request-timeout: 20s
Note STREAMABLE. The SSE transport has been superseded by Streamable HTTP, and major MCP clients published SSE sunset dates through mid-2026. If you are starting today, do not build on SSE transport for MCP — you will be migrating within a quarter. (This is separate from using SSE to stream tokens to your own browser client, which is still perfectly fine.)
Validate Every Tool Parameter Like It Came From the Internet
Because it did. The parameters an agent passes to your tools are model output, which means they are shaped by whatever the user typed.
The NSA published MCP security guidance in May 2026, and the recurring theme is unglamorous: input validation and least privilege at the tool boundary.
@McpTool(name = "issue_refund", description = "Refund a paid order, partially or in full.")
public RefundResult issueRefund(
@McpToolParam(description = "The order id") String orderId,
@McpToolParam(description = "Amount in minor units") long amountMinor,
McpSyncRequestContext context
) {
Order order = requireOwnedOrder(orderId, context);
if (amountMinor <= 0 || amountMinor > order.paidMinor()) {
throw new ToolExecutionException(
"Refund amount must be between 1 and %d".formatted(order.paidMinor())
);
}
// Anything that moves money gets a human in the loop.
if (amountMinor > HUMAN_APPROVAL_THRESHOLD) {
return RefundResult.pendingApproval(
approvals.open(order, amountMinor, context.sessionId())
);
}
return payments.refund(order, amountMinor);
}
That last branch is the pattern MCP calls elicitation, and it is what makes agents deployable in regulated contexts. The agent is allowed to propose the refund. A person approves it. You get the automation benefit without betting the company on a model's judgement.
A rule that has served me well: tools that read can be autonomous, tools that write need a budget, tools that move money need a human.
Streaming to the Frontend
The Next.js side stays deliberately boring — auth, quota, passthrough.
// app/api/agent/route.js
import { after } from "next/server";
import { auth } from "@/lib/auth";
import { checkBudget, recordSpend } from "@/lib/budget";
export async function POST(req) {
const session = await auth();
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const budget = await checkBudget(session.userId);
if (!budget.allowed) {
return Response.json(
{ error: "Daily AI budget reached", resetsAt: budget.resetsAt },
{ status: 429 }
);
}
const { messages, sessionId } = await req.json();
const upstream = await fetch(`${process.env.ORCHESTRATOR_URL}/agent/stream`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${await session.serviceToken()}`,
},
body: JSON.stringify({ messages, sessionId, userId: session.userId }),
});
if (!upstream.ok) {
return Response.json({ error: "Agent unavailable" }, { status: 502 });
}
// Reconcile spend after the response is flushed, not in the hot path.
after(() => recordSpend(session.userId, sessionId));
return new Response(upstream.body, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
}
Two details worth stealing. The budget check happens before the upstream call, so a runaway user costs you one Redis read instead of a full agent run. And spend reconciliation runs in after() so it never adds latency to the first token.
On the client, the thing that actually determines perceived quality is how you render tool activity. Users tolerate a slow agent; they do not tolerate a frozen one.
"use client";
import { useState } from "react";
export default function AgentStream({ sessionId }) {
const [text, setText] = useState("");
const [activity, setActivity] = useState([]);
async function send(messages) {
const res = await fetch("/api/agent", {
method: "POST",
body: JSON.stringify({ messages, sessionId }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n\n")) {
if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5));
if (event.type === "token") {
setText((prev) => prev + event.value);
} else if (event.type === "tool_start") {
setActivity((prev) => [...prev, { tool: event.name, state: "running" }]);
} else if (event.type === "tool_end") {
setActivity((prev) =>
prev.map((item) =>
item.tool === event.name ? { ...item, state: "done" } : item
)
);
}
}
}
}
return (
<div>
<ActivityRail items={activity} />
<Markdown>{text}</Markdown>
</div>
);
}
Emitting tool_start and tool_end as distinct events costs almost nothing on the backend and transforms the experience. "Checking your order history…" tells the user the system is working. A spinner tells them nothing.
The Costs Nobody Budgets For
This is where most agent projects quietly die. There are five line items between your demo and production, and teams typically plan for one of them.
| Cost | Why it surprises teams |
|---|---|
| Tokens | Agents loop. One user turn can be 6–10 model calls. |
| Data | Retrieval, embeddings, and the storage to keep them fresh. |
| Evals | Running a real benchmark suite is itself a large inference bill. |
| Guardrails | Every safety check is another model call in the latency budget. |
| Observability | Trace volume scales with steps, not with requests. |
The eval number is the one that shocks people. The Holistic Agent Leaderboard reported roughly $40,000 to run 21,730 rollouts across nine models and nine benchmarks. That is the cost of knowing whether your agent works.
And more spend does not buy accuracy. On the Online Mind2Web benchmark, one browser agent configuration cost $1,577 to reach 40% accuracy while a different framework hit 42% for $171 — a 9x cost difference in favour of the better result. Framework and prompt design dominate model choice more than the marketing suggests.
The practical consequence: instrument cost per session from day one.
{
"traceId": "9f2c...",
"sessionId": "sess_8812",
"userId": "usr_441",
"step": "tool.call",
"tool": "get_order_status",
"model": "claude-sonnet-5",
"tokens": { "prompt": 2140, "completion": 96 },
"latencyMs": 640,
"costUsd": 0.0071,
"outcome": "ok"
}
Emit one of these per step, not per request. Then a single query answers the question your CFO will eventually ask: which feature, which customer, which tool is burning the money. Without per-step traces you are guessing, and guessing at 3am during an incident is how teams end up switching off the feature entirely.
Evals Before Guardrails, Guardrails From Evals
The pattern that has emerged across the tooling ecosystem is a layered strategy, and the layers are cheap-to-expensive in exactly the order you should run them:
- Unit tests on tool schemas and deterministic transforms. Milliseconds, no inference, run on every commit.
- Scenario evals that replay real user journeys end to end. Minutes and real tokens — run on every merge to main.
- Production monitoring on live traces to catch drift. Continuous, sampled.
The leverage comes from connecting the first two to the third. An eval that scores "did the agent leak another tenant's data" is a test before release and a runtime guardrail after it — same scoring function, different execution context. Teams that maintain those separately end up with guardrails that drift out of sync with what they actually test.
Keep runtime guardrails inside a latency budget. Anything above roughly 200ms of blocking checks is felt by the user, so run cheap deterministic checks inline (regex, schema, tenant assertions) and expensive model-graded checks asynchronously on a sample of traffic.
A Short Checklist
If you are taking an agent from demo to production, this is what I would verify before shipping:
- Tenant and user identity come from the request context, never from tool parameters.
- Every tool validates its inputs and returns errors as sentences the model can act on.
- Money-moving and destructive tools require human approval above a threshold.
- Per-step traces record tokens, latency, and cost with a session and user id attached.
- A per-user budget is enforced before the model is called, not after.
- Streamable HTTP for MCP transport, not the deprecated SSE transport.
- Tool activity is surfaced in the UI as discrete events.
- You are on patched Next.js and React versions — twelve vulnerabilities were disclosed and fixed on May 6, 2026, and RSC-era apps have a wider server surface than SPAs did.
- A scenario eval suite runs in CI, and at least one of its scoring functions also runs as a production guardrail.
The Real Takeaway
The interesting engineering in 2026 is not prompt engineering. It is the same engineering it has always been: clear boundaries, untrusted input treated as untrusted, observability that answers questions you have not thought of yet, and a cost model you understand before the invoice arrives.
MCP gave us a standard integration layer. Spring AI made exposing an existing domain a matter of annotations. Next.js made streaming cheap. All of that removes accidental complexity — which just means the essential complexity is now the whole job.
Build the boring parts well and the agent takes care of itself.
Building an AI feature that needs to survive contact with real users? I work with startups and product teams on exactly this — Spring Boot orchestration, MCP tool layers, and Next.js frontends that stream. Tell me what you are building.