Series overview
Part 9 of 1753% complete
2026-08-28•4 min read

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

agent-api/src/main/resources/application.yml (additions)
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: 12s

ToolPolicy and the registry — this table is the security model, in code:

agent-api/src/main/java/in/o612/eng/opsagent/agent/tools/ToolPolicy.java
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 }
}
agent-api/src/main/java/in/o612/eng/opsagent/agent/tools/ToolPolicyRegistry.java
package in.o612.eng.opsagent.agent.tools;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
@Component
public 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:

agent-api/src/main/java/in/o612/eng/opsagent/agent/tools/McpToolExecutor.java
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.*;
@Component
public 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:

agent-api/src/main/java/in/o612/eng/opsagent/agent/orchestration/AgentOrchestrator.java
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;
@Service
public 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.
  • AgentOrchestratorTest with a scripted ChatModel: (a) direct answer → 1 model call, 0 tool calls; (b) model proposes get_service_status → executor invoked, result appended, second model call returns the answer; (c) model proposes create_incident → APPROVAL_REQUIRED result, zero executions; (d) model proposes drop_table → UNKNOWN_TOOL; (e) model proposes tools forever → TOOL_BUDGET_EXCEEDED at exactly the cap.
  • TenantInjectionTest: model supplies {"tenant":"globex"} for an acme caller → the executor’s args map contains acme. The model cannot pick the tenant — this test is the boundary.
  • McpRoundTripIT (simulator + MCP server + Postgres containers): real tools/call over Streamable HTTP returns seeded status.

Failure-injection lab

  1. MCP server down: callTool fails → {"error":"TOOL_FAILED"} into the model context → the stub/model answers “status unavailable” instead of inventing one.
  2. Simulator latency 13000 vs tool timeout 12s: TOOL_TIMEOUT — the deadline is ours.
  3. Scripted model emitting a tool call per step forever: loop exits at max-tool-calls with a Failed event — 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_status under ops:read.
  • create_incident proposals produce APPROVAL_REQUIRED with 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 loop

git 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: AgentOrchestrator hand-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: ToolProposed published per call; Failed on budget/deadline
  • Next: chapter-09-oauth-security
JavaSpring BootAI

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind