Skip to content

Commit 91b14df

Browse files
kvmiloscopybara-github
authored andcommitted
fix(mcp): emit valid JSON Schema when converting ADK tools to MCP tools
PiperOrigin-RevId: 995388951
1 parent 189d463 commit 91b14df

2 files changed

Lines changed: 320 additions & 13 deletions

File tree

‎core/src/main/java/com/google/adk/tools/mcp/ConversionUtils.java‎

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,23 @@
1616

1717
package com.google.adk.tools.mcp;
1818

19+
import com.fasterxml.jackson.core.type.TypeReference;
20+
import com.google.adk.JsonBaseModel;
1921
import com.google.adk.tools.BaseTool;
2022
import com.google.common.collect.ImmutableMap;
2123
import com.google.genai.types.FunctionDeclaration;
2224
import com.google.genai.types.Schema;
23-
import io.modelcontextprotocol.json.McpJsonDefaults;
24-
import io.modelcontextprotocol.json.McpJsonMapper;
25+
import com.google.genai.types.Type;
2526
import io.modelcontextprotocol.spec.McpSchema;
27+
import java.util.List;
28+
import java.util.Locale;
29+
import java.util.Map;
2630
import java.util.Optional;
2731

2832
/** Utility class for converting between different representations of MCP tools. */
2933
public final class ConversionUtils {
3034

31-
private static final McpJsonMapper jsonMapper = McpJsonDefaults.getMapper();
35+
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
3236

3337
/**
3438
* Input schema for a tool with no parameters. This is the schema the deprecated {@code
@@ -38,16 +42,62 @@ public final class ConversionUtils {
3842
private static final ImmutableMap<String, Object> NO_PARAMETERS_SCHEMA =
3943
ImmutableMap.of("type", "object");
4044

45+
/**
46+
* Converts an ADK tool to an MCP tool. The input schema is the declaration's {@code
47+
* parametersJsonSchema} when it is set and not empty, otherwise its {@code parameters} with
48+
* lower-case types, otherwise {@code {"type": "object"}}.
49+
*/
4150
public static McpSchema.Tool adkToMcpToolType(BaseTool tool) {
42-
Optional<Schema> parameters = tool.declaration().flatMap(FunctionDeclaration::parameters);
43-
if (parameters.isEmpty()) {
44-
return McpSchema.Tool.builder(tool.name(), NO_PARAMETERS_SCHEMA)
45-
.description(tool.description())
46-
.build();
51+
Optional<FunctionDeclaration> declaration = tool.declaration();
52+
Map<String, Object> inputSchema =
53+
declaration
54+
.flatMap(FunctionDeclaration::parametersJsonSchema)
55+
// ADK's Jackson 2 mapper: a Jackson 3 MCP SDK mapper would read a JsonNode as a bean.
56+
.map(schema -> JsonBaseModel.getMapper().convertValue(schema, MAP_TYPE))
57+
.filter(schema -> !schema.isEmpty())
58+
.or(
59+
() ->
60+
declaration
61+
.flatMap(FunctionDeclaration::parameters)
62+
.map(ConversionUtils::toJsonSchema))
63+
.orElse(NO_PARAMETERS_SCHEMA);
64+
return McpSchema.Tool.builder(tool.name(), inputSchema).description(tool.description()).build();
65+
}
66+
67+
/** Converts a genai {@link Schema}, which spells types in upper case, to JSON Schema. */
68+
private static Map<String, Object> toJsonSchema(Schema schema) {
69+
Map<String, Object> json = JsonBaseModel.getMapper().convertValue(schema, MAP_TYPE);
70+
lowercaseTypes(json);
71+
return json;
72+
}
73+
74+
/**
75+
* Lower-cases the {@code type} of {@code node} and of its nested schemas, in place. A type with
76+
* no JSON Schema name, such as {@code TYPE_UNSPECIFIED}, is removed, so that schema accepts any
77+
* value.
78+
*/
79+
private static void lowercaseTypes(Object node) {
80+
if (!(node instanceof Map<?, ?> rawSchema)) {
81+
return;
82+
}
83+
// Safe: convertValue built the whole tree from Map<String, Object> nodes.
84+
@SuppressWarnings("unchecked")
85+
Map<String, Object> schema = (Map<String, Object>) rawSchema;
86+
if (schema.get("type") instanceof String type) {
87+
Type.Known known = new Type(type).knownEnum();
88+
if (known == Type.Known.TYPE_UNSPECIFIED) {
89+
schema.remove("type");
90+
} else {
91+
schema.put("type", known.name().toLowerCase(Locale.ROOT));
92+
}
93+
}
94+
if (schema.get("properties") instanceof Map<?, ?> properties) {
95+
properties.values().forEach(ConversionUtils::lowercaseTypes);
96+
}
97+
lowercaseTypes(schema.get("items"));
98+
if (schema.get("anyOf") instanceof List<?> anyOf) {
99+
anyOf.forEach(ConversionUtils::lowercaseTypes);
47100
}
48-
return McpSchema.Tool.builder(tool.name(), jsonMapper, parameters.get().toJson())
49-
.description(tool.description())
50-
.build();
51101
}
52102

53103
private ConversionUtils() {}

‎core/src/test/java/com/google/adk/tools/mcp/ConversionUtilsTest.java‎

Lines changed: 259 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,22 @@
1717
package com.google.adk.tools.mcp;
1818

1919
import static com.google.common.truth.Truth.assertThat;
20+
import static org.mockito.Mockito.mock;
2021

22+
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
23+
import com.fasterxml.jackson.databind.node.ObjectNode;
24+
import com.google.adk.tools.Annotations;
2125
import com.google.adk.tools.BaseTool;
26+
import com.google.adk.tools.FunctionTool;
27+
import com.google.common.collect.ImmutableList;
28+
import com.google.common.collect.ImmutableMap;
2229
import com.google.genai.types.FunctionDeclaration;
2330
import com.google.genai.types.Schema;
31+
import com.google.genai.types.Type;
32+
import io.modelcontextprotocol.client.McpSyncClient;
33+
import io.modelcontextprotocol.json.McpJsonDefaults;
2434
import io.modelcontextprotocol.spec.McpSchema;
35+
import java.util.Map;
2536
import java.util.Optional;
2637
import org.junit.Test;
2738
import org.junit.runner.RunWith;
@@ -45,20 +56,266 @@ public Optional<FunctionDeclaration> declaration() {
4556
}
4657
}
4758

59+
/** Backs the {@link FunctionTool} test: ADK derives the tool schema from this signature. */
60+
public static ImmutableMap<String, Object> getWeather(
61+
@Annotations.Schema(name = "city", description = "The city name.") String city,
62+
@Annotations.Schema(name = "count") int count) {
63+
return ImmutableMap.of("city", city, "count", count);
64+
}
65+
66+
private static Map<String, Object> inputSchemaFor(Schema parameters) {
67+
FunctionDeclaration declaration =
68+
FunctionDeclaration.builder().name("tool").parameters(parameters).build();
69+
return ConversionUtils.adkToMcpToolType(
70+
new FakeTool("tool", "description", Optional.of(declaration)))
71+
.inputSchema();
72+
}
73+
4874
@Test
4975
public void adkToMcpToolType_declarationWithParameters_setsInputSchema() {
5076
FunctionDeclaration declaration =
5177
FunctionDeclaration.builder()
5278
.name("withParams")
53-
.parameters(Schema.builder().type("OBJECT").build())
79+
.parameters(
80+
Schema.builder()
81+
.type("OBJECT")
82+
.properties(ImmutableMap.of("city", Schema.builder().type("STRING").build()))
83+
.build())
5484
.build();
5585
BaseTool tool = new FakeTool("withParams", "has params", Optional.of(declaration));
5686

5787
McpSchema.Tool result = ConversionUtils.adkToMcpToolType(tool);
5888

5989
assertThat(result.name()).isEqualTo("withParams");
6090
assertThat(result.description()).isEqualTo("has params");
61-
assertThat(result.inputSchema()).containsEntry("type", "OBJECT");
91+
assertThat(result.inputSchema())
92+
.containsExactly(
93+
"type",
94+
"object",
95+
"properties",
96+
ImmutableMap.of("city", ImmutableMap.of("type", "string")));
97+
}
98+
99+
@Test
100+
public void adkToMcpToolType_functionTool_emitsJsonSchema() {
101+
FunctionTool tool = FunctionTool.create(ConversionUtilsTest.class, "getWeather");
102+
103+
McpSchema.Tool result = ConversionUtils.adkToMcpToolType(tool);
104+
105+
assertThat(result.inputSchema())
106+
.containsExactly(
107+
"type",
108+
"object",
109+
"properties",
110+
ImmutableMap.of(
111+
"city", ImmutableMap.of("type", "string", "description", "The city name."),
112+
"count", ImmutableMap.of("type", "integer")),
113+
"required",
114+
ImmutableList.of("city", "count"));
115+
// The same meta-schema check an MCP SDK server runs on every tool it registers.
116+
assertThat(McpJsonDefaults.getSchemaValidator().validateSchema(result.inputSchema()).valid())
117+
.isTrue();
118+
}
119+
120+
@Test
121+
public void adkToMcpToolType_mcpTool_keepsServerInputSchema() {
122+
ImmutableMap<String, Object> inputSchema =
123+
ImmutableMap.of(
124+
"type",
125+
"object",
126+
"properties",
127+
ImmutableMap.of("query", ImmutableMap.of("type", "string")),
128+
"required",
129+
ImmutableList.of("query"),
130+
"additionalProperties",
131+
false);
132+
McpTool tool =
133+
new McpTool(
134+
McpSchema.Tool.builder("search", inputSchema).description("Searches").build(),
135+
mock(McpSyncClient.class),
136+
mock(McpSessionManager.class));
137+
138+
McpSchema.Tool result = ConversionUtils.adkToMcpToolType(tool);
139+
140+
assertThat(result.name()).isEqualTo("search");
141+
assertThat(result.inputSchema()).isEqualTo(inputSchema);
142+
}
143+
144+
@Test
145+
public void adkToMcpToolType_parametersAndParametersJsonSchema_prefersParametersJsonSchema() {
146+
ImmutableMap<String, Object> jsonSchema =
147+
ImmutableMap.of(
148+
"type",
149+
"object",
150+
"properties",
151+
ImmutableMap.of("jsonParam", ImmutableMap.of("type", "string")));
152+
FunctionDeclaration declaration =
153+
FunctionDeclaration.builder()
154+
.name("both")
155+
.parameters(
156+
Schema.builder()
157+
.type(Type.Known.OBJECT)
158+
.properties(
159+
ImmutableMap.of(
160+
"schemaParam", Schema.builder().type(Type.Known.STRING).build()))
161+
.build())
162+
.parametersJsonSchema(jsonSchema)
163+
.build();
164+
165+
McpSchema.Tool result =
166+
ConversionUtils.adkToMcpToolType(new FakeTool("both", "both", Optional.of(declaration)));
167+
168+
assertThat(result.inputSchema()).isEqualTo(jsonSchema);
169+
}
170+
171+
@Test
172+
public void adkToMcpToolType_jsonNodeParametersJsonSchema_convertsToMap() {
173+
ObjectNode jsonSchema = JsonNodeFactory.instance.objectNode().put("type", "object");
174+
jsonSchema.putObject("properties").putObject("query").put("type", "string");
175+
FunctionDeclaration declaration =
176+
FunctionDeclaration.builder().name("node").parametersJsonSchema(jsonSchema).build();
177+
178+
McpSchema.Tool result =
179+
ConversionUtils.adkToMcpToolType(new FakeTool("node", "node", Optional.of(declaration)));
180+
181+
assertThat(result.inputSchema())
182+
.containsExactly(
183+
"type",
184+
"object",
185+
"properties",
186+
ImmutableMap.of("query", ImmutableMap.of("type", "string")));
187+
}
188+
189+
@Test
190+
public void adkToMcpToolType_emptyParametersJsonSchema_usesParameters() {
191+
FunctionDeclaration declaration =
192+
FunctionDeclaration.builder()
193+
.name("empty")
194+
.parameters(
195+
Schema.builder()
196+
.type(Type.Known.OBJECT)
197+
.properties(
198+
ImmutableMap.of("city", Schema.builder().type(Type.Known.STRING).build()))
199+
.build())
200+
.parametersJsonSchema(ImmutableMap.of())
201+
.build();
202+
203+
McpSchema.Tool result =
204+
ConversionUtils.adkToMcpToolType(new FakeTool("empty", "empty", Optional.of(declaration)));
205+
206+
assertThat(result.inputSchema())
207+
.containsExactly(
208+
"type",
209+
"object",
210+
"properties",
211+
ImmutableMap.of("city", ImmutableMap.of("type", "string")));
212+
}
213+
214+
@Test
215+
public void adkToMcpToolType_nestedSchemas_lowercasesEveryType() {
216+
Schema schema =
217+
Schema.builder()
218+
.type(Type.Known.OBJECT)
219+
.properties(
220+
ImmutableMap.of(
221+
"tags",
222+
Schema.builder()
223+
.type(Type.Known.ARRAY)
224+
.items(Schema.builder().type(Type.Known.STRING).build())
225+
.build(),
226+
"id",
227+
Schema.builder()
228+
.anyOf(
229+
ImmutableList.of(
230+
Schema.builder().type(Type.Known.STRING).build(),
231+
Schema.builder().type(Type.Known.INTEGER).build()))
232+
.build()))
233+
.build();
234+
235+
assertThat(inputSchemaFor(schema))
236+
.containsExactly(
237+
"type",
238+
"object",
239+
"properties",
240+
ImmutableMap.of(
241+
"tags",
242+
ImmutableMap.of("type", "array", "items", ImmutableMap.of("type", "string")),
243+
"id",
244+
ImmutableMap.of(
245+
"anyOf",
246+
ImmutableList.of(
247+
ImmutableMap.of("type", "string"), ImmutableMap.of("type", "integer")))));
248+
}
249+
250+
@Test
251+
public void adkToMcpToolType_unspecifiedType_isDropped() {
252+
Schema schema =
253+
Schema.builder()
254+
.type(Type.Known.OBJECT)
255+
.properties(
256+
ImmutableMap.of(
257+
"anything", Schema.builder().type(Type.Known.TYPE_UNSPECIFIED).build()))
258+
.build();
259+
260+
assertThat(inputSchemaFor(schema))
261+
.containsExactly(
262+
"type", "object", "properties", ImmutableMap.of("anything", ImmutableMap.of()));
263+
}
264+
265+
@Test
266+
public void adkToMcpToolType_everyGenaiKeyword_passesMetaSchemaCheck() {
267+
// Some keywords sit on a type they do not apply to, such as minLength on an array; they pass
268+
// through and stay valid JSON Schema.
269+
Schema schema =
270+
Schema.builder()
271+
.type(Type.Known.OBJECT)
272+
.title("Request")
273+
.description("All keywords.")
274+
.nullable(true)
275+
.example(ImmutableMap.of("name", "a"))
276+
.properties(
277+
ImmutableMap.of(
278+
"name",
279+
Schema.builder()
280+
.type(Type.Known.STRING)
281+
.pattern("^a")
282+
.minLength(1L)
283+
.maxLength(5L)
284+
.format("enum")
285+
.enum_(ImmutableList.of("a", "ab"))
286+
.default_("a")
287+
.minimum(1.0)
288+
.build(),
289+
"tags",
290+
Schema.builder()
291+
.type(Type.Known.ARRAY)
292+
.items(
293+
Schema.builder()
294+
.type(Type.Known.NUMBER)
295+
.minimum(0.0)
296+
.maximum(9.0)
297+
.build())
298+
.minItems(1L)
299+
.maxItems(3L)
300+
.minLength(2L)
301+
.build(),
302+
"id",
303+
Schema.builder()
304+
.anyOf(
305+
ImmutableList.of(
306+
Schema.builder().type(Type.Known.STRING).build(),
307+
Schema.builder().type(Type.Known.INTEGER).build()))
308+
.build()))
309+
.required(ImmutableList.of("name"))
310+
.minProperties(1L)
311+
.maxProperties(2L)
312+
.propertyOrdering(ImmutableList.of("tags", "name"))
313+
.build();
314+
315+
Map<String, Object> inputSchema = inputSchemaFor(schema);
316+
317+
// The meta-schema check an MCP SDK server runs on every tool it registers.
318+
assertThat(McpJsonDefaults.getSchemaValidator().validateSchema(inputSchema).valid()).isTrue();
62319
}
63320

64321
@Test

0 commit comments

Comments
 (0)