Skip to content

Commit 60250a9

Browse files
kvmiloscopybara-github
authored andcommitted
fix(mcp): emit valid JSON Schema when converting ADK tools to MCP tools
PiperOrigin-RevId: 999650939
1 parent 39e06a7 commit 60250a9

3 files changed

Lines changed: 400 additions & 18 deletions

File tree

‎core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,8 @@ private static Map<String, Object> toMap(Object schema) {
783783

784784
/**
785785
* Lowercases the {@code type} names in a raw schema, which {@link #schemaNormalizerModule} only
786-
* does for typed schemas. ADK's own MCP export sends genai's uppercase names, such as "OBJECT".
786+
* does for typed schemas. MCP servers built with older ADK Java releases send genai's uppercase
787+
* names, such as "OBJECT".
787788
*
788789
* @param node The schema node to adjust in place.
789790
*/

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

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,27 @@
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;
22+
import com.google.common.base.Ascii;
23+
import com.google.common.collect.ImmutableList;
2024
import com.google.common.collect.ImmutableMap;
2125
import com.google.genai.types.FunctionDeclaration;
2226
import com.google.genai.types.Schema;
23-
import io.modelcontextprotocol.json.McpJsonDefaults;
24-
import io.modelcontextprotocol.json.McpJsonMapper;
27+
import com.google.genai.types.Type;
2528
import io.modelcontextprotocol.spec.McpSchema;
29+
import java.util.ArrayList;
30+
import java.util.List;
31+
import java.util.Map;
32+
import java.util.Objects;
2633
import java.util.Optional;
34+
import org.jspecify.annotations.Nullable;
2735

28-
/** Utility class for converting between different representations of MCP tools. */
36+
/** Static helpers for converting ADK tools to MCP tools. */
2937
public final class ConversionUtils {
3038

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

3341
/**
3442
* Input schema for a tool with no parameters. This is the schema the deprecated {@code
@@ -38,16 +46,101 @@ public final class ConversionUtils {
3846
private static final ImmutableMap<String, Object> NO_PARAMETERS_SCHEMA =
3947
ImmutableMap.of("type", "object");
4048

49+
private static final ImmutableMap<String, Object> NULL_SCHEMA = ImmutableMap.of("type", "null");
50+
51+
/**
52+
* Converts an ADK tool to an MCP tool. Uses the declaration's {@code parametersJsonSchema} when
53+
* present and non-empty, falls back to {@code parameters} converted to JSON Schema, and defaults
54+
* to {@code {"type": "object"}}.
55+
*/
4156
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();
47-
}
48-
return McpSchema.Tool.builder(tool.name(), jsonMapper, parameters.get().toJson())
49-
.description(tool.description())
50-
.build();
57+
Optional<FunctionDeclaration> declaration = tool.declaration();
58+
Map<String, Object> inputSchema =
59+
declaration
60+
.flatMap(FunctionDeclaration::parametersJsonSchema)
61+
// ADK's Jackson 2 mapper: a Jackson 3 MCP SDK mapper would read a JsonNode as a bean.
62+
.map(schema -> JsonBaseModel.getMapper().convertValue(schema, MAP_TYPE))
63+
.filter(schema -> !schema.isEmpty())
64+
.or(
65+
() ->
66+
declaration
67+
.flatMap(FunctionDeclaration::parameters)
68+
.map(ConversionUtils::toJsonSchema))
69+
.orElse(NO_PARAMETERS_SCHEMA);
70+
return McpSchema.Tool.builder(tool.name(), inputSchema).description(tool.description()).build();
71+
}
72+
73+
/** Converts a genai {@link Schema}, which spells types in uppercase, to JSON Schema. */
74+
private static Map<String, Object> toJsonSchema(Schema schema) {
75+
Map<String, Object> json = JsonBaseModel.getMapper().convertValue(schema, MAP_TYPE);
76+
// MCP requires the input schema itself to have "type": "object", so it cannot be nullable.
77+
json.remove("nullable");
78+
normalizeSchema(json);
79+
return json;
80+
}
81+
82+
/**
83+
* Normalizes {@code node} and its nested schemas in place: lowercases {@code type}, drops a type
84+
* with no JSON Schema name such as {@code TYPE_UNSPECIFIED} so the schema accepts any value, and
85+
* replaces genai's {@code nullable}, which JSON Schema does not have.
86+
*/
87+
private static void normalizeSchema(Object node) {
88+
if (!(node instanceof Map<?, ?> rawSchema)) {
89+
return;
90+
}
91+
// Safe: convertValue built the whole tree from Map<String, Object> nodes.
92+
@SuppressWarnings("unchecked")
93+
Map<String, Object> schema = (Map<String, Object>) rawSchema;
94+
normalizeType(schema);
95+
normalizeSubschemas(schema);
96+
if (Objects.equals(schema.remove("nullable"), true)) {
97+
allowNull(schema);
98+
}
99+
}
100+
101+
private static void normalizeType(Map<String, Object> schema) {
102+
if (!(schema.get("type") instanceof String type)) {
103+
return;
104+
}
105+
// Type maps a name it does not know to TYPE_UNSPECIFIED; dropping it keeps the schema valid.
106+
Type.Known known = new Type(type).knownEnum();
107+
if (known == Type.Known.TYPE_UNSPECIFIED) {
108+
schema.remove("type");
109+
} else {
110+
schema.put("type", Ascii.toLowerCase(known.name()));
111+
}
112+
}
113+
114+
private static void normalizeSubschemas(Map<String, Object> schema) {
115+
if (schema.get("properties") instanceof Map<?, ?> properties) {
116+
properties.values().forEach(ConversionUtils::normalizeSchema);
117+
}
118+
normalizeSchema(schema.get("items"));
119+
if (schema.get("anyOf") instanceof List<?> anyOf) {
120+
anyOf.forEach(ConversionUtils::normalizeSchema);
121+
}
122+
}
123+
124+
/**
125+
* Allows null in whichever of {@code type}, {@code enum} and {@code anyOf} {@code schema} has,
126+
* since JSON Schema has no {@code nullable} keyword.
127+
*/
128+
private static void allowNull(Map<String, Object> schema) {
129+
if (schema.get("type") instanceof String type && !type.equals("null")) {
130+
schema.put("type", ImmutableList.of(type, "null"));
131+
}
132+
if (schema.get("enum") instanceof List<?> values && !values.contains(null)) {
133+
schema.put("enum", append(values, null));
134+
}
135+
if (schema.get("anyOf") instanceof List<?> anyOf && !anyOf.contains(NULL_SCHEMA)) {
136+
schema.put("anyOf", append(anyOf, NULL_SCHEMA));
137+
}
138+
}
139+
140+
private static List<Object> append(List<?> list, @Nullable Object element) {
141+
List<Object> result = new ArrayList<>(list);
142+
result.add(element);
143+
return result;
51144
}
52145

53146
private ConversionUtils() {}

0 commit comments

Comments
 (0)