Skip to main content
Glama
spec.ts25.9 kB
import _ from "lodash"; import { OnlyProperties } from "../../spec/props.ts"; import type { CfProperty } from "../types.ts"; import { CfHandler, CfHandlerKind } from "../types.ts"; import { type EntraOpenApiDocument, type EntraOpenApiOperation, type EntraOpenApiRequestBody, type EntraOpenApiResponse, type EntraSchema, type JsonSchemaObject, type JsonSchemaObjectOnly, type NormalizedEntraSchema, type OperationData, type PropertySet, } from "./schema.ts"; import { getGraphServiceCategory } from "./categories.ts"; export function mergeResourceOperations( resourceName: string, operations: OperationData[], description: string | undefined, allSchemas: EntraOpenApiDocument, ): { schema: EntraSchema; onlyProperties: OnlyProperties; domainProperties: Record<string, CfProperty>; resourceValueProperties: Record<string, CfProperty>; } | null { const { handlers, getOperation, postOperation, patchOperation, isGetOperationList, } = buildHandlersFromOperations(operations); // Use provided description or fall back to default const schemaDescription = description ?? `Microsoft Entra ${resourceName} resource`; 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), ); // Microsoft Graph uses "2XX" for success responses instead of "200" const getResponse = getOperation?.responses?.["200"] || getOperation?.responses?.["2XX"]; const getProps = extractPropertiesFromResponse( getResponse, isGetOperationList, ); const getProperties: PropertySet = new Set(Object.keys(getProps.properties)); const updateProperties: PropertySet = new Set(); // Merge PATCH into domain (POST + PATCH = writable properties) if (patchOperation) { const updateProps = extractPropertiesFromRequestBody( patchOperation.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 updateProps.required.forEach((prop) => requiredProperties.add(prop)); } const resourceValueProperties = { ...getProps.properties }; const onlyProperties: OnlyProperties = { createOnly: [], readOnly: [], writeOnly: [], primaryIdentifier: ["id"], }; // createOnly: only in POST, not in PATCH createProperties.forEach((prop) => { if (!updateProperties.has(prop)) { onlyProperties.createOnly.push(prop); } }); // readOnly: in GET but not in POST or PATCH getProperties.forEach((prop) => { if (!createProperties.has(prop) && !updateProperties.has(prop)) { onlyProperties.readOnly.push(prop); } }); // writeOnly: in POST/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), ); // Filter out OData metadata properties - these are server-generated and not user-controllable const filteredDomainProperties = Object.fromEntries( Object.entries(writableDomainProperties).filter(([key, _]) => !isODataMetadataProperty(key) ), ); // Also filter OData metadata from resource_value properties const filteredResourceValueProperties = Object.fromEntries( Object.entries(resourceValueProperties).filter(([key, _]) => !isODataMetadataProperty(key) ), ); // Extract schemas for discriminator expansion const schemas = allSchemas.components?.schemas as | Record<string, JsonSchemaObject> | undefined; const normalizer = new EntraNormalizer(schemas); // Normalize domain properties (POST + PATCH = writable) const normalizedDomainProperties = Object.fromEntries( Object.entries(filteredDomainProperties) .map(([key, prop]) => { const normalized = normalizer.normalize(prop); // Skip properties that are part of cycles if (!normalized) return null; return [key, normalized]; }) .filter((entry): entry is [string, NormalizedEntraSchema] => entry !== null ), ); // Normalize resource_value properties (GET response = readable) const normalizedResourceValueProperties = Object.fromEntries( Object.entries(filteredResourceValueProperties) .map(([key, prop]) => { const normalized = normalizer.normalize(prop); // Skip properties that are part of cycles if (!normalized) return null; return [key, normalized]; }) .filter((entry): entry is [string, NormalizedEntraSchema] => entry !== null ), ); // Use Azure-style naming: Microsoft.{Service}/{Resource} // E.g., Microsoft.Identity/users, Microsoft.Teams/teams const serviceCategory = getGraphServiceCategory(resourceName); const schema: EntraSchema = { typeName: `Microsoft.${serviceCategory}/${resourceName}`, description: schemaDescription, requiredProperties, handlers, endpoint: resourceName, }; 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: EntraOpenApiOperation | null; postOperation: EntraOpenApiOperation | null; patchOperation: EntraOpenApiOperation | null; isGetOperationList: boolean; } { const handlers = {} as Record<CfHandlerKind, CfHandler>; let getOperation: EntraOpenApiOperation | null = null; let postOperation: EntraOpenApiOperation | null = null; let patchOperation: EntraOpenApiOperation | 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.post) { postOperation = openApiDescription.post; handlers["create"] = defaultHandler; } if (openApiDescription.delete) { handlers["delete"] = defaultHandler; } }); return { handlers, getOperation, postOperation, patchOperation, isGetOperationList, }; } export function extractPropertiesFromRequestBody( requestBody: EntraOpenApiRequestBody | undefined, ): { properties: Record<string, NormalizedEntraSchema>; required: string[] } { const schema = requestBody?.content?.["application/json"]?.schema; return flattenSchemaProperties(schema); } export function extractPropertiesFromResponse( response: EntraOpenApiResponse | undefined, isListOperation: boolean = false, ): { properties: Record<string, NormalizedEntraSchema>; required: string[] } { let schema = response?.content?.["application/json"]?.schema; // For LIST operations, first flatten the schema, then extract from the collection items if (isListOperation && schema) { // Flatten first to resolve allOf const flattened = flattenSchemaProperties(schema); // Now check for the 'value' array property const valueSchema = flattened.properties.value; if (valueSchema) { // The value property is an array, so get the items schema const items = valueSchema.items; if (items) { schema = items; return flattenSchemaProperties(schema); } } } return flattenSchemaProperties(schema); } function flattenSchemaProperties( schema: NormalizedEntraSchema | undefined, ): { properties: Record<string, NormalizedEntraSchema>; required: string[] } { if (!schema) { return { properties: {}, required: [] }; } const properties: Record<string, NormalizedEntraSchema> = {}; const required: string[] = []; // Recursively merge properties from allOf members first if (schema.allOf && Array.isArray(schema.allOf)) { for (const member of schema.allOf) { const flattened = flattenSchemaProperties(member); // Merge properties from this allOf member Object.assign(properties, flattened.properties); // Merge required arrays required.push(...flattened.required); } } // Then add properties from the main schema (these take precedence) if (schema.properties) { Object.assign(properties, schema.properties); } // Add required from main schema if (schema.required) { required.push(...schema.required); } // Remove duplicates from required array const uniqueRequired = [...new Set(required)]; return { properties, required: uniqueRequired }; } // Helper: Expand discriminators by finding matching subtypes and adding them as properties function expandEntraDiscriminators( discriminator: JsonSchemaObjectOnly["discriminator"], properties: JsonSchemaObjectOnly["properties"], schemas: Record<string, JsonSchemaObject>, ): { expandedProperties: Record<string, JsonSchemaObject>; discriminators: Record<string, Record<string, string>>; } | undefined { if (!discriminator || typeof discriminator !== "object") return undefined; if (!properties) return undefined; const discriminatorProp = discriminator.propertyName; const mapping = discriminator.mapping; if (!discriminatorProp || !mapping) return undefined; // Skip @odata.type discriminators - they have 1055+ subtypes (all entity types) // and are not useful for property type narrowing if (discriminatorProp === "@odata.type") { return undefined; } const expandedProperties: Record<string, JsonSchemaObject> = { ...properties, }; // Replace the discriminator field with an object containing the subtypes const discriminatorObject: JsonSchemaObject = { type: "object", properties: {}, }; const discriminatorMap: Record<string, string> = {}; // Format: "#microsoft.graph.application": "#/components/schemas/microsoft.graph.application" for (const [discriminatorValue, schemaRef] of Object.entries(mapping)) { // Extract schema name from ref like "#/components/schemas/microsoft.graph.application" const schemaName = String(schemaRef).replace( "#/components/schemas/", "", ); const subtypeSchema = schemas[schemaName]; if (!subtypeSchema) continue; // Handle boolean schemas if (typeof subtypeSchema === "boolean") continue; // Flatten allOf to get all properties from parent and child schemas const flattened = flattenSchemaProperties( subtypeSchema as NormalizedEntraSchema, ); // Store the subtype's properties (flattened from allOf) const subtypeProps: JsonSchemaObject = { type: "object", description: subtypeSchema.description, properties: flattened.properties, required: flattened.required, }; // Use schema name as the key (cleaner than the full discriminator value) discriminatorObject.properties![schemaName] = subtypeProps; discriminatorMap[schemaName] = discriminatorValue; } expandedProperties[discriminatorProp] = discriminatorObject; const discriminators = { [discriminatorProp]: discriminatorMap, }; return { expandedProperties, discriminators, }; } // Helper: Merge two schemas by intersecting their properties function intersectEntraSchema( prop: NormalizedEntraSchema, intersected: NormalizedEntraSchema, ): NormalizedEntraSchema { if (!prop) return intersected; if (!intersected) return prop; // Merge properties from both schemas for (const key of Object.keys(prop) as (keyof NormalizedEntraSchema)[]) { if (prop[key] === undefined) continue; switch (key) { // Merge nested properties recursively case "properties": { if (!prop.properties) break; intersected.properties ??= {}; for (const [propName, childProp] of Object.entries(prop.properties)) { intersected.properties[propName] = intersectEntraSchema( childProp as NormalizedEntraSchema, intersected.properties[propName] as NormalizedEntraSchema, ); } break; } // Merge array items and additionalProperties case "items": case "additionalProperties": { intersected[key] = intersectEntraSchema( prop[key] as NormalizedEntraSchema, intersected[key] as NormalizedEntraSchema, ); break; } // Merge arrays without duplicates case "enum": case "required": { if (prop[key]) { intersected[key] = (intersected[key] ?? []).concat( prop[key].filter((v) => !intersected[key]?.includes(v)), ); } break; } // Override with new values case "title": case "description": intersected[key] = prop[key]; break; // Skip these - already handled case "allOf": case "anyOf": case "oneOf": break; // Prefer false (writable) over true (readonly) case "readOnly": { if (intersected.readOnly !== undefined) { intersected.readOnly = intersected.readOnly && prop.readOnly; } else { intersected.readOnly = prop.readOnly; } break; } // For other fields, just copy if not already set default: { if (intersected[key] === undefined) { intersected[key] = prop[key]; } break; } } } return intersected; } // Helper: Union two schemas (used for anyOf/oneOf) const MORE_SPECIFIC_THAN_STRING = ["number", "integer", "boolean"]; function unionEntraSchema( prop: NormalizedEntraSchema | undefined, unioned: NormalizedEntraSchema | undefined, ): NormalizedEntraSchema | undefined { if (!prop) return unioned; if (!unioned) return { ...prop }; // Prefer more specific types over string if ( unioned.type === "string" && MORE_SPECIFIC_THAN_STRING.includes(prop.type as string) ) { unioned.type = prop.type; } else if ( prop.type === "string" && MORE_SPECIFIC_THAN_STRING.includes(unioned.type as string) ) { prop.type = unioned.type; } return intersectEntraSchema(prop, unioned); } // Maximum depth of full resource property expansion (below this, resources are assumed // to be references and will just have "id") const MAX_EXPANDED_RESOURCE_DEPTH = 1; const MAX_NORMALIZATION_DEPTH = 10; export class EntraNormalizer { constructor(schemas?: Record<string, JsonSchemaObject>) { this.schemas = schemas; } normalizing: JsonSchemaObject[] = []; schemas?: Record<string, JsonSchemaObject>; discriminatorCollector: Record<string, Record<string, string>> = {}; /// Normalize a general JSONSchema from Entra into simpler format without nesting normalize(prop: JsonSchemaObject): NormalizedEntraSchema; normalize( prop: JsonSchemaObject | undefined, ): NormalizedEntraSchema | undefined; normalize( prop: JsonSchemaObject | undefined, ): NormalizedEntraSchema | undefined { // This is only meant to be called at the top level, non-recursively if (prop === undefined) return undefined; if (this.normalizing.length !== 0) { throw new Error("normalize() should only be called at top level"); } const result = this.normalizeOrCycle(prop, "", 0); if (!result) return undefined; // Stub deeply nested resources this.stubResourceReferences(result, 0); return result; } private normalizeOrCycle( prop: JsonSchemaObject, path = "", depth = 0, ): NormalizedEntraSchema | undefined { // Check for excessive depth if (depth > MAX_NORMALIZATION_DEPTH) { return undefined; } // Check for cycles BEFORE any object copying if (this.normalizing.includes(prop)) { return undefined; // Cycle detected } this.normalizing.push(prop); // Track ORIGINAL object try { // Flatten the schema, merging allOf props and such, before we normalize type/format const normalized = this.flatten(prop, {}, path, depth); if (normalized === undefined) return undefined; // Infer type from properties / items if missing normalized.type ??= this.inferType(normalized); // Normalize formats to SI-supported ones if (normalized.format) { const format = normalized.format as string; if ( [ "duration", "uuid", "email", "date", "time", "date-time-rfc1123", "byte", "binary", "password", "uri", ].includes(format) ) { delete normalized.format; } // Normalize integer formats to int64 else if ( normalized.type === "integer" && (format === "int16" || format === "int32" || format === "int64" || format === "uint8") ) { normalized.format = "int64"; } // Normalize number formats to double else if ( normalized.type === "number" && (format === "float" || format === "double" || format === "decimal" || format === "int16" || format === "int32" || format === "uint8") ) { normalized.format = "double"; } } // At the root level, attach collected discriminators if ( this.discriminatorCollector && Object.keys(this.discriminatorCollector).length > 0 ) { normalized.discriminators = this.discriminatorCollector; } return normalized; } finally { this.normalizing.pop(); // Always clean up } } private inferType(prop: NormalizedEntraSchema): string | undefined { if (prop.type) { // Handle array of types (e.g., ["string", "null"]) if (Array.isArray(prop.type)) { // Return the first non-null type, or the first type if all are null const nonNullType = prop.type.find((t) => t !== "null"); return (nonNullType || prop.type[0]) as string; } return prop.type as string; } if (prop.properties || prop.additionalProperties) return "object"; if (prop.items) return "array"; return undefined; } private flatten( schemaProp: JsonSchemaObject, flattened: NormalizedEntraSchema = {}, path = "", depth = 0, ): NormalizedEntraSchema | undefined { // Handle boolean schemas up front (JSON Schema allows true/"any" and false/"never") if (schemaProp === false) { throw new Error( `Boolean schema 'false' (never) not supported at path: ${ path || "(root)" }`, ); } // "any" becomes empty schema (which matches anything) if (schemaProp === true) { schemaProp = {}; } // Pull off the stuff we're removing and children we're normalizing const { oneOf, anyOf, allOf, properties, items, additionalProperties, discriminator, ...rest } = schemaProp; let expandedProperties = properties; // Merge oneOf and anyOf by normalizing each alternative and then merging them if (oneOf) { for (const alternative of oneOf) { const child = this.normalizeOrCycle(alternative, path, depth + 1); if (!child) continue; // Skip cycles unionEntraSchema(child, flattened); } } if (anyOf) { for (const alternative of anyOf) { const child = this.normalizeOrCycle(alternative, path, depth + 1); if (!child) continue; // Skip cycles unionEntraSchema(child, flattened); } } // Merge allOf types into the flattened type // We don't normalize here, because allOf children can be *partial* properties if (allOf) { for (const alternative of allOf) { this.flatten(alternative, flattened, path, depth); } } // Expand discriminators AFTER allOf processing if (this.schemas) { const expansion = expandEntraDiscriminators( discriminator, properties, this.schemas, ); if (expansion) { expandedProperties = expansion.expandedProperties; // Add discriminators to collector if (this.discriminatorCollector) { for ( const [key, subtypeMap] of Object.entries(expansion.discriminators) ) { this.discriminatorCollector[key] = subtypeMap; } } } } // Normalize child schemas (properties, items, etc.) const prop: NormalizedEntraSchema = rest as NormalizedEntraSchema; if (expandedProperties) { prop.properties = {}; if (Object.keys(expandedProperties).length > 0) { for ( const [propName, childProp] of Object.entries(expandedProperties) ) { const child = this.normalizeOrCycle( childProp, path ? `${path}.${propName}` : propName, depth + 1, ); if (!child) continue; // If the prop is part of a cycle, don't include it prop.properties[propName] = child; } // If all props were part of cycles, this prop is part of the cycle if (Object.keys(prop.properties).length == 0) return undefined; } } if (items) { prop.items = this.normalizeOrCycle( items, path ? `${path}[items]` : "[items]", depth + 1, ); if (prop.items === undefined) return undefined; } if (additionalProperties) { prop.additionalProperties = this.normalizeOrCycle( additionalProperties, path ? `${path}[additionalProperties]` : "[additionalProperties]", depth + 1, ); if (prop.additionalProperties === undefined) return undefined; } // Finally, intersect the props together intersectEntraSchema(prop, flattened); return flattened; } private stubResourceReferences( prop: NormalizedEntraSchema | undefined, resourceDepth: number, ): void { if (!prop) return; // Detect Microsoft Graph resources/entities // Graph entities have an 'id' property and typically multiple other properties if ( prop.properties && "id" in prop.properties && Object.keys(prop.properties).length >= 3 ) { resourceDepth += 1; // Stub the resource if we're at max depth - keep only the ID! if (resourceDepth > MAX_EXPANDED_RESOURCE_DEPTH) { prop.properties = { id: prop.properties.id }; return; } } // Recursively check nested properties if (prop.properties) { for (const childProp of Object.values(prop.properties)) { this.stubResourceReferences( childProp as NormalizedEntraSchema, resourceDepth, ); } } // Arrays/maps don't increment depth counter if (prop.items) { this.stubResourceReferences( prop.items as NormalizedEntraSchema, resourceDepth, ); } if (prop.additionalProperties) { this.stubResourceReferences( prop.additionalProperties as NormalizedEntraSchema, resourceDepth, ); } } } export function mergePropertyDefinitions( existing: NormalizedEntraSchema | undefined, newProp: NormalizedEntraSchema, ): NormalizedEntraSchema { 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: EntraOpenApiOperation): boolean { const response = operation.responses?.["200"] || operation.responses?.["2XX"]; const schema = response?.content?.["application/json"]?.schema; if (!schema) { return false; } if (schema.type === "array") { return true; } // Check if response has a 'value' property that is an array (collection endpoint pattern) // Need to check both direct properties and allOf 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?.value) { const valueSchema = properties.value; if (valueSchema.type === "array" || valueSchema.items !== undefined) { return true; } } return false; } function isODataMetadataProperty(propName: string): boolean { return propName.startsWith("@odata."); }

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