@h1deya/langchain-mcp-tools
Version:
MCP To LangChain Tools Conversion Utility
440 lines (439 loc) • 19 kB
JavaScript
/**
* Transforms a JSON Schema to be compatible with Gemini's OpenAPI 3.0 subset
* Used for converting MCP tool schemas to Gemini function declarations
*
* The changes are mostly structural/validation-level and shouldn't break core
* tool functionality:
* - Core parameter structure is preserved
* - Valid required fields are maintained (invalid ones are filtered out)
* - Basic types are converted accurately
* - anyOf variants are individually validated and fixed
*
* Key transformations for Gemini compatibility:
* - Filters required fields that don't exist in properties
* - Ensures anyOf variants follow Gemini's strict validation rules
* - Removes unsupported JSON Schema features
* - Converts type arrays to nullable single types where possible
*
* For the OpenAPI subset requirement for function declarations
* see: https://ai.google.dev/api/caching#Schema
* For the OpenAPI 3.0 subset limitations vs full JSON Schema
* see: https://ai.google.dev/gemini-api/docs/structured-output
*/
export function makeJsonSchemaGeminiCompatible(schema, defsContext = {}) {
const tracker = {
fieldsRemoved: [],
fieldsConverted: [],
referencesResolved: 0,
typeArraysConverted: 0,
formatsRemoved: [],
exclusiveBoundsConverted: 0,
requiredFieldsFiltered: 0,
anyOfVariantsFixed: 0,
anyOfMergedFields: 0,
};
const result = transformSchemaInternal(schema, defsContext, tracker);
return {
schema: result,
wasTransformed: getTotalChanges(tracker) > 0,
changesSummary: generateChangesSummary(tracker),
};
}
/**
* Validates that required fields only reference properties that actually exist
* This addresses the core Gemini validation error you're experiencing
*/
function validateAndFilterRequired(required, properties, tracker) {
if (!required || !Array.isArray(required)) {
return required;
}
if (!properties) {
// If no properties but required exists, remove required entirely
if (required.length > 0) {
tracker.requiredFieldsFiltered += required.length;
}
return undefined;
}
const validRequired = required.filter(fieldName => Object.hasOwn(properties, fieldName));
const filteredCount = required.length - validRequired.length;
if (filteredCount > 0) {
tracker.requiredFieldsFiltered += filteredCount;
}
return validRequired.length > 0 ? validRequired : undefined;
}
/**
* Ensures each anyOf variant is independently valid according to Gemini rules
*/
function transformAnyOfVariants(anyOf, defsContext, tracker) {
return anyOf.map((variant) => {
const transformedVariant = transformSchemaInternal(variant, defsContext, tracker);
// Special validation for anyOf variants
if (transformedVariant.type === 'object' && transformedVariant.required) {
const originalRequired = transformedVariant.required;
transformedVariant.required = validateAndFilterRequired(transformedVariant.required, transformedVariant.properties, tracker);
if (originalRequired && originalRequired.length !== (transformedVariant.required?.length || 0)) {
tracker.anyOfVariantsFixed++;
}
}
return transformedVariant;
});
}
function transformSchemaInternal(schema, defsContext, tracker) {
// Handle $ref by resolving definitions
if (schema.$ref) {
const refPath = schema.$ref.replace('#/$defs/', '').replace('#/definitions/', '');
const resolvedSchema = defsContext[refPath];
if (resolvedSchema) {
tracker.referencesResolved++;
return transformSchemaInternal(resolvedSchema, defsContext, tracker);
}
// If can't resolve, return a generic object
tracker.fieldsConverted.push(`$ref (unresolved): ${schema.$ref}`);
return { type: 'object', description: `Reference: ${schema.$ref}` };
}
// Extract and merge $defs into context for resolution
if (schema.$defs || schema.definitions) {
const defs = schema.$defs || schema.definitions;
Object.assign(defsContext, defs);
tracker.fieldsRemoved.push('$defs/definitions');
}
const result = {};
// Handle type arrays (e.g., ["string", "null"]) -> convert to single type + nullable
if (Array.isArray(schema.type)) {
tracker.typeArraysConverted++;
const nonNullTypes = schema.type.filter((t) => t !== 'null');
const hasNull = schema.type.includes('null');
if (nonNullTypes.length === 1) {
result.type = nonNullTypes[0];
if (hasNull) {
result.nullable = true;
}
}
else if (nonNullTypes.length > 1) {
// Multiple non-null types -> use anyOf
result.anyOf = nonNullTypes.map((type) => ({ type }));
if (hasNull) {
result.nullable = true;
}
}
else {
// Only null type
result.type = 'string';
result.nullable = true;
}
}
else if (schema.type) {
result.type = schema.type;
}
// Handle format field - filter unsupported formats
if (schema.format) {
if (result.type === 'string') {
// Only allow supported string formats for Gemini
if (['enum', 'date-time'].includes(schema.format)) {
result.format = schema.format;
}
else {
tracker.formatsRemoved.push(`${schema.format} (string)`);
}
}
else if (result.type === 'number') {
// Only allow supported number formats
if (['float', 'double'].includes(schema.format)) {
result.format = schema.format;
}
else {
tracker.formatsRemoved.push(`${schema.format} (number)`);
}
}
else if (result.type === 'integer') {
// Only allow supported integer formats
if (['int32', 'int64'].includes(schema.format)) {
result.format = schema.format;
}
else {
tracker.formatsRemoved.push(`${schema.format} (integer)`);
}
}
else {
tracker.formatsRemoved.push(`${schema.format} (${result.type})`);
}
}
// Copy other basic supported fields
if (schema.description)
result.description = schema.description;
// Copy enum only if it's a string array since Gemini can't handle others
if (schema.enum && Array.isArray(schema.enum) && schema.enum.every(item => typeof item === 'string')) {
result.enum = schema.enum;
}
if (schema.nullable !== undefined)
result.nullable = schema.nullable;
if (schema.example !== undefined)
result.example = schema.example;
if (schema.default !== undefined)
result.default = schema.default;
if (schema.pattern)
result.pattern = schema.pattern;
// Handle numeric constraints - convert exclusive to inclusive
if (typeof schema.minimum === 'number') {
result.minimum = schema.minimum;
}
if (typeof schema.exclusiveMinimum === 'number') {
tracker.exclusiveBoundsConverted++;
// Convert exclusive to inclusive (approximate)
result.minimum = schema.exclusiveMinimum + (Number.isInteger(schema.exclusiveMinimum) ? 1 : 0.0001);
}
if (typeof schema.maximum === 'number') {
result.maximum = schema.maximum;
}
if (typeof schema.exclusiveMaximum === 'number') {
tracker.exclusiveBoundsConverted++;
// Convert exclusive to inclusive (approximate)
result.maximum = schema.exclusiveMaximum - (Number.isInteger(schema.exclusiveMaximum) ? 1 : 0.0001);
}
// Handle string constraints
if (typeof schema.minLength === 'number')
result.minLength = schema.minLength;
if (typeof schema.maxLength === 'number')
result.maxLength = schema.maxLength;
// Handle array constraints
if (typeof schema.minItems === 'number')
result.minItems = schema.minItems;
if (typeof schema.maxItems === 'number')
result.maxItems = schema.maxItems;
if (schema.items) {
if (Array.isArray(schema.items)) {
// Handle array case - maybe take first item or merge
if (schema.items.length > 0) {
result.items = transformSchemaInternal(schema.items[0], defsContext, tracker);
}
}
else {
// Handle single schema case
result.items = transformSchemaInternal(schema.items, defsContext, tracker);
}
}
// Handle object properties
if (schema.properties) {
result.properties = {};
for (const [key, propSchema] of Object.entries(schema.properties)) {
result.properties[key] = transformSchemaInternal(propSchema, defsContext, tracker);
}
}
// CRITICAL FIX: Validate required fields against actual properties
if (Array.isArray(schema.required)) {
result.required = validateAndFilterRequired(schema.required, result.properties, tracker);
}
// Handle anyOf (supported) but convert allOf/oneOf to anyOf
if (schema.anyOf) {
result.anyOf = transformAnyOfVariants(schema.anyOf, defsContext, tracker);
}
else if (schema.allOf) {
tracker.fieldsConverted.push('allOf → object merge');
// Convert allOf to object merge (best effort)
const merged = { type: 'object' };
for (const subSchema of schema.allOf) {
Object.assign(merged, subSchema);
if (subSchema.properties) {
merged.properties = { ...merged.properties, ...subSchema.properties };
}
if (subSchema.required) {
merged.required = [...(merged.required || []), ...subSchema.required];
}
}
return transformSchemaInternal(merged, defsContext, tracker);
}
else if (schema.oneOf) {
tracker.fieldsConverted.push('oneOf → anyOf');
// Convert oneOf to anyOf (less strict)
result.anyOf = transformAnyOfVariants(schema.oneOf, defsContext, tracker);
}
// CRITICAL FIX for Gemini 1.5-flash: anyOf cannot coexist with other fields
// This addresses the "anyOf must be the only field set" validation error
if (result.anyOf && (result.properties || result.required || result.type || result.description || result.example)) {
// Strategy: merge base schema properties into each anyOf variant
const baseSchema = {};
// Collect base properties that need to be merged
if (result.properties)
baseSchema.properties = result.properties;
if (result.required)
baseSchema.required = result.required;
if (result.type)
baseSchema.type = result.type;
if (result.description)
baseSchema.description = result.description;
if (result.example)
baseSchema.example = result.example;
if (result.nullable)
baseSchema.nullable = result.nullable;
// Merge base schema into each anyOf variant
result.anyOf = result.anyOf.map(variant => {
const mergedVariant = { ...variant };
// Merge properties
if (baseSchema.properties || variant.properties) {
mergedVariant.properties = {
...(baseSchema.properties || {}),
...(variant.properties || {})
};
}
// Merge required fields
if (baseSchema.required || variant.required) {
const baseRequired = baseSchema.required || [];
const variantRequired = variant.required || [];
const combinedRequired = [...new Set([...baseRequired, ...variantRequired])];
if (combinedRequired.length > 0) {
mergedVariant.required = combinedRequired;
}
}
// Use variant-specific type if available, otherwise fall back to base type
if (!variant.type && baseSchema.type) {
mergedVariant.type = baseSchema.type;
}
// Use variant-specific description if available, otherwise fall back to base description
if (!variant.description && baseSchema.description) {
mergedVariant.description = baseSchema.description;
}
// Merge other base properties if not present in variant
if (baseSchema.example !== undefined && mergedVariant.example === undefined) {
mergedVariant.example = baseSchema.example;
}
if (baseSchema.nullable !== undefined && mergedVariant.nullable === undefined) {
mergedVariant.nullable = baseSchema.nullable;
}
return mergedVariant;
});
// Remove conflicting base fields from root level
delete result.properties;
delete result.required;
delete result.type;
delete result.description;
delete result.example;
delete result.nullable;
tracker.fieldsConverted.push('anyOf + other fields → merged variants');
tracker.anyOfMergedFields++;
}
// Track removal of unsupported fields
const unsupportedFields = [
'$schema', '$id', '$ref', '$defs', 'definitions',
'exclusiveMinimum', 'exclusiveMaximum', 'additionalProperties',
'patternProperties', 'dependencies', 'if', 'then', 'else',
'allOf', 'oneOf', 'not', 'const', 'contains', 'unevaluatedProperties'
];
for (const field of unsupportedFields) {
if (schema[field] !== undefined && !['allOf', 'oneOf', 'exclusiveMinimum', 'exclusiveMaximum', '$defs', 'definitions', '$ref'].includes(field)) {
tracker.fieldsRemoved.push(field);
}
}
return result;
}
function getTotalChanges(tracker) {
return tracker.fieldsRemoved.length +
tracker.fieldsConverted.length +
tracker.referencesResolved +
tracker.typeArraysConverted +
tracker.formatsRemoved.length +
tracker.exclusiveBoundsConverted +
tracker.requiredFieldsFiltered +
tracker.anyOfVariantsFixed +
tracker.anyOfMergedFields;
}
function generateChangesSummary(tracker) {
const changes = [];
if (tracker.referencesResolved > 0) {
changes.push(`${tracker.referencesResolved} reference(s) resolved`);
}
if (tracker.typeArraysConverted > 0) {
changes.push(`${tracker.typeArraysConverted} type array(s) converted`);
}
if (tracker.exclusiveBoundsConverted > 0) {
changes.push(`${tracker.exclusiveBoundsConverted} exclusive bound(s) converted`);
}
if (tracker.requiredFieldsFiltered > 0) {
changes.push(`${tracker.requiredFieldsFiltered} invalid required field(s) filtered`);
}
if (tracker.anyOfVariantsFixed > 0) {
changes.push(`${tracker.anyOfVariantsFixed} anyOf variant(s) fixed`);
}
if (tracker.anyOfMergedFields > 0) {
changes.push(`${tracker.anyOfMergedFields} anyOf+fields merged`);
}
if (tracker.formatsRemoved.length > 0) {
const formatTypes = [...new Set(tracker.formatsRemoved.map(f => f.split(' ')[0]))];
changes.push(`${tracker.formatsRemoved.length} unsupported format(s) removed (${formatTypes.slice(0, 3).join(', ')}${formatTypes.length > 3 ? '...' : ''})`);
}
if (tracker.fieldsConverted.length > 0) {
changes.push(`${tracker.fieldsConverted.length} field(s) converted (${tracker.fieldsConverted.slice(0, 2).join(', ')}${tracker.fieldsConverted.length > 2 ? '...' : ''})`);
}
if (tracker.fieldsRemoved.length > 0) {
const removedTypes = [...new Set(tracker.fieldsRemoved)];
changes.push(`${tracker.fieldsRemoved.length} unsupported field(s) removed (${removedTypes.slice(0, 3).join(', ')}${removedTypes.length > 3 ? '...' : ''})`);
}
if (changes.length === 0) {
return '';
}
return changes.join(', ');
}
/**
* Specifically transforms MCP tool schemas for Gemini function declarations
*/
export function transformMcpToolForGemini(mcpTool) {
const transformResult = makeJsonSchemaGeminiCompatible(mcpTool.inputSchema || {});
const functionDeclaration = {
name: mcpTool.name,
description: mcpTool.description,
parameters: transformResult.schema
};
// Ensure parameters has a type if not set
if (!functionDeclaration.parameters.type) {
functionDeclaration.parameters.type = 'object';
}
return {
functionDeclaration,
wasTransformed: transformResult.wasTransformed,
changesSummary: transformResult.changesSummary
};
}
/**
* Enhanced utility to validate that a schema only uses Gemini-supported fields
* and follows all Gemini validation rules
*/
export function validateGeminiSchema(schema, path = '') {
const errors = [];
const supportedFields = new Set([
'type', 'format', 'description', 'nullable', 'enum', 'maxItems',
'minItems', 'properties', 'required', 'propertyOrdering', 'items',
'minimum', 'maximum', 'minLength', 'maxLength', 'pattern',
'example', 'anyOf', 'default'
]);
for (const [key, value] of Object.entries(schema)) {
const currentPath = path ? `${path}.${key}` : key;
if (!supportedFields.has(key)) {
errors.push(`Unsupported field '${key}' at ${currentPath}`);
}
// Enhanced validation: Check required vs properties consistency
if (key === 'required' && Array.isArray(value) && schema.properties) {
const invalidRequired = value.filter(reqField => !Object.hasOwn(schema.properties, reqField));
if (invalidRequired.length > 0) {
errors.push(`Required field(s) [${invalidRequired.join(', ')}] not found in properties at ${currentPath}`);
}
}
// Enhanced validation: Required only allowed for object type
if (key === 'required' && schema.type !== 'object') {
errors.push(`Required field only allowed for object type, found ${schema.type} at ${currentPath}`);
}
// Recursively validate nested schemas
if (key === 'properties' && typeof value === 'object') {
for (const [propKey, propValue] of Object.entries(value)) {
errors.push(...validateGeminiSchema(propValue, `${currentPath}.${propKey}`));
}
}
else if (key === 'items' && typeof value === 'object') {
errors.push(...validateGeminiSchema(value, `${currentPath}.items`));
}
else if (key === 'anyOf' && Array.isArray(value)) {
value.forEach((item, index) => {
errors.push(...validateGeminiSchema(item, `${currentPath}[${index}]`));
});
}
}
return errors;
}