diff --git a/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java b/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java index 41c68a574..96a83ac36 100644 --- a/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java +++ b/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java @@ -783,7 +783,8 @@ private static Map 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. */ diff --git a/core/src/main/java/com/google/adk/tools/mcp/ConversionUtils.java b/core/src/main/java/com/google/adk/tools/mcp/ConversionUtils.java index 23d7032fe..2186643eb 100644 --- a/core/src/main/java/com/google/adk/tools/mcp/ConversionUtils.java +++ b/core/src/main/java/com/google/adk/tools/mcp/ConversionUtils.java @@ -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_TYPE = new TypeReference<>() {}; /** * Input schema for a tool with no parameters. This is the schema the deprecated {@code @@ -38,16 +46,101 @@ public final class ConversionUtils { private static final ImmutableMap NO_PARAMETERS_SCHEMA = ImmutableMap.of("type", "object"); + private static final ImmutableMap 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 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 declaration = tool.declaration(); + Map 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 toJsonSchema(Schema schema) { + Map 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 nodes. + @SuppressWarnings("unchecked") + Map schema = (Map) rawSchema; + normalizeType(schema); + normalizeSubschemas(schema); + if (Objects.equals(schema.remove("nullable"), true)) { + allowNull(schema); + } + } + + private static void normalizeType(Map 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 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 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 append(List list, @Nullable Object element) { + List result = new ArrayList<>(list); + result.add(element); + return result; } private ConversionUtils() {} diff --git a/core/src/test/java/com/google/adk/tools/mcp/ConversionUtilsTest.java b/core/src/test/java/com/google/adk/tools/mcp/ConversionUtilsTest.java index b84c91565..69fee12fc 100644 --- a/core/src/test/java/com/google/adk/tools/mcp/ConversionUtilsTest.java +++ b/core/src/test/java/com/google/adk/tools/mcp/ConversionUtilsTest.java @@ -18,10 +18,19 @@ import static com.google.common.truth.Truth.assertThat; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.adk.tools.BaseTool; +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 com.google.genai.types.Type; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.spec.McpSchema; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import org.junit.Test; import org.junit.runner.RunWith; @@ -45,12 +54,33 @@ public Optional declaration() { } } + private static Map inputSchemaFor(Schema parameters) { + FunctionDeclaration declaration = + FunctionDeclaration.builder().name("tool").parameters(parameters).build(); + return ConversionUtils.adkToMcpToolType( + new FakeTool("tool", "description", Optional.of(declaration))) + .inputSchema(); + } + + /** Runs the checks an MCP SDK server applies: the schema itself, then a call's arguments. */ + private static void assertMcpServerAccepts( + Map inputSchema, Map arguments) { + assertThat(McpJsonDefaults.getSchemaValidator().validateSchema(inputSchema).errorMessage()) + .isNull(); + assertThat(McpJsonDefaults.getSchemaValidator().validate(inputSchema, arguments).errorMessage()) + .isNull(); + } + @Test public void adkToMcpToolType_declarationWithParameters_setsInputSchema() { FunctionDeclaration declaration = FunctionDeclaration.builder() .name("withParams") - .parameters(Schema.builder().type("OBJECT").build()) + .parameters( + Schema.builder() + .type("OBJECT") + .properties(ImmutableMap.of("city", Schema.builder().type("STRING").build())) + .build()) .build(); BaseTool tool = new FakeTool("withParams", "has params", Optional.of(declaration)); @@ -58,13 +88,271 @@ public void adkToMcpToolType_declarationWithParameters_setsInputSchema() { assertThat(result.name()).isEqualTo("withParams"); assertThat(result.description()).isEqualTo("has params"); - assertThat(result.inputSchema()).containsEntry("type", "OBJECT"); + assertThat(result.inputSchema()) + .containsExactly( + "type", + "object", + "properties", + ImmutableMap.of("city", ImmutableMap.of("type", "string"))); + } + + @Test + public void adkToMcpToolType_parametersAndParametersJsonSchema_prefersParametersJsonSchema() { + ImmutableMap jsonSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of("jsonParam", ImmutableMap.of("type", "string"))); + FunctionDeclaration declaration = + FunctionDeclaration.builder() + .name("both") + .parameters( + Schema.builder() + .type(Type.Known.OBJECT) + .properties( + ImmutableMap.of( + "schemaParam", Schema.builder().type(Type.Known.STRING).build())) + .build()) + .parametersJsonSchema(jsonSchema) + .build(); + + McpSchema.Tool result = + ConversionUtils.adkToMcpToolType(new FakeTool("both", "both", Optional.of(declaration))); + + assertThat(result.inputSchema()).isEqualTo(jsonSchema); + } + + @Test + public void adkToMcpToolType_jsonNodeParametersJsonSchema_convertsToMap() { + ObjectNode jsonSchema = JsonNodeFactory.instance.objectNode().put("type", "object"); + jsonSchema.putObject("properties").putObject("query").put("type", "string"); + FunctionDeclaration declaration = + FunctionDeclaration.builder().name("node").parametersJsonSchema(jsonSchema).build(); + + McpSchema.Tool result = + ConversionUtils.adkToMcpToolType(new FakeTool("node", "node", Optional.of(declaration))); + + assertThat(result.inputSchema()) + .containsExactly( + "type", + "object", + "properties", + ImmutableMap.of("query", ImmutableMap.of("type", "string"))); + } + + @Test + public void adkToMcpToolType_emptyParametersJsonSchema_usesParameters() { + FunctionDeclaration declaration = + FunctionDeclaration.builder() + .name("empty") + .parameters( + Schema.builder() + .type(Type.Known.OBJECT) + .properties( + ImmutableMap.of("city", Schema.builder().type(Type.Known.STRING).build())) + .build()) + .parametersJsonSchema(ImmutableMap.of()) + .build(); + + McpSchema.Tool result = + ConversionUtils.adkToMcpToolType(new FakeTool("empty", "empty", Optional.of(declaration))); + + assertThat(result.inputSchema()) + .containsExactly( + "type", + "object", + "properties", + ImmutableMap.of("city", ImmutableMap.of("type", "string"))); + } + + @Test + public void adkToMcpToolType_genaiSchema_convertsToValidJsonSchema() { + Schema schema = + Schema.builder() + .type(Type.Known.OBJECT) + .title("Request") + .propertyOrdering(ImmutableList.of("tags", "id")) + .properties( + ImmutableMap.of( + "code", + Schema.builder() + .type(Type.Known.STRING) + .pattern("^[a-z]+$") + .maxLength(5L) + .default_("abc") + .example("xyz") + .build(), + "tags", + Schema.builder() + .type(Type.Known.ARRAY) + .items(Schema.builder().type(Type.Known.STRING).build()) + .minItems(1L) + // Keywords on a type they do not apply to pass through and stay valid. + .minLength(2L) + .build(), + "id", + Schema.builder() + .anyOf( + ImmutableList.of( + Schema.builder().type(Type.Known.STRING).build(), + Schema.builder().type(Type.Known.INTEGER).minimum(0.0).build())) + .build(), + "unspecified", + Schema.builder().type(Type.Known.TYPE_UNSPECIFIED).description("Any.").build(), + "custom", + Schema.builder().type("custom").build())) + .build(); + + Map inputSchema = inputSchemaFor(schema); + + assertThat(inputSchema) + .containsExactly( + "type", + "object", + "title", + "Request", + "propertyOrdering", + ImmutableList.of("tags", "id"), + "properties", + ImmutableMap.of( + "code", + ImmutableMap.of( + "type", + "string", + "pattern", + "^[a-z]+$", + "maxLength", + 5L, + "default", + "abc", + "example", + "xyz"), + "tags", + ImmutableMap.of( + "type", + "array", + "items", + ImmutableMap.of("type", "string"), + "minItems", + 1L, + "minLength", + 2L), + "id", + ImmutableMap.of( + "anyOf", + ImmutableList.of( + ImmutableMap.of("type", "string"), + ImmutableMap.of("type", "integer", "minimum", 0.0))), + "unspecified", + ImmutableMap.of("description", "Any."), + "custom", + ImmutableMap.of())); + assertMcpServerAccepts( + inputSchema, ImmutableMap.of("code", "ab", "tags", ImmutableList.of("a"), "id", 7)); + } + + @Test + public void adkToMcpToolType_nullableGenaiSchema_mapsNullableToJsonSchema() { + Schema schema = + Schema.builder() + .type(Type.Known.OBJECT) + // MCP requires the input schema itself to be an object, so this nullable is dropped. + .nullable(true) + .required(ImmutableList.of("name")) + .properties( + ImmutableMap.of( + "name", + Schema.builder().type(Type.Known.STRING).nullable(true).build(), + "level", + Schema.builder() + .type(Type.Known.STRING) + .enum_(ImmutableList.of("LOW", "HIGH")) + .nullable(true) + .build(), + "id", + Schema.builder() + .anyOf( + ImmutableList.of( + Schema.builder().type(Type.Known.STRING).build(), + Schema.builder().type(Type.Known.INTEGER).build())) + .nullable(true) + .build(), + "maybe", + Schema.builder() + .anyOf( + ImmutableList.of( + Schema.builder().type(Type.Known.STRING).build(), + Schema.builder().type(Type.Known.NULL).build())) + .nullable(true) + .build(), + "nothing", + Schema.builder().type(Type.Known.NULL).nullable(true).build(), + "preset", + Schema.builder() + .type(Type.Known.STRING) + .enum_(Arrays.asList("A", null)) + .nullable(true) + .build(), + "loose", + Schema.builder().nullable(true).build(), + "plain", + Schema.builder().type(Type.Known.STRING).nullable(false).build())) + .build(); + + Map inputSchema = inputSchemaFor(schema); + + assertThat(inputSchema) + .containsExactly( + "type", + "object", + "required", + ImmutableList.of("name"), + "properties", + ImmutableMap.of( + "name", + ImmutableMap.of("type", ImmutableList.of("string", "null")), + "level", + ImmutableMap.of( + "type", + ImmutableList.of("string", "null"), + "enum", + Arrays.asList("LOW", "HIGH", null)), + "id", + ImmutableMap.of( + "anyOf", + ImmutableList.of( + ImmutableMap.of("type", "string"), + ImmutableMap.of("type", "integer"), + ImmutableMap.of("type", "null"))), + "maybe", + ImmutableMap.of( + "anyOf", + ImmutableList.of( + ImmutableMap.of("type", "string"), ImmutableMap.of("type", "null"))), + "nothing", + ImmutableMap.of("type", "null"), + "preset", + ImmutableMap.of( + "type", ImmutableList.of("string", "null"), "enum", Arrays.asList("A", null)), + "loose", + ImmutableMap.of(), + "plain", + ImmutableMap.of("type", "string"))); + Map arguments = new HashMap<>(); + arguments.put("name", null); + arguments.put("level", null); + arguments.put("id", null); + arguments.put("maybe", null); + arguments.put("nothing", null); + arguments.put("preset", null); + arguments.put("loose", null); + assertMcpServerAccepts(inputSchema, arguments); } @Test public void adkToMcpToolType_declarationWithoutParameters_defaultsInputSchema() { - // A present declaration with no parameters is a valid no-argument tool. Before the fix this - // threw NoSuchElementException from an unguarded Optional.get() on parameters(). + // A declaration with no parameters is a valid no-argument tool. FunctionDeclaration declaration = FunctionDeclaration.builder().name("noParams").build(); BaseTool tool = new FakeTool("noParams", "no params", Optional.of(declaration));