Skip to main content
Glama
spec.ts26.3 kB
import _ from "lodash"; import { OnlyProperties } from "../../spec/props.ts"; import type { CfProperty } from "../types.ts"; import { CfHandler, CfHandlerKind } from "../types.ts"; import { type DigitalOceanOpenApiOperation, type DigitalOceanOpenApiRequestBody, type DigitalOceanOpenApiResponse, type DigitalOceanSchema, type JsonSchemaObject, type NormalizedDigitalOceanSchema, type OperationData, type PropertySet, } from "./schema.ts"; import logger from "../../logger.ts"; import { JSONSchema } from "../draft_07.ts"; import TypeValue = JSONSchema.TypeValue; export function mergeResourceOperations( resourceName: string, endpoint: string, operations: OperationData[], description?: string, ): { schema: DigitalOceanSchema; onlyProperties: OnlyProperties; domainProperties: Record<string, CfProperty>; resourceValueProperties: Record<string, CfProperty>; } | null { const { handlers, getOperation, postOperation, putOperation, patchOperation, isGetOperationList, } = buildHandlersFromOperations(operations); // Use provided description or fall back to default const schemaDescription = description ?? `DigitalOcean ${resourceName} resource`; logger.debug(`Will parse ${resourceName}`); const createProps = extractPropertiesFromRequestBody( postOperation?.requestBody, ); // Start with POST properties for domain (writable properties) const domainProperties = { ...createProps.properties }; const requiredProperties = new Set(createProps.required || []); // Properties that appear in different operations - for onlyProperties classification const createProperties: PropertySet = new Set( Object.keys(createProps.properties ?? {}), ); const getResponse = getOperation?.responses?.["200"]; const getProps = extractPropertiesFromResponse( getResponse, isGetOperationList, ); const getProperties: PropertySet = new Set(Object.keys(getProps.properties ?? {})); const updateProperties: PropertySet = new Set(); // Merge PUT/PATCH into domain (POST + PUT/PATCH = writable properties) const updateOperation = patchOperation || putOperation; if (updateOperation) { const updateProps = extractPropertiesFromRequestBody( updateOperation.requestBody, ); Object.keys(updateProps.properties ?? {}).forEach((prop) => updateProperties.add(prop) ); Object.entries(updateProps.properties ?? {}).forEach(([key, prop]) => { domainProperties[key] = mergePropertyDefinitions( domainProperties[key], prop, ); }); // Add required properties from UPDATE operation if(updateProps.required) { updateProps.required.forEach((prop) => requiredProperties.add(prop)); } } const resourceValueProperties = { ...getProps.properties }; // Extract identifier field from the endpoint path let identifierField = "id"; // Default const endpointWithId = operations.find(op => op.endpointHasId && ( op.openApiDescription.get || op.openApiDescription.put || op.openApiDescription.patch || op.openApiDescription.delete ) ); if (endpointWithId) { // Extract parameter name from path like /v2/domains/{domain_name} const pathMatch = endpointWithId.endpoint.match(/\{([^}]+)\}$/); if (pathMatch) { const paramName = pathMatch[1]; // First, check for endpoint-specific mappings to handle conflicts // where the same parameter name means different things in different resources const endpointPath = endpointWithId.endpoint; const endpointSpecificMappings: Record<string, string> = { '/v2/apps/{id}': 'id', // Apps uses 'id' field // VPC NAT Gateways also uses 'id' field - removed incorrect 'name' mapping }; if (endpointSpecificMappings[endpointPath]) { identifierField = endpointSpecificMappings[endpointPath]; } else { // Fall back to parameter name mapping // Map URL parameter names to actual field names in the response // This mapping is derived from comprehensive analysis of POST responses in the OpenAPI spec const paramToFieldMap: Record<string, string> = { // Standard ID parameters 'action_id': 'id', 'alert_id': 'id', 'autoscale_pool_id': 'id', 'byoip_prefix_uuid': 'id', 'cdn_id': 'id', 'certificate_id': 'id', 'check_id': 'id', 'cluster_id': 'id', 'database_cluster_uuid': 'id', 'destination_uuid': 'id', 'domain_record_id': 'id', 'droplet_id': 'id', 'firewall_id': 'id', 'image_id': 'id', 'kubernetes_cluster_id': 'id', 'lb_id': 'id', 'load_balancer_id': 'id', 'logsink_id': 'id', 'nfs_id': 'id', 'node_pool_id': 'id', 'pa_id': 'id', 'project_id': 'id', 'sink_uuid': 'id', 'ssh_key_identifier': 'id', 'volume_id': 'id', 'vpc_id': 'id', 'vpc_peering_id': 'id', // Name-based identifiers 'access_key': 'access_key', // Spaces Keys - the URL parameter maps to access_key field 'domain_name': 'name', 'pool_name': 'name', 'registry_name': 'name', // Container Registry - uses 'name' field, not 'id' 'tag_id': 'name', 'topic_name': 'name', 'trigger_name': 'name', 'username': 'username', // UUID-based identifiers 'alert_uuid': 'uuid', // Monitoring Alert Policies 'namespace_id': 'namespace', // Functions - uses 'namespace' field (fn-xxx format), not 'uuid' 'resource_uuid': 'uuid', // Add-Ons 'uuid': 'uuid', // GradientAI Platform 'workspace_uuid': 'uuid', // IP-based identifiers 'floating_ip': 'ip', // Floating IPs use 'ip' field 'reserved_ip': 'ip', // Reserved IPs use 'ip' field 'reserved_ipv6': 'ip', // Reserved IPv6 use 'ip' field }; identifierField = paramToFieldMap[paramName] || paramName.replace(/_/g, ''); } } } // Determine update method (PUT or PATCH) // PATCH takes precedence if both exist (per buildHandlersFromOperations logic) let updateMethod: "PUT" | "PATCH" | undefined; if (patchOperation) { updateMethod = "PATCH"; } else if (putOperation) { updateMethod = "PUT"; } // Extract required query parameters from GET operation // Some resources (like NFS) require query parameters for GET/PUT/PATCH/DELETE const requiredQueryParams: string[] = []; if (getOperation?.parameters) { for (const param of getOperation.parameters) { if (param.in === "query" && param.required) { requiredQueryParams.push(param.name); } } } const onlyProperties: OnlyProperties = { createOnly: [], readOnly: [], writeOnly: [], primaryIdentifier: [identifierField], }; // createOnly: only in POST, not in PUT/PATCH createProperties.forEach((prop) => { if (!updateProperties.has(prop)) { onlyProperties.createOnly.push(prop); } }); // Special case: Spaces Keys can only update the name, not grants // The API docs explicitly state: "You can only update the name of the key" // but the OpenAPI spec incorrectly includes grants in PATCH body if (endpoint === "/v2/spaces/keys" && updateProperties.has("grants")) { updateProperties.delete("grants"); onlyProperties.createOnly.push("grants"); } // readOnly: in GET but not in POST or PUT/PATCH getProperties.forEach((prop) => { if (!createProperties.has(prop) && !updateProperties.has(prop)) { onlyProperties.readOnly.push(prop); } }); // writeOnly: in POST/PUT/PATCH but not in GET const writeProps = [...createProperties, ...updateProperties]; onlyProperties.writeOnly = [ ...new Set(writeProps.filter((prop) => !getProperties.has(prop))), ]; // Remove readOnly properties from domain const writableDomainProperties = Object.fromEntries( Object.entries(domainProperties).filter(([_, prop]) => !prop.readOnly), ); // Normalize domain properties (POST + PUT/PATCH = writable) const normalizedDomainProperties = Object.fromEntries( Object.entries(writableDomainProperties).map(([key, prop]) => [ key, normalizeDigitalOceanProperty(prop), ]), ); // Normalize resource_value properties (GET response = readable) const normalizedResourceValueProperties = Object.fromEntries( Object.entries(resourceValueProperties).map(([key, prop]) => [ key, normalizeDigitalOceanProperty(prop), ]), ); // Extract the doc tag from the first operation const firstOp = getOperation || postOperation || putOperation || patchOperation; const docTag = firstOp?.tags?.[0]; // Use provider-style naming: DigitalOcean Droplets (capitalized, preserving plurality) const schema: DigitalOceanSchema = { typeName: `DigitalOcean ${resourceName}`, description: schemaDescription, requiredProperties, handlers, endpoint, docTag, identifierField, updateMethod, requiredQueryParams: requiredQueryParams.length > 0 ? requiredQueryParams : undefined, }; return { schema, onlyProperties, domainProperties: normalizedDomainProperties as Record<string, CfProperty>, resourceValueProperties: normalizedResourceValueProperties as Record< string, CfProperty >, }; } export function buildHandlersFromOperations(operations: OperationData[]): { handlers: Record<CfHandlerKind, CfHandler>; getOperation: DigitalOceanOpenApiOperation | null; postOperation: DigitalOceanOpenApiOperation | null; putOperation: DigitalOceanOpenApiOperation | null; patchOperation: DigitalOceanOpenApiOperation | null; isGetOperationList: boolean; } { const handlers = {} as Record<CfHandlerKind, CfHandler>; let getOperation: DigitalOceanOpenApiOperation | null = null; let postOperation: DigitalOceanOpenApiOperation | null = null; let putOperation: DigitalOceanOpenApiOperation | null = null; let patchOperation: DigitalOceanOpenApiOperation | null = null; let isGetOperationList = false; operations.forEach(({ openApiDescription }) => { const defaultHandler = { permissions: [], timeoutInMinutes: 60 }; if (openApiDescription.get) { const op = openApiDescription.get; const isListOp = isListOperation(op); // Prefer READ over LIST for extracting resource properties if (!getOperation || (!isListOp && isGetOperationList)) { getOperation = op; isGetOperationList = isListOp; } handlers[isListOp ? "list" : "read"] = defaultHandler; } if (openApiDescription.patch) { patchOperation = openApiDescription.patch; handlers["update"] = defaultHandler; } if (openApiDescription.put) { putOperation = openApiDescription.put; // Only set update handler if PATCH doesn't already exist if (!handlers["update"]) { handlers["update"] = defaultHandler; } } if (openApiDescription.post) { postOperation = openApiDescription.post; handlers["create"] = defaultHandler; } if (openApiDescription.delete) { handlers["delete"] = defaultHandler; } }); return { handlers, getOperation, postOperation, putOperation, patchOperation, isGetOperationList, }; } export function extractPropertiesFromRequestBody( requestBody: DigitalOceanOpenApiRequestBody | undefined, ): NormalizedDigitalOceanSchema { const schema = requestBody?.content?.["application/json"]?.schema; if (!schema) { return { properties: {}, required: [] }; } // TODO deal with "oneOf" props // if (schema.oneOf && Array.isArray(schema.oneOf)) { // // Try to find the "single" variant - typically has "name" not "names" // let selectedSchema = schema.oneOf[0]; // // for (const alt of schema.oneOf) { // const flattened = flattenSchemaProperties(alt); // // Prefer schema with "name" property over "names" (single vs multi) // if (flattened.properties.name && !flattened.properties.names) { // selectedSchema = alt; // break; // } // } // return flattenSchemaProperties(selectedSchema); // } return flattenSchemaProperties(schema); } export function extractPropertiesFromResponse( response: DigitalOceanOpenApiResponse | undefined, isListOperation: boolean = false, ): NormalizedDigitalOceanSchema { let schema = response?.content?.["application/json"]?.schema; if (!schema) { return { properties: {}, required: [] }; } // For LIST operations, extract from the collection items if (isListOperation) { // Flatten first to resolve allOf const flattened = flattenSchemaProperties(schema); // DigitalOcean typically uses a wrapper object with the resource name as key // e.g., { droplets: [...] } or { volumes: [...] } // Find the first property that is an array for (const [key, propSchema] of Object.entries(flattened.properties ?? {})) { if (propSchema.type === "array" && propSchema.items) { schema = propSchema.items; return flattenSchemaProperties(schema); } } } else { // For single resource GET operations, DigitalOcean wraps the response // e.g., { droplet: {...} } or { volume: {...} } // Unwrap by taking the first property value const flattened = flattenSchemaProperties(schema); if (flattened.properties && Object.keys(flattened.properties).length > 0) { const firstPropSchema = Object.values(flattened.properties)[0]; if (firstPropSchema && firstPropSchema.properties) { return flattenSchemaProperties(firstPropSchema); } } } return flattenSchemaProperties(schema); } function flattenSchemaProperties( schema: NormalizedDigitalOceanSchema | undefined, ): NormalizedDigitalOceanSchema { if (!schema) { return { properties: {}, required: [] }; } const clone = _.cloneDeep(schema); const rootProp = { allOf: clone.allOf, anyOf: clone.anyOf, oneOf: clone.oneOf, properties: clone.properties || {}, required: clone.required || [], type: clone.type, } as NormalizedDigitalOceanSchema; const queue = [{ prop: rootProp, level: 0, path: "" }]; while (queue.length > 0) { const entry = queue.shift(); if (!entry) continue; const { prop, level, path } = entry; logger.verbose(`Parsing ${path} (lvl ${level})`) const { properties, items, required, type, } = flattenOfStatements(prop); prop.properties = properties; prop.required = required; prop.items = items; prop.type = type; prop.allOf = undefined; prop.anyOf = undefined; prop.oneOf = undefined; const subProps = Object.entries(prop.properties ?? {}); if (prop.items) { subProps.push(["$items", prop.items]); } for (const [name, childProp] of subProps) { queue.push({ prop: childProp, level: level + 1, path: `${path}/${name}` }); } } return { properties: rootProp.properties, required: rootProp.required }; } export function normalizeDigitalOceanProperty( prop: JsonSchemaObject, path = "", visited = new Set<JsonSchemaObject>(), ): JsonSchemaObject { if (visited.has(prop)) { throw new Error( `Cycle detected in schema at path: ${path || "(root)"}`, ); } // Validate input if (!prop || typeof prop !== "object") { throw new Error( `Invalid schema at path ${ path || "(root)" }: expected object, got ${typeof prop}`, ); } // Track this object to detect cycles const newVisited = new Set(visited); newVisited.add(prop); if (prop.type) { // normalize nested properties const normalized = { ...prop }; // Remove or normalize unsupported formats const format = normalized.format; if (format && typeof format === "string") { // Remove unsupported formats if ( [ "duration", "uuid", "email", "date", "date-time-rfc1123", "byte", "binary", "password", "ipv4", // TODO Joi can validate ips we should use this "ipv6", // TODO Joi can validate ips we should use this "cidr", ].includes(format) ) { delete normalized.format; } // Normalize integer formats to int64 else if ( normalized.type === "integer" && ["int32", "int64", "uint32"].includes(format) ) { normalized.format = "int64"; } // Normalize number formats to double else if ( normalized.type === "number" && ["float", "double", "decimal"].includes(format) ) { normalized.format = "double"; } else if ( normalized.type === "string" && ["hostname", "url"].includes(format) ) { normalized.format = "uri"; } } // Recursively normalize nested properties if (normalized.properties) { try { normalized.properties = Object.fromEntries( Object.entries(normalized.properties).map( ([key, value]) => { if (!value || typeof value !== "object") { throw new Error( `Invalid property "${key}" at path ${path}: expected object`, ); } return [ key, normalizeDigitalOceanProperty( value, path ? `${path}.${key}` : key, newVisited, ), ]; }, ), ); } catch (error) { throw new Error( `Error normalizing properties at path ${path || "(root)"}: ${ error instanceof Error ? error.message : String(error) }`, ); } } // Recursively normalize additionalProperties if ( normalized.additionalProperties && typeof normalized.additionalProperties === "object" ) { try { normalized.additionalProperties = normalizeDigitalOceanProperty( normalized.additionalProperties, path ? `${path}[additionalProperties]` : "[additionalProperties]", newVisited, ); } catch (error) { throw new Error( `Error normalizing additionalProperties at path ${ path || "(root)" }: ${error instanceof Error ? error.message : String(error)}`, ); } } // Recursively normalize array items if (normalized.items) { if (typeof normalized.items !== "object") { throw new Error( `Invalid items definition at path ${ path || "(root)" }: expected object`, ); } try { normalized.items = normalizeDigitalOceanProperty( normalized.items, path ? `${path}[items]` : "[items]", newVisited, ); } catch (error) { throw new Error( `Error normalizing items at path ${path || "(root)"}: ${ error instanceof Error ? error.message : String(error) }`, ); } } return normalized; } // Handle oneOf with primitive types - smoosh them like cfDb does for array types if (prop.oneOf) { if (!Array.isArray(prop.oneOf)) { throw new Error( `Invalid oneOf at path ${path || "(root)"}: expected array`, ); } const allPrimitives = prop.oneOf.every((member) => { if (!member || typeof member !== "object") { return false; } const type = member.type; return ( type === "string" || type === "number" || type === "integer" || type === "boolean" ); }); if (allPrimitives) { // Pick the non-string type (prefer number, integer, boolean over string) const nonStringMember = prop.oneOf.find( (member) => member.type !== "string", ); const smooshed = nonStringMember ? { ...prop, type: nonStringMember.type, oneOf: undefined } : { ...prop, type: "string", oneOf: undefined }; return normalizeDigitalOceanProperty(smooshed, path, newVisited); } } // Handle anyOf with primitive types - smoosh them like oneOf if (prop.anyOf) { if (!Array.isArray(prop.anyOf)) { throw new Error( `Invalid anyOf at path ${path || "(root)"}: expected array`, ); } const allPrimitives = prop.anyOf.every((member) => { if (!member || typeof member !== "object") { return false; } const type = member.type; return ( type === "string" || type === "number" || type === "integer" || type === "boolean" ); }); if (allPrimitives) { // Pick the non-string type (prefer number, integer, boolean over string) const nonStringMember = prop.anyOf.find( (member) => member.type !== "string", ); const smooshed = nonStringMember ? { ...prop, type: nonStringMember.type, anyOf: undefined } : { ...prop, type: "string", anyOf: undefined }; return normalizeDigitalOceanProperty(smooshed, path, newVisited); } } return prop; } export function mergePropertyDefinitions( existing: NormalizedDigitalOceanSchema | undefined, newProp: NormalizedDigitalOceanSchema, ): NormalizedDigitalOceanSchema { if (!existing) return newProp; if (existing.type !== newProp.type && newProp.type) { return { ...newProp }; } const merged = { ...existing }; // Merge enum values if both exist if (existing.enum && newProp.enum) { merged.enum = [...new Set([...existing.enum, ...newProp.enum])]; } else if (newProp.enum) { merged.enum = newProp.enum; } return merged; } function isListOperation(operation: DigitalOceanOpenApiOperation): boolean { const response = operation.responses?.["200"]; const schema = response?.content?.["application/json"]?.schema; if (!schema) { return false; } if (schema.type === "array") { return true; } // Check if response has properties that are arrays (DigitalOcean pattern) // e.g., { droplets: [...] } let properties = schema.properties; // If there's an allOf, merge properties from all schemas if (schema.allOf) { properties = {}; for (const subSchema of schema.allOf) { if (subSchema.properties) { Object.assign(properties, subSchema.properties); } } } if (properties) { // Check if any property is an array for (const propSchema of Object.values(properties)) { if ( typeof propSchema === "object" && (propSchema.type === "array" || propSchema.items !== undefined) ) { return true; } } } return false; } function flattenOfStatements(schema: NormalizedDigitalOceanSchema): NormalizedDigitalOceanSchema { let properties: Record<string, NormalizedDigitalOceanSchema> | undefined = {}; const requiredDuplicated: string[] | undefined = []; let items: NormalizedDigitalOceanSchema | undefined = undefined; let type = schema.type; if (schema.allOf) { for (const subSchema of schema.allOf) { if (!type) { type = subSchema.type; } else if (subSchema.type && subSchema.type !== type) { type = decideTypePrecedence(subSchema.type, type); } if (subSchema.required) requiredDuplicated.push(...subSchema.required); if (!items) { items = subSchema.items; } const flattened = flattenOfStatements(subSchema); if (flattened.properties) { Object.assign(properties, flattened.properties); } if (flattened.required) { requiredDuplicated.push(...flattened.required); } } } else if (schema.anyOf) { for (const subSchema of schema.anyOf) { if (!type) { type = subSchema.type; } else if (subSchema.type && subSchema.type !== type) { type = decideTypePrecedence(subSchema.type, type); } if (!items) { items = subSchema.items; } const flattened = flattenOfStatements(subSchema); if (flattened.properties) { Object.assign(properties, flattened.properties); } if (flattened.required) { requiredDuplicated.push(...flattened.required); } } } else if (schema.oneOf) { for (const subSchema of schema.oneOf) { if (!type) { type = subSchema.type; } else if (subSchema.type && subSchema.type !== type) { // Type precedence rules: string wins over numeric types type = decideTypePrecedence(subSchema.type, type); } if (!items) { items = subSchema.items; } const flattened = flattenOfStatements(subSchema); if (flattened.properties) { Object.assign(properties, flattened.properties); } if (flattened.required) { requiredDuplicated.push(...flattened.required); } } } // Apply schema's own properties on top to give them precedence Object.assign(properties, schema.properties); if (schema.required) { requiredDuplicated.push(...schema.required); } if (!items && schema.items) { items = schema.items; } // Deduplicate required properties const required = !type || type === "object" ? Array.from(new Set(requiredDuplicated)) : undefined; if (type && type !== "object") { properties = undefined; } return { properties, required, items, type, } } /// Decide which type wins when openapi declares that a property can be more than one type function decideTypePrecedence(t1: TypeValue, t2: TypeValue) { const precedenceKey = (t1: TypeValue, t2: TypeValue) => JSON.stringify([t1, t2].sort()); const typePrecedence = new Map<string, string>([ [precedenceKey('integer', 'string'), 'string'], [precedenceKey('number', 'string'), 'string'], [precedenceKey('integer', 'number'), 'number'], ]); const sortedKey = precedenceKey(t1, t2); const winningType = typePrecedence.get(sortedKey); if (!winningType) { throw new Error(`Could not find type precedence rule for types: ${t1} and ${t2}`); } return winningType; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server