Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,8 @@ private static Map<String, Object> toMap(Object schema) {

/**
* Lowercases the {@code type} names in a raw schema, which {@link #schemaNormalizerModule} only
* does for typed schemas. ADK's own MCP export sends genai's uppercase names, such as "OBJECT".
* does for typed schemas. MCP servers built with older ADK Java releases send genai's uppercase
* names, such as "OBJECT".
*
* @param node The schema node to adjust in place.
*/
Expand Down
119 changes: 106 additions & 13 deletions core/src/main/java/com/google/adk/tools/mcp/ConversionUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,27 @@

package com.google.adk.tools.mcp;

import com.fasterxml.jackson.core.type.TypeReference;
import com.google.adk.JsonBaseModel;
import com.google.adk.tools.BaseTool;
import com.google.common.base.Ascii;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.Schema;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import com.google.genai.types.Type;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.jspecify.annotations.Nullable;

/** Utility class for converting between different representations of MCP tools. */
/** Static helpers for converting ADK tools to MCP tools. */
public final class ConversionUtils {

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

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

private static final ImmutableMap<String, Object> NULL_SCHEMA = ImmutableMap.of("type", "null");

/**
* Converts an ADK tool to an MCP tool. Uses the declaration's {@code parametersJsonSchema} when
* present and non-empty, falls back to {@code parameters} converted to JSON Schema, and defaults
* to {@code {"type": "object"}}.
*/
public static McpSchema.Tool adkToMcpToolType(BaseTool tool) {
Optional<Schema> parameters = tool.declaration().flatMap(FunctionDeclaration::parameters);
if (parameters.isEmpty()) {
return McpSchema.Tool.builder(tool.name(), NO_PARAMETERS_SCHEMA)
.description(tool.description())
.build();
}
return McpSchema.Tool.builder(tool.name(), jsonMapper, parameters.get().toJson())
.description(tool.description())
.build();
Optional<FunctionDeclaration> declaration = tool.declaration();
Map<String, Object> inputSchema =
declaration
.flatMap(FunctionDeclaration::parametersJsonSchema)
// ADK's Jackson 2 mapper: a Jackson 3 MCP SDK mapper would read a JsonNode as a bean.
.map(schema -> JsonBaseModel.getMapper().convertValue(schema, MAP_TYPE))
.filter(schema -> !schema.isEmpty())
.or(
() ->
declaration
.flatMap(FunctionDeclaration::parameters)
.map(ConversionUtils::toJsonSchema))
.orElse(NO_PARAMETERS_SCHEMA);
return McpSchema.Tool.builder(tool.name(), inputSchema).description(tool.description()).build();
}

/** Converts a genai {@link Schema}, which spells types in uppercase, to JSON Schema. */
private static Map<String, Object> toJsonSchema(Schema schema) {
Map<String, Object> json = JsonBaseModel.getMapper().convertValue(schema, MAP_TYPE);
// MCP requires the input schema itself to have "type": "object", so it cannot be nullable.
json.remove("nullable");
normalizeSchema(json);
return json;
}

/**
* Normalizes {@code node} and its nested schemas in place: lowercases {@code type}, drops a type
* with no JSON Schema name such as {@code TYPE_UNSPECIFIED} so the schema accepts any value, and
* replaces genai's {@code nullable}, which JSON Schema does not have.
*/
private static void normalizeSchema(Object node) {
if (!(node instanceof Map<?, ?> rawSchema)) {
return;
}
// Safe: convertValue built the whole tree from Map<String, Object> nodes.
@SuppressWarnings("unchecked")
Map<String, Object> schema = (Map<String, Object>) rawSchema;
normalizeType(schema);
normalizeSubschemas(schema);
if (Objects.equals(schema.remove("nullable"), true)) {
allowNull(schema);
}
}

private static void normalizeType(Map<String, Object> schema) {
if (!(schema.get("type") instanceof String type)) {
return;
}
// Type maps a name it does not know to TYPE_UNSPECIFIED; dropping it keeps the schema valid.
Type.Known known = new Type(type).knownEnum();
if (known == Type.Known.TYPE_UNSPECIFIED) {
schema.remove("type");
} else {
schema.put("type", Ascii.toLowerCase(known.name()));
}
}

private static void normalizeSubschemas(Map<String, Object> schema) {
if (schema.get("properties") instanceof Map<?, ?> properties) {
properties.values().forEach(ConversionUtils::normalizeSchema);
}
normalizeSchema(schema.get("items"));
if (schema.get("anyOf") instanceof List<?> anyOf) {
anyOf.forEach(ConversionUtils::normalizeSchema);
}
}

/**
* Allows null in whichever of {@code type}, {@code enum} and {@code anyOf} {@code schema} has,
* since JSON Schema has no {@code nullable} keyword.
*/
private static void allowNull(Map<String, Object> schema) {
if (schema.get("type") instanceof String type && !type.equals("null")) {
schema.put("type", ImmutableList.of(type, "null"));
}
if (schema.get("enum") instanceof List<?> values && !values.contains(null)) {
schema.put("enum", append(values, null));
}
if (schema.get("anyOf") instanceof List<?> anyOf && !anyOf.contains(NULL_SCHEMA)) {
schema.put("anyOf", append(anyOf, NULL_SCHEMA));
}
}

private static List<Object> append(List<?> list, @Nullable Object element) {
List<Object> result = new ArrayList<>(list);
result.add(element);
return result;
}

private ConversionUtils() {}
Expand Down
Loading
Loading