An agent with three tools does not need a registry. An organisation with forty tools, six agents, two MCP servers and a compliance team does. Someone has to know which tools exist, who owns each one, which agents may see it, how risky it is, and what happens when it is renamed. In ADK Java none of that is built in, because that is not the framework's job: it puts tools in front of a model and runs the calls that come back. The registry is the application layer you build on top.
This page designs that layer for Google's Agent Development Kit for Java. It starts with what the framework actually does with tool names, read from the google-adk 1.10.1 jar, because two of those behaviours decide the design. Then it builds a catalog of tool specifications, validates it at startup, exposes per-agent profiles through a toolset, uses tool metadata in a policy callback, and handles renames and external MCP tools. How a single call is resolved and executed is covered in tool dispatch mechanics; wrapping tools is covered in tool composition patterns.
The registry around an ADK Java agent
What the framework does with tool names
An LlmAgent has no global tool registry. LlmAgent.Builder.tools(Object...) accepts a mix of BaseTool and BaseToolset instances, and on every model call the flow resolves them: each toolset's getTools(ReadonlyContext) returns a Flowable<BaseTool>, and each tool's processLlmRequest adds its declaration to the request and itself to a map keyed by name(). When the model answers with function calls, the flow looks them up in that map. Two consequences follow, and both were confirmed by reading the 1.10.1 bytecode.
- A duplicate name fails late.
LlmRequest.Builder.appendToolsmerges tools into an immutable map whose merge function throwsIllegalArgumentExceptionwith the messageDuplicate tool name: .... Nothing checks at agent build time, so an agent with two tools namedsearchstarts cleanly and fails on its first model call. If one of the two comes from a toolset whose contents depend on session state, it fails only for some sessions. - An unknown name is dropped. If the model calls a name that is not in the map, the flow logs
Tool not found: {}at WARN and skips that call. The model does not receive an error response explaining the mistake. This is what happens after a rename, when old conversation history still shows the model the old name.
Neither behaviour is a bug to work around in the framework. They tell you where your own checks belong: uniqueness and naming must be enforced before an agent is ever built, and renames need a deliberate migration path.
A tool specification
Start with a data structure that describes a tool independently of the agent that will use it. The factory builds the tool lazily, so the catalog can be validated without opening database connections.
public enum Risk { READ, WRITE, IRREVERSIBLE }
public record ToolSpec(
String name, // the name the model sees; must equal tool.name()
int version,
String owner, // team or on-call alias
Risk risk,
Set<String> profiles, // which agent profiles may see it
Supplier<BaseTool> factory) {}
public final class ToolCatalog {
public static List<ToolSpec> specs(OrderService orders, KbService kb) {
return List.of(
new ToolSpec("kb_search", 3, "support-platform", Risk.READ,
Set.of("support", "triage"), () -> FunctionTool.create(kb, "kbSearch")),
new ToolSpec("order_lookup", 2, "orders", Risk.READ,
Set.of("support"), () -> FunctionTool.create(orders, "lookup")),
new ToolSpec("order_refund", 1, "orders", Risk.IRREVERSIBLE,
Set.of("support"), () -> FunctionTool.create(orders, "refund")));
}
}Keep the catalog in code, reviewed like code, with an owner for every entry. The owner field is what lets an alert on a failing tool page the right team; the risk field is what a policy callback reads later.
Validating the catalog at startup
Build every tool once at startup and check the result, so problems fail the deploy rather than a conversation. Read the name back from tool.name() instead of trusting the spec, because the declared name is whatever the tool reports, and that is the key the request map will use.
public final class ToolRegistry {
private static final Pattern NAME = Pattern.compile("^[a-z][a-z0-9_]{2,63}$");
private final Map<String, Entry> byName = new LinkedHashMap<>();
public record Entry(ToolSpec spec, BaseTool tool) {}
public ToolRegistry(List<ToolSpec> specs, int maxDeclarationChars) {
List<String> errors = new ArrayList<>();
for (ToolSpec s : specs) {
BaseTool t = s.factory().get();
if (!t.name().equals(s.name()))
errors.add(s.name() + ": tool reports name " + t.name());
if (!NAME.matcher(t.name()).matches())
errors.add(t.name() + ": name violates house pattern");
if (t.description() == null || t.description().length() < 40)
errors.add(t.name() + ": description too short to guide the model");
if (byName.containsKey(t.name()))
errors.add(t.name() + ": duplicate (owners " + byName.get(t.name()).spec().owner()
+ ", " + s.owner() + ")");
t.setCustomMetadata("risk", s.risk().name());
t.setCustomMetadata("owner", s.owner());
t.setCustomMetadata("version", s.version());
byName.put(t.name(), new Entry(s, t));
}
int chars = byName.values().stream()
.mapToInt(e -> e.tool().declaration().map(d -> d.toJson().length()).orElse(0)).sum();
if (chars > maxDeclarationChars)
errors.add("declarations total " + chars + " chars, budget " + maxDeclarationChars);
if (!errors.isEmpty())
throw new IllegalStateException("Tool registry invalid:\n" + String.join("\n", errors));
}
public List<BaseTool> forProfile(String profile) {
return byName.values().stream()
.filter(e -> e.spec().profiles().contains(profile))
.map(Entry::tool).toList();
}
public Optional<Entry> get(String name) { return Optional.ofNullable(byName.get(name)); }
}The name pattern is a house rule, deliberately stricter than what model providers accept, so names stay portable across models. The declaration budget matters because every visible tool's schema is sent with every model call. FunctionDeclaration.toJson() gives a cheap size proxy; convert it to tokens with your model's counter if you need precision. Run the constructor in a unit test as well as at boot, so a duplicate fails in CI.
Per-agent profiles through a toolset
An agent should see only the tools its job needs. Fewer tools mean shorter requests, fewer wrong choices by the model, and a smaller blast radius. A small BaseToolset turns a registry profile into the agent's tool list:
public final class ProfileToolset implements BaseToolset {
private final List<BaseTool> tools;
public ProfileToolset(ToolRegistry registry, String profile) {
this.tools = List.copyOf(registry.forProfile(profile));
}
@Override public Flowable<BaseTool> getTools(ReadonlyContext ctx) {
return Flowable.fromIterable(tools);
}
@Override public void close() {}
}
LlmAgent support = LlmAgent.builder()
.name("support_agent")
.model("gemini-2.5-flash")
.instruction("Answer order questions. Use tools; never guess order data.")
.tools(new ProfileToolset(registry, "support"))
.beforeToolCallbackSync(new RiskPolicy())
.build();Because getTools receives a ReadonlyContext with state(), userId() and agentName(), the toolset can also narrow the list per session. The composition article shows a role-scoped version; the same caveat applies here: hiding a tool is not authorization, and session keys that select tools must be written by server code, never by a tool the model can call.
Policy from tool metadata
Metadata attached during validation is available wherever a BaseTool is. A before-tool callback receives the tool, its arguments and the ToolContext; returning a non-empty map skips the tool and gives the model that map as the result.
public final class RiskPolicy implements Callbacks.BeforeToolCallbackSync {
@Override
public Optional<Map<String, Object>> call(InvocationContext inv, BaseTool tool,
Map<String, Object> args, ToolContext ctx) {
Object risk = tool.customMetadata().get("risk");
if ("IRREVERSIBLE".equals(risk) && !Boolean.TRUE.equals(ctx.state().get("refunds_enabled"))) {
return Optional.of(Map.of("status", "refused",
"reason", "Refunds need a human approver for this account."));
}
return Optional.empty(); // run the tool
}
}The policy reads a tier, not a tool name, so adding a new irreversible tool needs no policy change. The same metadata feeds logs and metrics: tag each call with owner and version so dashboards group by team. Version 1.10.1 also gives ToolContext requestConfirmation methods for a human approval step; check that flow against your version before relying on it. Wider governance practice is in ADK Java governance, and callback ordering in ADK Java callbacks.
Renames, versions and aliases
Renaming a tool looks free and is not. Stored sessions contain earlier function calls and responses under the old name, and the model will often call it again. In 1.10.1 that call is logged and skipped. Treat a rename as a two-release migration. First, register the new name and keep a thin alias under the old one that delegates to it and records usage. Second, once alias calls have dropped to zero for longer than your session retention, remove the alias.
public final class AliasTool extends BaseTool {
private final BaseTool target;
public AliasTool(String oldName, BaseTool target) {
super(oldName, "Deprecated alias of " + target.name() + ". " + target.description());
this.target = target;
}
@Override public Optional<FunctionDeclaration> declaration() {
return target.declaration().map(d -> d.toBuilder().name(name()).build());
}
@Override public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
Metrics.counter("tool_alias_calls", "alias", name()).increment();
return target.runAsync(args, ctx);
}
}Change the semantics of a tool only by adding a new name. If argument meaning changes under an unchanged name, old conversations will call it with old intent. The version field in the spec records the change for humans; the name is what the model sees.
External tools from MCP servers
Tools from MCP servers arrive through McpToolset. In 1.10.1 its constructors accept either a List<String> of tool names or a ToolPredicate, and ADK ships NamedToolPredicate for the name-list case. Always pass an allowlist. A server upgrade that adds a tool named like one of yours would otherwise throw the duplicate-name exception at the next model call, and a server that adds a powerful tool would otherwise expose it silently. Validate the filtered MCP tools against the same registry rules at startup by resolving the toolset once and running each returned tool through the checks. Keep the server's version in the spec so a drift alert can fire when it changes.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
IllegalArgumentException: Duplicate tool name on the first turn | Two tools or toolsets report the same name | Registry uniqueness check in CI and at boot |
Tool not found WARN in the logs | Renamed tool still in history, or a hallucinated name | Alias tool; alert on this log line |
| Model picks the wrong tool | Too many tools, overlapping descriptions | Smaller profiles; description lint |
| Requests grow, latency rises | Declaration bloat | Declaration budget in the registry |
| New MCP tool appears in prompts | Unfiltered toolset | Allowlist predicate |
| Write tool runs without approval | Policy keyed by tool name | Policy keyed by risk metadata |
Trade-offs
A static, code-reviewed catalog is safe and easy to audit, but every new tool needs a deploy. A dynamic catalog loaded from a database lets teams add tools without a release, at the cost of a second validation path and a place where a bad entry can take down every agent at once. Most teams should start static and add dynamic loading only for external, filtered MCP tools. Per-session tool selection reduces prompt size and wrong choices but makes behaviour depend on state, so log the resolved tool names on every call. Measure the effect of each tool on cost and errors with tool observability metrics.
What to do next
- List every tool every agent uses today, with an owner and a risk tier. Anything without an owner is the first thing to remove.
- Add the registry constructor checks as a unit test, so a duplicate name fails CI rather than the first conversation.
- Alert on the
Tool not foundlog line; each occurrence is a rename, a missing profile entry or a hallucination worth reading. - Give each agent a profile and measure declaration size per profile.
- Move approval and refusal decisions into a callback keyed by
customMetadatarisk, not by tool name. - Put an allowlist on every MCP toolset and record the server version.
- Write down the rename procedure: alias first, remove only after alias calls stay at zero beyond session retention.