Skip to content

Commit cd6e950

Browse files
Doris26copybara-github
authored andcommitted
feat: Add GeminiSchemaUtil for converting OpenAPI/MCP JsonSchema to com.google.genai.types.Schema
This change introduces `GeminiSchemaUtil` to centralize the logic for converting OpenAPI/MCP `JsonSchema` to `com.google.genai.types.Schema`. The utility handles type sanitization, format filtering, snake_case conversion for schema keywords, and recursive processing of nested schemas. PiperOrigin-RevId: 794283578
1 parent 103aeae commit cd6e950

File tree

3 files changed

+990
-15
lines changed

3 files changed

+990
-15
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.tools.mcp;
18+
19+
import static com.google.common.base.Strings.isNullOrEmpty;
20+
21+
import com.fasterxml.jackson.databind.JsonNode;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.fasterxml.jackson.databind.node.ArrayNode;
24+
import com.fasterxml.jackson.databind.node.ObjectNode;
25+
import com.google.common.collect.ImmutableSet;
26+
import com.google.genai.types.Schema;
27+
import io.modelcontextprotocol.spec.McpSchema.JsonSchema;
28+
import java.io.IOException;
29+
import java.util.Iterator;
30+
import java.util.Locale;
31+
import java.util.Map;
32+
import java.util.Objects;
33+
import org.jspecify.annotations.Nullable;
34+
35+
/**
36+
* Utility class for converting OpenAPI/MCP JsonSchema to Gemini Schema format.
37+
*
38+
* <p>This utility handles: - Converting field names to snake_case - Sanitizing schema types
39+
* (handling nullable types and arrays) - Filtering format fields based on type - Recursively
40+
* processing nested schemas - Only keeping fields supported by Gemini
41+
*/
42+
public final class GeminiSchemaUtil {
43+
44+
private static final ImmutableSet<String> SUPPORTED_FIELDS =
45+
ImmutableSet.of(
46+
"type",
47+
"description",
48+
"format",
49+
"enum",
50+
"required",
51+
"minimum",
52+
"maximum",
53+
"min_length",
54+
"max_length",
55+
"pattern",
56+
"default",
57+
"nullable",
58+
"title");
59+
60+
private static final ImmutableSet<String> SCHEMA_FIELD_NAMES = ImmutableSet.of("items");
61+
62+
private static final ImmutableSet<String> LIST_SCHEMA_FIELD_NAMES = ImmutableSet.of("any_of");
63+
64+
// Fields that contain dictionaries of schemas
65+
private static final ImmutableSet<String> DICT_SCHEMA_FIELD_NAMES = ImmutableSet.of("properties");
66+
67+
private GeminiSchemaUtil() {}
68+
69+
/**
70+
* Converts an OpenAPI/MCP JsonSchema to a Gemini Schema object.
71+
*
72+
* @param openApiSchema The input schema in OpenAPI/MCP format
73+
* @param objectMapper The ObjectMapper to use for JSON processing
74+
* @return A Gemini Schema object
75+
* @throws IOException if JSON processing fails
76+
*/
77+
public static @Nullable Schema toGeminiSchema(JsonSchema openApiSchema, ObjectMapper objectMapper)
78+
throws IOException {
79+
if (openApiSchema == null) {
80+
return null;
81+
}
82+
83+
JsonNode schemaNode = objectMapper.valueToTree(openApiSchema);
84+
ObjectNode sanitizedSchema = sanitizeSchemaFormatsForGemini(schemaNode, objectMapper);
85+
convertTypesToUpperCase(sanitizedSchema, objectMapper);
86+
String jsonStr = objectMapper.writeValueAsString(sanitizedSchema);
87+
return Schema.fromJson(jsonStr);
88+
}
89+
90+
/**
91+
* Converts a string to snake_case.
92+
*
93+
* <p>Handles lowerCamelCase, UpperCamelCase, space-separated case, acronyms (e.g., "REST API")
94+
* and consecutive uppercase letters correctly. Also handles mixed cases with and without spaces.
95+
*
96+
* <p>Examples: - camelCase -> camel_case - UpperCamelCase -> upper_camel_case - space separated
97+
* -> space_separated - REST API -> rest_api
98+
*
99+
* @param text The input string
100+
* @return The snake_case version of the string
101+
*/
102+
public static String toSnakeCase(String text) {
103+
if (isNullOrEmpty(text)) {
104+
return text;
105+
}
106+
107+
text = text.replaceAll("[^a-zA-Z0-9]+", "_");
108+
109+
text = text.replaceAll("([a-z0-9])([A-Z])", "$1_$2");
110+
text = text.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2");
111+
112+
text = text.toLowerCase(Locale.ROOT);
113+
text = text.replaceAll("_+", "_");
114+
115+
text = text.replaceAll("^_+|_+$", "");
116+
117+
return text;
118+
}
119+
120+
/**
121+
* Sanitizes the schema type field to ensure it has a valid type.
122+
*
123+
* @param schema The schema node to sanitize
124+
* @param objectMapper The ObjectMapper for creating new nodes
125+
*/
126+
private static void sanitizeSchemaType(ObjectNode schema, ObjectMapper objectMapper) {
127+
if (!schema.has("type") || schema.get("type").isNull()) {
128+
// If no type is specified, default to object
129+
schema.put("type", "object");
130+
} else if (schema.get("type").isArray()) {
131+
// Handle array types (e.g., ["string", "null"])
132+
ArrayNode typeArray = (ArrayNode) schema.get("type");
133+
boolean nullable = false;
134+
String nonNullType = null;
135+
136+
for (JsonNode t : typeArray) {
137+
String typeStr = t.asText();
138+
if (Objects.equals(typeStr, "null")) {
139+
nullable = true;
140+
} else if (nonNullType == null) {
141+
nonNullType = typeStr;
142+
}
143+
}
144+
145+
if (nonNullType == null) {
146+
nonNullType = "object";
147+
}
148+
149+
if (nullable) {
150+
// Create new array with non-null type and null
151+
ArrayNode newTypeArray = objectMapper.createArrayNode();
152+
newTypeArray.add(nonNullType);
153+
newTypeArray.add("null");
154+
schema.set("type", newTypeArray);
155+
} else {
156+
schema.put("type", nonNullType);
157+
}
158+
} else if (Objects.equals(schema.get("type").asText(), "null")) {
159+
// If type is just "null", make it ["object", "null"]
160+
ArrayNode newTypeArray = objectMapper.createArrayNode();
161+
newTypeArray.add("object");
162+
newTypeArray.add("null");
163+
schema.set("type", newTypeArray);
164+
}
165+
}
166+
167+
/**
168+
* Filters and sanitizes the schema to only include fields supported by Gemini.
169+
*
170+
* @param schema The input schema node
171+
* @param objectMapper The ObjectMapper for creating new nodes
172+
* @return A sanitized schema node
173+
*/
174+
private static ObjectNode sanitizeSchemaFormatsForGemini(
175+
JsonNode schema, ObjectMapper objectMapper) {
176+
if (schema == null || !schema.isObject()) {
177+
return objectMapper.createObjectNode();
178+
}
179+
180+
ObjectNode snakeCaseSchema = objectMapper.createObjectNode();
181+
ObjectNode originalSchema = (ObjectNode) schema;
182+
183+
for (Map.Entry<String, JsonNode> entry : originalSchema.properties()) {
184+
String fieldName = toSnakeCase(entry.getKey());
185+
JsonNode fieldValue = entry.getValue();
186+
187+
if (SCHEMA_FIELD_NAMES.contains(fieldName)) {
188+
// Recursively process schema fields
189+
snakeCaseSchema.set(fieldName, sanitizeSchemaFormatsForGemini(fieldValue, objectMapper));
190+
} else if (LIST_SCHEMA_FIELD_NAMES.contains(fieldName)) {
191+
// Process list of schemas
192+
if (fieldValue.isArray()) {
193+
ArrayNode newArray = objectMapper.createArrayNode();
194+
for (JsonNode value : fieldValue) {
195+
newArray.add(sanitizeSchemaFormatsForGemini(value, objectMapper));
196+
}
197+
snakeCaseSchema.set(fieldName, newArray);
198+
}
199+
} else if (DICT_SCHEMA_FIELD_NAMES.contains(fieldName) && fieldValue != null) {
200+
// Process dictionary of schemas
201+
if (fieldValue.isObject()) {
202+
ObjectNode newDict = objectMapper.createObjectNode();
203+
for (Map.Entry<String, JsonNode> dictEntry : fieldValue.properties()) {
204+
newDict.set(
205+
dictEntry.getKey(),
206+
sanitizeSchemaFormatsForGemini(dictEntry.getValue(), objectMapper));
207+
}
208+
snakeCaseSchema.set(fieldName, newDict);
209+
}
210+
} else if (Objects.equals(fieldName, "format")
211+
&& fieldValue != null
212+
&& !fieldValue.isNull()) {
213+
// Special handling of format field
214+
handleFormatField(originalSchema, fieldName, fieldValue, snakeCaseSchema);
215+
} else if (SUPPORTED_FIELDS.contains(fieldName)
216+
&& fieldValue != null
217+
&& !fieldValue.isNull()) {
218+
// Keep supported fields
219+
snakeCaseSchema.set(fieldName, fieldValue);
220+
}
221+
}
222+
223+
sanitizeSchemaType(snakeCaseSchema, objectMapper);
224+
225+
return snakeCaseSchema;
226+
}
227+
228+
/**
229+
* Handles the special processing of format fields based on type.
230+
*
231+
* @param originalSchema The original schema node
232+
* @param fieldName The field name (should be "format")
233+
* @param fieldValue The format value
234+
* @param snakeCaseSchema The output schema to add the format to
235+
*/
236+
private static void handleFormatField(
237+
ObjectNode originalSchema,
238+
String fieldName,
239+
JsonNode fieldValue,
240+
ObjectNode snakeCaseSchema) {
241+
242+
String format = fieldValue.asText();
243+
String currentType = null;
244+
245+
if (originalSchema.has("type")) {
246+
JsonNode typeNode = originalSchema.get("type");
247+
if (typeNode.isTextual()) {
248+
currentType = typeNode.asText();
249+
} else if (typeNode.isArray() && typeNode.size() > 0) {
250+
for (JsonNode t : typeNode) {
251+
if (!Objects.equals(t.asText(), "null")) {
252+
currentType = t.asText();
253+
break;
254+
}
255+
}
256+
}
257+
}
258+
259+
if (currentType != null) {
260+
if ((currentType.equals("integer") || currentType.equals("number"))
261+
&& (Objects.equals(format, "int32") || Objects.equals(format, "int64"))) {
262+
// Only "int32" and "int64" are supported for integer or number type
263+
snakeCaseSchema.put(fieldName, format);
264+
} else if (currentType.equals("string")
265+
&& (Objects.equals(format, "date-time") || Objects.equals(format, "enum"))) {
266+
// Only 'enum' and 'date-time' are supported for STRING type
267+
snakeCaseSchema.put(fieldName, format);
268+
}
269+
// All other format values are dropped
270+
}
271+
}
272+
273+
/**
274+
* Converts type fields to uppercase for Gemini compatibility.
275+
*
276+
* @param node The node to process
277+
* @param objectMapper The ObjectMapper for creating new nodes
278+
*/
279+
private static void convertTypesToUpperCase(JsonNode node, ObjectMapper objectMapper) {
280+
if (node == null || !node.isObject()) {
281+
return;
282+
}
283+
284+
ObjectNode objNode = (ObjectNode) node;
285+
286+
// Convert type to uppercase
287+
if (objNode.has("type")) {
288+
JsonNode typeNode = objNode.get("type");
289+
if (typeNode.isTextual()) {
290+
objNode.put("type", typeNode.asText().toUpperCase(Locale.ROOT));
291+
} else if (typeNode.isArray()) {
292+
// Handle array types like ["object", "null"]
293+
// TODO: use gemini json schema once it's ready.
294+
ArrayNode typeArray = (ArrayNode) typeNode;
295+
String nonNullType = null;
296+
boolean hasNull = false;
297+
298+
for (JsonNode t : typeArray) {
299+
String typeStr = t.asText();
300+
if (Objects.equals(typeStr, "null") || Objects.equals(typeStr, "NULL")) {
301+
hasNull = true;
302+
} else {
303+
nonNullType = typeStr.toUpperCase(Locale.ROOT);
304+
}
305+
}
306+
307+
if (nonNullType == null) {
308+
nonNullType = "OBJECT";
309+
}
310+
311+
objNode.put("type", nonNullType);
312+
if (hasNull) {
313+
objNode.put("nullable", true);
314+
}
315+
}
316+
}
317+
318+
if (objNode.has("properties")) {
319+
JsonNode properties = objNode.get("properties");
320+
if (properties.isObject()) {
321+
Iterator<JsonNode> propValues = properties.elements();
322+
while (propValues.hasNext()) {
323+
convertTypesToUpperCase(propValues.next(), objectMapper);
324+
}
325+
}
326+
}
327+
328+
if (objNode.has("items")) {
329+
convertTypesToUpperCase(objNode.get("items"), objectMapper);
330+
}
331+
332+
if (objNode.has("any_of")) {
333+
JsonNode anyOf = objNode.get("any_of");
334+
if (anyOf.isArray()) {
335+
for (JsonNode schema : anyOf) {
336+
convertTypesToUpperCase(schema, objectMapper);
337+
}
338+
}
339+
}
340+
}
341+
}

0 commit comments

Comments
 (0)