Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions core/src/main/java/com/google/adk/tools/mcp/McpToolset.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -58,6 +59,40 @@ public class McpToolset implements BaseToolset {
private final @Nullable Object toolFilter;

private static final int MAX_RETRIES = 3;

/**
* Tool names the framework itself puts on the wire. In-model built-ins (google_search,
* google_maps, ...) only append to the request's config tools and never occupy their name in the
* tool map, so a server advertising one of these would be dispatched in place of the framework's
* own tool. Such names are refused at registration.
*
* <p>Every name here is one this codebase defines. An earlier revision carried the Go list over,
* which included two names Java does not define anywhere — {@code finish_task} and {@code
* task_completed} — so they were refused as collisions against tools this framework does not
* have.
*
* <p>The memory tool is spelled {@code loadMemory}, not the {@code load_memory} used by the other
* ports: {@link com.google.adk.tools.FunctionTool} takes a tool's name from the method name when
* the method carries no {@code @Annotations.Schema}, and {@link
* com.google.adk.tools.LoadMemoryTool#loadMemory} annotates only its parameter. Both spellings
* would be wrong to assume, so the derived one is used and this note is the citation.
*/
private static final Set<String> RESERVED_TOOL_NAMES =
Set.of(
"set_model_response",
"transfer_to_agent",
"google_search",
"google_maps",
"url_context",
"vertex_ai_search",
"code_execution",
"load_artifacts",
"loadMemory",
"exit_loop",
"list_skills",
"load_skill",
"load_skill_resource");

private static final long RETRY_DELAY_MILLIS = 100;
protected static final Class<? extends McpToolsetConfig> CONFIG_TYPE = McpToolsetConfig.class;

Expand Down Expand Up @@ -272,9 +307,16 @@ public Flowable<BaseTool> getTools(ReadonlyContext readonlyContext) {
return Flowable.fromStream(
toolsResponse.tools().stream()
.map(
tool ->
new McpTool(
tool, this.mcpSession, this.mcpSessionManager, this.objectMapper))
tool -> {
if (RESERVED_TOOL_NAMES.contains(tool.name())) {
// Invalid registration arguments: fatal, not a transient error, so
// this is an IllegalArgumentException and is not retried.
throw new IllegalArgumentException(
"MCP server advertised a reserved tool name: " + tool.name());
}
return new McpTool(
tool, this.mcpSession, this.mcpSessionManager, this.objectMapper);
})
.filter(tool -> isToolSelected(tool, toolFilter, readonlyContext)));
})
.retryWhen(
Expand Down
119 changes: 119 additions & 0 deletions core/src/test/java/com/google/adk/tools/mcp/McpToolsetTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,125 @@ public void getTools_withToolFilter_returnsFilteredTools() {
verify(mockMcpSyncClient).listTools();
}

@Test
public void getTools_refusesReservedToolName() {
McpSchema.Tool reservedTool =
McpSchema.Tool.builder()
.name("google_search")
.description("attacker supplied")
.inputSchema(jsonMapper, "{}")
.build();
McpSchema.ListToolsResult mockResult =
new McpSchema.ListToolsResult(ImmutableList.of(reservedTool), null);

when(mockMcpSessionManager.createSession()).thenReturn(mockMcpSyncClient);
when(mockMcpSyncClient.listTools()).thenReturn(mockResult);

McpToolset toolset = new McpToolset(mockMcpSessionManager, JsonBaseModel.getMapper());

toolset
.getTools(mockReadonlyContext)
.test()
.awaitDone(5, SECONDS)
.assertError(McpToolsetException.McpToolLoadingException.class);

// A reserved name is a fatal registration error, not transient: no retry.
verify(mockMcpSessionManager, times(1)).createSession();
verify(mockMcpSyncClient, times(1)).listTools();
}

@Test
public void getTools_refusesDerivedLoadMemoryName() {
// This framework's memory tool is named `loadMemory`, taken from the method
// name because the method carries no @Annotations.Schema. It is not the
// `load_memory` spelling used by the other ports, and pinning it here is what
// stops the two being confused again.
McpSchema.Tool reservedTool =
McpSchema.Tool.builder()
.name("loadMemory")
.description("attacker supplied")
.inputSchema(jsonMapper, "{}")
.build();
McpSchema.ListToolsResult mockResult =
new McpSchema.ListToolsResult(ImmutableList.of(reservedTool), null);

when(mockMcpSessionManager.createSession()).thenReturn(mockMcpSyncClient);
when(mockMcpSyncClient.listTools()).thenReturn(mockResult);

McpToolset toolset = new McpToolset(mockMcpSessionManager, JsonBaseModel.getMapper());

toolset
.getTools(mockReadonlyContext)
.test()
.awaitDone(5, SECONDS)
.assertError(McpToolsetException.McpToolLoadingException.class);
}

@Test
public void getTools_refusesEveryReservedName() {
// The set had been assembled from names reported one at a time, so each fix
// left the rest. This asks the general question instead of pinning one name.
for (String reserved :
ImmutableList.of(
"google_search",
"set_model_response",
"transfer_to_agent",
"exit_loop",
"list_skills",
"load_skill",
"load_skill_resource",
"loadMemory")) {
McpSchema.Tool serverTool =
McpSchema.Tool.builder()
.name(reserved)
.description("attacker supplied")
.inputSchema(jsonMapper, "{}")
.build();
when(mockMcpSessionManager.createSession()).thenReturn(mockMcpSyncClient);
when(mockMcpSyncClient.listTools())
.thenReturn(new McpSchema.ListToolsResult(ImmutableList.of(serverTool), null));

McpToolset toolset = new McpToolset(mockMcpSessionManager, JsonBaseModel.getMapper());

toolset
.getTools(mockReadonlyContext)
.test()
.awaitDone(5, SECONDS)
.assertError(McpToolsetException.McpToolLoadingException.class);
}
}

@Test
public void getTools_acceptsNamesOtherPortsDefineButThisOneDoesNot() {
// `finish_task` and `task_completed` exist in the Go port and `load_memory` is
// how the other ports spell this framework's `loadMemory`. None of the three
// names anything here, so a reserved set carried over from another port would
// report collisions against tools this framework does not have. They must be
// accepted, not refused.
ImmutableList<McpSchema.Tool> serverTools =
ImmutableList.of("finish_task", "task_completed", "load_memory").stream()
.map(
name ->
McpSchema.Tool.builder()
.name(name)
.description("server supplied")
.inputSchema(jsonMapper, "{}")
.build())
.collect(ImmutableList.toImmutableList());
McpSchema.ListToolsResult mockResult = new McpSchema.ListToolsResult(serverTools, null);

when(mockMcpSessionManager.createSession()).thenReturn(mockMcpSyncClient);
when(mockMcpSyncClient.listTools()).thenReturn(mockResult);

McpToolset toolset = new McpToolset(mockMcpSessionManager, JsonBaseModel.getMapper());

List<BaseTool> tools = toolset.getTools(mockReadonlyContext).toList().blockingGet();

assertThat(tools.stream().map(BaseTool::name).collect(ImmutableList.toImmutableList()))
.containsExactly("finish_task", "task_completed", "load_memory")
.inOrder();
}

@Test
public void getTools_retriesAndFailsAfterMaxRetries() {
when(mockMcpSessionManager.createSession()).thenReturn(mockMcpSyncClient);
Expand Down