Connect the agent to remote MCP tools: policy registry and bounded loop
Checkpoint tag: chapter-08-mcp-client — a natural-language question can flow through the model into get_service_status/list_recent_incidents on the remote server, under policy, inside hard budget limits.
What will be built
agent-api gains its tool path: an MCP client connection to mcp-operations-server, a ToolPolicyRegistry describing every tool’s risk class/scope/timeout/approval requirement, an AgentOrchestrator implementing the bounded loop by hand (model proposes → policy disposes → executor runs → result appended → repeat, with hard caps), and ToolProposed/Completed events on the SSE channel.
Why it matters
Spring AI can wire MCP tool callbacks straight into ChatClient and let the framework loop automatically. We deliberately do not — AdvisorParams.toolCallingAdvisorAutoRegister(false) — because the loop is where the safety requirements live: maximum steps, per-call deadlines, scope checks, approval suspension, and audit events. An advisor-managed loop is convenient and opaque; a hand-rolled loop is thirty lines you can put breakpoints in. Convenience is for prototypes; loops that can call real systems get written where you can see them.
Concepts explained
Proposal vs. execution. The model’s output is a request: {tool: "get_service_status", args: {...}}. Nothing executes because the model said so. The orchestrator validates the tool name against the registry (unknown tools are dropped, not executed), checks the caller’s scopes, injects the tenant itself — the model never chooses which tenant’s data a call reads — and only then calls McpSyncClient.callTool.
Budget dimensions. Three independent caps: max-model-calls (loop iterations), max-tool-calls, and a wall-clock deadline. Any one tripping ends the loop with a typed Failed event — “budget exhausted” is an answer, not a hang.
Structured tool errors are data. Chapter 7’s {"error": "UPSTREAM_UNAVAILABLE"} payloads flow back into the model’s context as tool results. The model can say “status check failed” instead of hallucinating a status. Whether it should retry is the registry’s decision, not the model’s.
Files added or changed
agent-api/src/main/resources/application.yml (mcp client + loop budgets)agent-api/build.gradle.kts (+ spring-ai-starter-mcp-client)agent-api/src/main/java/in/o612/eng/opsagent/agent/ tools/ToolPolicy.java, tools/ToolPolicyRegistry.java, tools/McpToolExecutor.java orchestration/AgentOrchestrator.java, orchestration/AgentBudget.java conversation/ConversationService.java (route through orchestrator)agent-api/src/test/java/... (policy, loop-bound, tool-selection tests)Complete code
spring: ai: mcp: client: enabled: true type: SYNC toolcallback: enabled: false # we do NOT auto-register tool callbacks streamable-http: connections: ops: url: ${MCP_SERVER_URL:http://localhost:8081} endpoint: /mcp
agent: loop: max-model-calls: 8 max-tool-calls: 10 deadline: 45s tools: connect-timeout: 2s per-call-timeout: 12sToolPolicy and the registry — this table is the security model, in code:
package in.o612.eng.opsagent.agent.tools;
import java.time.Duration;
public record ToolPolicy( String toolName, Risk risk, String requiredScope, // null = any authenticated caller Duration perCallTimeout, boolean retryable, boolean requiresApproval, AuditLevel auditLevel) {
public enum Risk { LOW, MEDIUM, HIGH } public enum AuditLevel { STANDARD, WRITE }}package in.o612.eng.opsagent.agent.tools;
import org.springframework.stereotype.Component;
import java.time.Duration;import java.util.Map;import java.util.Optional;
@Componentpublic class ToolPolicyRegistry {
private final Map<String, ToolPolicy> policies = Map.of( "get_service_status", new ToolPolicy("get_service_status", ToolPolicy.Risk.LOW, "ops:read", Duration.ofSeconds(10), true, false, ToolPolicy.AuditLevel.STANDARD), "list_recent_incidents", new ToolPolicy("list_recent_incidents", ToolPolicy.Risk.LOW, "ops:read", Duration.ofSeconds(10), true, false, ToolPolicy.AuditLevel.STANDARD), "get_incident", new ToolPolicy("get_incident", ToolPolicy.Risk.LOW, "ops:read", Duration.ofSeconds(10), true, false, ToolPolicy.AuditLevel.STANDARD), // write tools arrive in Chapter 10 — declared here so the policy // surface is visible even before they exist: "create_incident", new ToolPolicy("create_incident", ToolPolicy.Risk.HIGH, "ops:incident:write", Duration.ofSeconds(15), false, true, ToolPolicy.AuditLevel.WRITE), "append_incident_note", new ToolPolicy("append_incident_note", ToolPolicy.Risk.MEDIUM, "ops:note:write", Duration.ofSeconds(15), false, true, ToolPolicy.AuditLevel.WRITE) );
public Optional<ToolPolicy> find(String tool) { return Optional.ofNullable(policies.get(tool)); }}McpToolExecutor — timeout and tenant injection live here:
package in.o612.eng.opsagent.agent.tools;
import com.fasterxml.jackson.databind.ObjectMapper;import io.modelcontextprotocol.client.McpSyncClient;import io.modelcontextprotocol.spec.McpSchema;import org.springframework.stereotype.Component;
import java.time.Duration;import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.concurrent.*;
@Componentpublic class McpToolExecutor {
private final List<McpSyncClient> clients; // auto-configured by the MCP client starter private final ObjectMapper mapper = new ObjectMapper(); private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
public McpToolExecutor(List<McpSyncClient> clients) { this.clients = clients; }
public String execute(String tool, Map<String, Object> args, String tenantId, Duration timeout) { var merged = new HashMap<String, Object>(args); merged.put("tenant", tenantId); // tenant is OURS, not the model's try { var future = pool.submit(() -> clients.get(0).callTool(new McpSchema.CallToolRequest(tool, merged))); var result = future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); return mapper.writeValueAsString(result.content()); } catch (TimeoutException e) { return "{\"error\":\"TOOL_TIMEOUT\"}"; } catch (ExecutionException e) { return "{\"error\":\"TOOL_FAILED\"}"; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return "{\"error\":\"TOOL_INTERRUPTED\"}"; } catch (Exception e) { return "{\"error\":\"TOOL_CALL_INVALID\"}"; } }}The virtual-thread executor matters: McpSyncClient.callTool is blocking; future.get(timeout) is how a blocking call gets a deadline. The timeout is the policy’s, not the SDK default’s.
AgentOrchestrator — the loop:
package in.o612.eng.opsagent.agent.orchestration;
import in.o612.eng.opsagent.agent.model.ChatClientGateway;import in.o612.eng.opsagent.agent.tools.McpToolExecutor;import in.o612.eng.opsagent.agent.tools.ToolPolicy;import in.o612.eng.opsagent.agent.tools.ToolPolicyRegistry;import in.o612.eng.opsagent.contracts.conversation.AgentEvent;import in.o612.eng.opsagent.agent.conversation.ConversationEventBus;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.messages.AssistantMessage;import org.springframework.ai.chat.messages.Message;import org.springframework.ai.chat.messages.ToolResponseMessage;import org.springframework.ai.chat.messages.UserMessage;import org.springframework.ai.chat.prompt.Prompt;import org.springframework.stereotype.Service;
import java.time.Duration;import java.time.Instant;import java.util.ArrayList;import java.util.List;import java.util.Map;
@Servicepublic class AgentOrchestrator {
private final ChatClient chatClient; private final ToolPolicyRegistry policies; private final McpToolExecutor executor; private final ConversationEventBus events; private final AgentBudget budget = new AgentBudget(8, 10, Duration.ofSeconds(45));
public record Outcome(String answer, int modelCalls, int toolCalls) {}
public Outcome run(String conversationId, String tenantId, Set<String> callerScopes, String systemPrompt, String userMessage) { var messages = new ArrayList<Message>(); messages.add(new UserMessage(userMessage)); var started = Instant.now(); int toolCalls = 0;
for (int modelCalls = 1; modelCalls <= budget.maxModelCalls(); modelCalls++) { if (Duration.between(started, Instant.now()).compareTo(budget.deadline()) > 0) { return failed(conversationId, "DEADLINE_EXCEEDED"); } var response = chatClient.prompt() .system(systemPrompt) .messages(messages) .call() .chatResponse();
var output = response.getResult().getOutput(); var calls = output.getToolCalls(); if (calls == null || calls.isEmpty()) { return new Outcome(output.getText(), modelCalls, toolCalls); }
var toolResponses = new ArrayList<ToolResponseMessage.ToolResponse>(); for (var call : calls) { if (++toolCalls > budget.maxToolCalls()) { return failed(conversationId, "TOOL_BUDGET_EXCEEDED"); } events.publish(conversationId, new AgentEvent.ToolProposed( conversationId, call.name(), call.arguments(), Instant.now()));
toolResponses.add(new ToolResponseMessage.ToolResponse( call.id(), call.name(), dispatch(call, tenantId, callerScopes))); } messages.add(new AssistantMessage(output.getText(), Map.of(), calls)); messages.add(new ToolResponseMessage(toolResponses)); } return failed(conversationId, "MODEL_BUDGET_EXCEEDED"); }
private String dispatch(AssistantMessage.ToolCall call, String tenantId, Set<String> scopes) { var policy = policies.find(call.name()); if (policy.isEmpty()) { return "{\"error\":\"UNKNOWN_TOOL\"}"; } var p = policy.get(); if (p.requiredScope() != null && !scopes.contains(p.requiredScope())) { return "{\"error\":\"INSUFFICIENT_SCOPE\"}"; } if (p.requiresApproval()) { // Chapter 10 replaces this with the real approval flow return "{\"error\":\"APPROVAL_REQUIRED\"}"; } Map<String, Object> args = parseArgs(call.arguments()); return executor.execute(call.name(), args, tenantId, p.perCallTimeout()); }
// failed(...) publishes AgentEvent.Failed and returns an Outcome with a safe message; // parseArgs(...) maps the tool-call JSON arguments string to Map<String,Object>}AgentBudget is a small record carrying the three caps; making it a type (rather than three ints in a field) is what lets Chapter 11 vary budgets per caller tier without touching the loop.
ConversationService.handleMessageSync now routes through orchestrator.run(...) instead of calling the model once; the RAG context from Chapter 5 still gets prepended to the user message, so a question can cite runbooks and tools in one answer.
Automated tests
PolicyRegistryTest: every registered tool returns a policy; unknown tools deny by default.AgentOrchestratorTestwith a scriptedChatModel: (a) direct answer → 1 model call, 0 tool calls; (b) model proposesget_service_status→ executor invoked, result appended, second model call returns the answer; (c) model proposescreate_incident→APPROVAL_REQUIREDresult, zero executions; (d) model proposesdrop_table→UNKNOWN_TOOL; (e) model proposes tools forever →TOOL_BUDGET_EXCEEDEDat exactly the cap.TenantInjectionTest: model supplies{"tenant":"globex"}for anacmecaller → the executor’s args map containsacme. The model cannot pick the tenant — this test is the boundary.McpRoundTripIT(simulator + MCP server + Postgres containers): realtools/callover Streamable HTTP returns seeded status.
Failure-injection lab
- MCP server down:
callToolfails →{"error":"TOOL_FAILED"}into the model context → the stub/model answers “status unavailable” instead of inventing one. - Simulator
latency 13000vs tool timeout 12s:TOOL_TIMEOUT— the deadline is ours. - Scripted model emitting a tool call per step forever: loop exits at
max-tool-callswith aFailedevent — watch it on SSE, don’t just assert it.
Security considerations
Deny-by-default: an unlisted tool name is a denial, not an error worth retrying. Scope checks happen in dispatch even though Chapter 9 hasn’t introduced real scopes yet — callerScopes today is a header-derived set, and the shape of enforcement is already right. Argument parsing is a Jackson Map read, never string interpolation into the downstream call.
Observability checks
agent.loop.steps histogram, agent.tool.calls labeled tool+outcome (ok|denied|timeout|failed), agent.model.calls — the three numbers that distinguish “agent thought hard” from “agent looped out of control.”
Checkpoint verification checklist
- A real MCP round trip executes for
get_service_statusunderops:read. -
create_incidentproposals produceAPPROVAL_REQUIREDwith no execution. - Budget caps fire at exactly their configured values.
- Model-supplied tenant arguments are overwritten by caller context.
Commit message and Git tag
feat(agent-api): MCP client with policy-gated bounded agent loopgit tag chapter-08-mcp-client
What comes next
Chapter 9 replaces the trusted headers with real OIDC: Keycloak, JWT validation on both boundaries, and the negative security matrix.
Project State Ledger — chapter-08-mcp-client
- MCP client:
spring-ai-starter-mcp-client,streamable-http.connections.ops.url, SYNC, toolcallback auto-register OFF - Orchestration:
AgentOrchestratorhand-rolled loop; budgets 8 model calls / 10 tool calls / 45s - Policy:
ToolPolicyRegistry— risk, scope, timeout, retryable, approval, audit per tool; writes pre-declared - Executor:
McpToolExecutor— virtual-thread pool,future.get(timeout), tenant injected server-side - Events:
ToolProposedpublished per call;Failedon budget/deadline - Next:
chapter-09-oauth-security