From 2d6dbc79d9b1cb1c29b72b86c18b873ec5ff7b78 Mon Sep 17 00:00:00 2001 From: Aditya Bansal Date: Tue, 23 Sep 2025 10:52:05 -0700 Subject: [PATCH 1/8] wip --- src/server.py | 6 + src/utils/openapi_resolver.py | 122 +++++ swagger.json | 892 +++++++++++++++++++++++++++++++++- test_fix.py | 55 +++ test_mcp_stdio.py | 85 ++++ test_mcp_tools.py | 97 ++++ test_ref_issue.py | 107 ++++ test_resolution.py | 22 + tests/manual/client.py | 4 +- 9 files changed, 1381 insertions(+), 9 deletions(-) create mode 100644 src/utils/openapi_resolver.py create mode 100644 test_fix.py create mode 100644 test_mcp_stdio.py create mode 100644 test_mcp_tools.py create mode 100644 test_ref_issue.py create mode 100644 test_resolution.py diff --git a/src/server.py b/src/server.py index 17c9b49..f6a3da4 100644 --- a/src/server.py +++ b/src/server.py @@ -9,6 +9,7 @@ from .config import Config from .routes.mappers import custom_route_mapper from .utils.logging import setup_logging +from .utils.openapi_resolver import resolve_refs logger = setup_logging() @@ -38,6 +39,11 @@ def create_mcp_server() -> FastMCP: openapi_spec = load_openapi_spec() + # Resolve all $ref references to work around FastMCP issue + logger.info("Resolving OpenAPI $ref references...") + openapi_spec = resolve_refs(openapi_spec) + logger.info("OpenAPI $ref references resolved") + client = create_cortex_client() mcp_server = FastMCP.from_openapi( diff --git a/src/utils/openapi_resolver.py b/src/utils/openapi_resolver.py new file mode 100644 index 0000000..2fe938b --- /dev/null +++ b/src/utils/openapi_resolver.py @@ -0,0 +1,122 @@ +"""OpenAPI $ref resolver for FastMCP compatibility.""" + +from typing import Any + + +def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]: + """ + Recursively resolve all $ref references in an OpenAPI specification. + + This is a workaround for FastMCP's issue with $ref handling where it + doesn't properly include schema definitions when creating tool input schemas. + + Args: + spec: OpenAPI specification dictionary + + Returns: + Modified spec with all $refs resolved inline + """ + # Create a copy to avoid modifying the original + spec = spec.copy() + + # Get the components/schemas section for reference resolution + schemas = spec.get("components", {}).get("schemas", {}) + + def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any: + """Recursively resolve $ref in an object.""" + if visited is None: + visited = set() + + if isinstance(obj, dict): + # Check if this is a $ref + if "$ref" in obj and len(obj) == 1: + ref_path = obj["$ref"] + + # Prevent infinite recursion + if ref_path in visited: + # Return the ref as-is to avoid infinite loop + return obj + + visited.add(ref_path) + + # Extract schema name from reference + if ref_path.startswith("#/components/schemas/"): + schema_name = ref_path.split("/")[-1] + if schema_name in schemas: + # Recursively resolve the referenced schema + resolved = resolve_schema(schemas[schema_name].copy(), visited) + visited.remove(ref_path) + return resolved + + # If we can't resolve, return as-is + visited.remove(ref_path) + return obj + else: + # Recursively process all values in the dict + result = {} + for key, value in obj.items(): + result[key] = resolve_schema(value, visited) + return result + + elif isinstance(obj, list): + # Recursively process all items in the list + return [resolve_schema(item, visited) for item in obj] + else: + # Return primitive values as-is + return obj + + # Resolve refs in all paths + if "paths" in spec: + spec["paths"] = resolve_schema(spec["paths"]) + + return spec + + +def resolve_refs_with_defs(spec: dict[str, Any]) -> dict[str, Any]: + """ + Alternative approach: Keep $refs but ensure $defs section is populated. + + This transforms OpenAPI $refs to JSON Schema format and includes + all referenced schemas in a $defs section at the root level. + + Args: + spec: OpenAPI specification dictionary + + Returns: + Modified spec with $refs pointing to $defs and all definitions included + """ + # Create a copy to avoid modifying the original + spec = spec.copy() + + # Get the components/schemas section + schemas = spec.get("components", {}).get("schemas", {}) + + # Create $defs section at root level + if schemas: + spec["$defs"] = schemas.copy() + + def transform_refs(obj: Any) -> Any: + """Transform OpenAPI $refs to JSON Schema $refs.""" + if isinstance(obj, dict): + result = {} + for key, value in obj.items(): + if key == "$ref" and isinstance(value, str): + # Transform the reference format + if value.startswith("#/components/schemas/"): + schema_name = value.split("/")[-1] + result[key] = f"#/$defs/{schema_name}" + else: + result[key] = value + else: + result[key] = transform_refs(value) + return result + elif isinstance(obj, list): + return [transform_refs(item) for item in obj] + else: + return obj + + # Transform all refs in paths + if "paths" in spec: + spec["paths"] = transform_refs(spec["paths"]) + + return spec \ No newline at end of file diff --git a/swagger.json b/swagger.json index 3e9540b..ec57bb2 100644 --- a/swagger.json +++ b/swagger.json @@ -157,6 +157,12 @@ }, { "name": "[Integrations] SonarQube" + }, + { + "name": "public-eng-intel-metrics-controller" + }, + { + "name": "public-eng-intel-registry-controller" } ], "paths": { @@ -2487,10 +2493,21 @@ "operationId": "deleteEntitiesByType", "parameters": [ { - "description": "A list of entity types to delete", + "description": "A list of entity types or IDs delete", "in": "query", "name": "types", - "required": true, + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "ids", + "required": false, "schema": { "type": "array", "items": { @@ -2736,6 +2753,101 @@ "x-cortex-mcp-enabled": "true" } }, + "/api/v1/catalog/batch/{type}": { + "get": { + "description": "Gets a list of entities based on type and batch key plus identifier", + "operationId": "getEntitiesByBatchIdentifier", + "parameters": [ + { + "description": "The type name of the batches to retrieve. This corresponds to the `x-cortex-type` field in the entity descriptor.", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The batch key to filter entities by. This is a custom field set as entity metadata `__cortex_batch.key`", + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The batch version. This is a custom field set as entity metadata `__cortex_batch.identifier`, required if `compare` is specified", + "in": "query", + "name": "version", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The type over comparison to perform. Ignored unless `version` parameter must also be provided. Values can be `eq`, `ne`.", + "in": "query", + "name": "compare", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "includeArchived", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "description": "Number of results to return per page, between 1 and 1000. Default 250.", + "in": "query", + "name": "pageSize", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "default": 250 + } + }, + { + "description": "Page number to return, 0-indexed. Default 0.", + "in": "query", + "name": "page", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntityCIDListResponse" + } + } + }, + "description": "List of entity identifiers matching the batch key and type, based on the specified compare parameter." + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + }, + "summary": "Get entities by batch identifier", + "tags": [ + "Catalog Entities" + ], + "x-cortex-mcp-enabled": "false" + } + }, "/api/v1/catalog/custom-data": { "delete": { "description": "Use this endpoint when attempting to delete custom data where the key contains non-alphanumeric characters. Otherwise, use the standard API under `Custom Data`.", @@ -9393,6 +9505,95 @@ "x-cortex-mcp-enabled": "false" } }, + "/api/v1/eng-intel/metrics/point-in-time": { + "post": { + "operationId": "pointInTimeMetrics", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Batch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PointInTimeResponse" + } + } + }, + "description": "OK" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + }, + "tags": [ + "public-eng-intel-metrics-controller" + ], + "x-cortex-mcp-description": "Execute point-in-time queries for one or more engineering metrics in a single request.\n\n Returns current metric values for specified time periods, with support for batch processing\n and optional period-over-period comparisons.\n\n Request body supports:\n - Multiple metrics: Query several metrics efficiently in one call\n - Time range (startTime/endTime): Limited to 6 months maximum for performance\n - Flexible filtering: Use filters available for each metric (varies by source)\n - Grouping options: Group results by available dimensions (author, repo, team, etc.)\n - Aggregation methods: COUNT, SUM, AVG, MIN, MAX per metric requirements\n - Period comparisons: Optional comparison to previous time windows\n - Nested queries: Advanced nested metric calculations\n\n Response includes:\n - Lightweight metadata: Column definitions optimized for programmatic use\n - Row data: Actual metric values and dimensional data\n - No heavy schemas: Source definitions excluded (get from Registry API instead)\n\n Error responses:\n - 400: Invalid metric names, date range, validation errors, or unsupported metric combinations\n - 403: Feature not enabled (contact help@cortex.io)", + "x-cortex-mcp-enabled": "true" + } + }, + "/api/v1/eng-intel/registry/metrics/definitions": { + "get": { + "operationId": "listMetricDefinitions", + "parameters": [ + { + "in": "query", + "name": "view", + "required": false, + "schema": { + "type": "string", + "default": "basic" + } + }, + { + "in": "query", + "name": "key", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BasicMetricDefinitionsResponse" + }, + { + "$ref": "#/components/schemas/FullMetricDefinitionsResponse" + } + ] + } + } + }, + "description": "OK" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + }, + "tags": [ + "public-eng-intel-registry-controller" + ], + "x-cortex-mcp-description": "List all available engineering metric definitions.\n\n Query parameters:\n - view: \u0027basic\u0027 (default) returns minimal info, \u0027full\u0027 includes sources and query metadata\n - key: Filter metrics by key (supports multiple values and comma-separated lists)\n\n Error responses:\n - 400: Invalid view parameter (must be \u0027basic\u0027 or \u0027full\u0027)\n - 403: Restricted Feature (contact help@cortex.io)\n\n Filter operators by type (for constructing queries):\n - STRING: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, LIKE, NOT_LIKE, IN, NOT_IN, ANY\n - INTEGER/DECIMAL/DOUBLE: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IN, NOT_IN, BETWEEN, ANY\n - DATETIME/DATE: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, BETWEEN\n - BOOLEAN: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, IN, NOT_IN\n - ARRAY: EQUAL, CONTAINS, IN", + "x-cortex-mcp-enabled": "true" + } + }, "/api/v1/github/configurations": { "delete": { "operationId": "GitHubDeleteConfigurations", @@ -15406,6 +15607,59 @@ "x-cortex-mcp-enabled": "false" } }, + "/api/v1/scorecards/{tag}/entity/{entityTag}/scores": { + "post": { + "description": "Triggers score evaluation for entity scorecard", + "operationId": "refreshScorecardScoreForEntity", + "parameters": [ + { + "description": "Unique tag for the Scorecard", + "example": "my-production-readiness-checklist", + "in": "path", + "name": "tag", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The entity tag (`x-cortex-tag`) that identifies the entity.", + "in": "path", + "name": "entityTag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scorecard score evaluation triggered successfully" + }, + "403": { + "description": "Unauthorized" + }, + "404": { + "description": "Scorecard not found" + }, + "409": { + "description": "Already evaluating scorecard" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "description": "Scorecard evaluation failed" + } + }, + "summary": "Evaluate entity scorecard score", + "tags": [ + "Scorecards" + ], + "x-cortex-mcp-description": "Cortex Scorecards API - Measure and track service quality, compliance, and maturity levels across your organization with customizable scorecards and rules", + "x-cortex-mcp-enabled": "false" + } + }, "/api/v1/scorecards/{tag}/next-steps": { "get": { "operationId": "getScorecardNextStepsForEntity", @@ -18323,6 +18577,77 @@ } } }, + "Attribute": { + "required": [ + "name", + "type" + ], + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of what this field contains" + }, + "name": { + "type": "string", + "description": "Field name for use in queries" + }, + "type": { + "type": "string", + "description": "Data type - determines which filter operators are supported (see endpoint description)", + "enum": [ + "STRING", + "BOOLEAN", + "INTEGER", + "DECIMAL", + "DOUBLE", + "DATE", + "TREE", + "DATETIME", + "JSON", + "ARRAY", + "UNSUPPORTED" + ] + } + }, + "description": "Queryable fields available on this dimension" + }, + "AttributeFilterObject": { + "required": [ + "attribute", + "operator", + "predicate" + ], + "type": "object", + "properties": { + "attribute": { + "type": "string" + }, + "operator": { + "type": "string", + "enum": [ + "ANY", + "BETWEEN", + "CONTAINS", + "EQUAL", + "GREATER_THAN", + "GREATER_THAN_OR_EQUAL", + "IN", + "IS_NOT_NULL", + "IS_NULL", + "LESS_THAN", + "LESS_THAN_OR_EQUAL", + "LIKE", + "NOT_EQUAL", + "NOT_IN", + "NOT_LIKE" + ] + }, + "predicate": { + "type": "object" + } + } + }, "AuditLogResponse": { "required": [ "action", @@ -18757,7 +19082,134 @@ } ] }, - "BulkAwsConfigurationsRequest": { + "BasicMetricDefinition": { + "required": [ + "displayName", + "key", + "readiness" + ], + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of what this metric measures" + }, + "displayName": { + "type": "string", + "description": "Human-readable name" + }, + "key": { + "type": "string", + "description": "Unique identifier for the metric (used in queries)" + }, + "readiness": { + "$ref": "#/components/schemas/Readiness" + } + }, + "description": "List of available metric definitions with basic metadata" + }, + "BasicMetricDefinitionsResponse": { + "required": [ + "metrics" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MetricDefinitionsResponseObject" + }, + { + "type": "object", + "properties": { + "metrics": { + "type": "array", + "description": "List of available metric definitions with basic metadata", + "items": { + "$ref": "#/components/schemas/BasicMetricDefinition" + } + } + } + } + ] + }, + "Batch": { + "required": [ + "endTime", + "filters", + "groupBy", + "limit", + "metrics", + "orderBy", + "startTime" + ], + "type": "object", + "properties": { + "comparison": { + "oneOf": [ + { + "$ref": "#/components/schemas/TimeWindow" + } + ] + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AttributeFilterObject" + } + }, + "groupBy": { + "type": "array", + "items": { + "type": "string" + } + }, + "limit": { + "type": "integer", + "format": "int32" + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricAggregation" + } + }, + "nestedGroupBy": { + "type": "array", + "items": { + "type": "string" + } + }, + "nestedMetrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricAggregation" + } + }, + "nestedTimeAttribute": { + "type": "string" + }, + "nextPage": { + "type": "string" + }, + "orderBy": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderBy" + } + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "timeAttribute": { + "type": "string" + } + } + }, + "BulkAwsConfigurationsRequest": { "required": [ "configurations" ], @@ -19265,6 +19717,33 @@ } } }, + "Column": { + "required": [ + "alias", + "name", + "ordinal" + ], + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "ordinal": { + "type": "integer", + "format": "int32" + } + }, + "description": "List of column definitions describing each field in the result rows" + }, "CompoundFilter": { "type": "object", "properties": { @@ -21279,6 +21758,32 @@ } } }, + "Dimension": { + "required": [ + "attributes", + "description", + "name" + ], + "type": "object", + "properties": { + "attributes": { + "type": "array", + "description": "Queryable fields available on this dimension", + "items": { + "$ref": "#/components/schemas/Attribute" + } + }, + "description": { + "type": "string", + "description": "Description of the relationship this dimension represents" + }, + "name": { + "type": "string", + "description": "Dimension identifier for use in queries" + } + }, + "description": "Related entities that can be joined for additional context" + }, "DiscoveryAuditEvent": { "required": [ "isIgnored", @@ -21527,6 +22032,39 @@ }, "description": "Emphasized rules for the Initiative. Either emphasized levels or rules must be provided" }, + "EntityCIDListResponse": { + "required": [ + "ids", + "page", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "ids": { + "type": "array", + "description": "Unique, immutable, 18-character auto-generated identifier for the entity.", + "example": "en2da8159dbeefb974", + "items": { + "type": "string", + "description": "Unique, immutable, 18-character auto-generated identifier for the entity.", + "example": "en2da8159dbeefb974" + } + }, + "page": { + "type": "integer", + "format": "int32" + }, + "total": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, "EntityDestinationsResponse": { "required": [ "destinations" @@ -22412,6 +22950,115 @@ }, "description": "Filter applied to the Initiative" }, + "FilterOption": { + "required": [ + "displayName", + "filter", + "key" + ], + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "Human-readable label for this filter" + }, + "filter": { + "type": "string", + "description": "Specific attribute to filter on" + }, + "key": { + "type": "string", + "description": "Field key to reference in queries" + } + }, + "description": "Available filtering options for querying metrics" + }, + "FullMetricDefinition": { + "required": [ + "displayName", + "key", + "readiness" + ], + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of what this metric measures" + }, + "displayName": { + "type": "string", + "description": "Human-readable name" + }, + "key": { + "type": "string", + "description": "Unique identifier for the metric (used in queries)" + }, + "readiness": { + "$ref": "#/components/schemas/Readiness" + }, + "sourceRef": { + "type": "string", + "description": "Reference to the data source in the sources map - indicates which integration provides this metric" + }, + "timeAttribute": { + "$ref": "#/components/schemas/Attribute" + }, + "type": { + "type": "string", + "description": "Type of metric: COUNT (discrete values), DURATION (time-based), RATIO/RATE (percentages), VALUE (continuous)", + "enum": [ + "DURATION", + "COUNT", + "VALUE", + "RATE" + ] + }, + "units": { + "type": "string", + "description": "Units of measurement (e.g., \u0027seconds\u0027, \u0027count\u0027, \u0027percentage\u0027)" + }, + "valence": { + "type": "string", + "description": "Whether higher values are better (POSITIVE) or worse (NEGATIVE) for this metric", + "enum": [ + "POSITIVE", + "NEGATIVE" + ] + } + }, + "description": "List of available metric definitions with full metadata" + }, + "FullMetricDefinitionsResponse": { + "required": [ + "metrics", + "sources" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MetricDefinitionsResponseObject" + }, + { + "type": "object", + "properties": { + "metrics": { + "type": "array", + "description": "List of available metric definitions with full metadata", + "items": { + "$ref": "#/components/schemas/FullMetricDefinition" + } + }, + "sources": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Source" + }, + "description": "Map of source names to source metadata for query construction" + } + } + } + ] + }, "GCP": { "required": [ "projectId", @@ -23059,6 +23706,24 @@ } } }, + "GroupByOption": { + "required": [ + "displayName", + "key" + ], + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "Human-readable label for this grouping" + }, + "key": { + "type": "string", + "description": "Field key to use in GROUP BY clause" + } + }, + "description": "Available grouping options for aggregating metrics" + }, "GroupFilter": { "type": "object", "properties": { @@ -24110,6 +24775,53 @@ }, "description": "Custom data key/values associated with the entity." }, + "MetricAggregation": { + "required": [ + "aggregation", + "metric" + ], + "type": "object", + "properties": { + "aggregation": { + "type": "string", + "enum": [ + "SUM", + "AVG", + "COUNT", + "RATIO", + "MIN", + "MAX", + "P50", + "P95", + "RANKING" + ] + }, + "metric": { + "type": "string" + } + } + }, + "MetricDefinitionsResponseObject": { + "required": [ + "metrics", + "view" + ], + "type": "object", + "properties": { + "metrics": { + "type": "array", + "items": { + "type": "object" + } + }, + "view": { + "type": "string" + } + }, + "discriminator": { + "propertyName": "view" + } + }, "ModifiedRuleExemptionsResponse": { "required": [ "exemptions" @@ -25060,6 +25772,25 @@ } } }, + "OrderBy": { + "required": [ + "attribute", + "direction" + ], + "type": "object", + "properties": { + "attribute": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ] + } + } + }, "OwnerGroup": { "required": [ "groupName" @@ -25392,8 +26123,7 @@ }, "lastUpdated": { "type": "string", - "description": "When the plugin was last updated", - "format": "date-time" + "description": "When the plugin was last updated" }, "minimumRoleRequired": { "type": "string", @@ -25456,8 +26186,7 @@ }, "lastUpdated": { "type": "string", - "description": "When the plugin was last updated", - "format": "date-time" + "description": "When the plugin was last updated" }, "minimumRoleRequired": { "type": "string", @@ -25513,6 +26242,40 @@ } } }, + "PointInTimeComparison": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "discriminator": { + "propertyName": "type" + } + }, + "PointInTimeResponse": { + "required": [ + "metadata", + "rows" + ], + "type": "object", + "properties": { + "metadata": { + "$ref": "#/components/schemas/QueryMetadata" + }, + "rows": { + "type": "array", + "description": "Result rows containing metric values and dimensions - each row corresponds to the columns in metadata", + "items": { + "type": "object", + "description": "Result rows containing metric values and dimensions - each row corresponds to the columns in metadata" + } + } + } + }, "PrometheusConfiguration": { "required": [ "alias", @@ -25619,6 +26382,22 @@ } } }, + "QueryMetadata": { + "required": [ + "columns" + ], + "type": "object", + "properties": { + "columns": { + "type": "array", + "description": "List of column definitions describing each field in the result rows", + "items": { + "$ref": "#/components/schemas/Column" + } + } + }, + "description": "Query metadata describing the structure of returned columns" + }, "QueryRequest": { "required": [ "query" @@ -25698,6 +26477,21 @@ } } }, + "Readiness": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "description": "Whether this metric is ready to use - includes hints if configuration is needed", + "discriminator": { + "propertyName": "type" + } + }, "RejectRuleExemptionRequest": { "required": [ "reason", @@ -26616,6 +27410,56 @@ } } }, + "Source": { + "required": [ + "attributes", + "description", + "dimensions", + "filterOptions", + "groupByOptions", + "name" + ], + "type": "object", + "properties": { + "attributes": { + "type": "array", + "description": "Queryable fields available directly on this source", + "items": { + "$ref": "#/components/schemas/Attribute" + } + }, + "description": { + "type": "string", + "description": "Human-readable description of what data this source provides" + }, + "dimensions": { + "type": "array", + "description": "Related entities that can be joined for additional context", + "items": { + "$ref": "#/components/schemas/Dimension" + } + }, + "filterOptions": { + "type": "array", + "description": "Available filtering options for querying metrics", + "items": { + "$ref": "#/components/schemas/FilterOption" + } + }, + "groupByOptions": { + "type": "array", + "description": "Available grouping options for aggregating metrics", + "items": { + "$ref": "#/components/schemas/GroupByOption" + } + }, + "name": { + "type": "string", + "description": "Unique identifier for the source" + } + }, + "description": "Map of source names to source metadata for query construction" + }, "TEAM_FILTER": { "required": [ "type" @@ -27028,6 +27872,36 @@ } } }, + "TimeWindow": { + "required": [ + "decimalPlaces", + "endTime", + "startTime" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PointInTimeComparison" + }, + { + "type": "object", + "properties": { + "decimalPlaces": { + "type": "integer", + "format": "int32" + }, + "endTime": { + "type": "string", + "format": "date-time" + }, + "startTime": { + "type": "string", + "format": "date-time" + } + } + } + ] + }, "TooManyRequestsProblemDetail": { "required": [ "type", @@ -28256,6 +29130,10 @@ "$ref": "#/components/schemas/WorkflowInput" } }, + "jsValidatorScript": { + "type": "string", + "description": "Optional JavaScript validator script to validate the provided inputs" + }, "type": { "type": "string" } diff --git a/test_fix.py b/test_fix.py new file mode 100644 index 0000000..4fd4af0 --- /dev/null +++ b/test_fix.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Test if the $ref resolution fix works.""" + +import json +import asyncio +from src.server import create_mcp_server + +async def test_fix(): + # Create the server with our fix + server = create_mcp_server() + + print(f"Server created: {type(server)}") + + # Check the created tools + tools = await server.get_tools() + + print(f"Total tools: {len(tools)}") + + # Check a tool that previously had $ref issues + tool_name = "AzureActiveDirectorySaveConfiguration" + if tool_name in tools: + print(f"\nChecking {tool_name}...") + + # Try to access the MCP protocol level to see the actual schema + # This is a bit hacky but necessary to verify the fix + for attr in ['_mcp', 'mcp']: + if hasattr(server, attr): + mcp = getattr(server, attr) + if hasattr(mcp, '_server'): + internal_server = mcp._server + if hasattr(internal_server, 'request_handlers'): + handlers = internal_server.request_handlers + if 'tools/list' in handlers: + result = await handlers['tools/list']() + + # Find our tool + for tool in result.tools: + if tool.name == tool_name: + print(f"\nFound tool: {tool.name}") + if tool.inputSchema: + schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) + + # Check if there are any $refs + schema_str = json.dumps(schema) + if '$ref' in schema_str: + print("❌ ISSUE STILL PRESENT: Schema contains $ref") + print(f"Schema: {json.dumps(schema, indent=2)[:500]}...") + else: + print("✅ FIX SUCCESSFUL: No $ref in schema") + print(f"Schema properties: {list(schema.get('properties', {}).keys())}") + return + else: + print(f"Tool {tool_name} not found") + +asyncio.run(test_fix()) \ No newline at end of file diff --git a/test_mcp_stdio.py b/test_mcp_stdio.py new file mode 100644 index 0000000..43d871b --- /dev/null +++ b/test_mcp_stdio.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Test the actual MCP protocol to see tool schemas""" + +import json +import sys +import asyncio + +# Simulate MCP client-server interaction +async def test_mcp_protocol(): + # Import the server module + from src.server import create_mcp_server + + # Create the server + server = create_mcp_server() + + # Access the internal MCP server to call tools/list + # FastMCP wraps an MCP server + print(f"Server type: {type(server)}") + + # Try different approaches to get at the MCP server + for attr in ['_mcp', 'mcp', '_server', 'server']: + if hasattr(server, attr): + print(f"Found attribute: {attr}") + inner = getattr(server, attr) + print(f" Type: {type(inner)}") + + # Check if this has request_handlers + if hasattr(inner, 'request_handlers'): + print(f" Has request_handlers!") + handlers = inner.request_handlers + + if 'tools/list' in handlers: + print("\n Calling tools/list handler...") + result = await handlers['tools/list']() + print(f" Got {len(result.tools)} tools") + + # Find our problematic tool + for tool in result.tools: + if 'Azure' in tool.name and 'Save' in tool.name: + print(f"\n Found tool: {tool.name}") + if tool.inputSchema: + schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) + print(f" Input Schema:") + print(json.dumps(schema, indent=4)) + + # Check for $ref + if '$ref' in json.dumps(schema): + print("\n ⚠️ PROBLEM: Unresolved $ref in schema!") + else: + print("\n ✅ Schema looks resolved") + break + break + + # Also check for _server nested deeper + if hasattr(inner, '_server'): + print(f" Has _server attribute") + deeper = inner._server + if hasattr(deeper, 'request_handlers'): + print(f" Has request_handlers!") + handlers = deeper.request_handlers + + if 'tools/list' in handlers: + print("\n Calling tools/list handler...") + result = await handlers['tools/list']() + print(f" Got {len(result.tools)} tools") + + # Find our problematic tool + for tool in result.tools: + if 'Azure' in tool.name and 'Save' in tool.name: + print(f"\n Found tool: {tool.name}") + if tool.inputSchema: + schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) + print(f" Input Schema:") + print(json.dumps(schema, indent=4)) + + # Check for $ref + if '$ref' in json.dumps(schema): + print("\n ⚠️ PROBLEM: Unresolved $ref in schema!") + else: + print("\n ✅ Schema looks resolved") + break + break + +# Run the test +asyncio.run(test_mcp_protocol()) \ No newline at end of file diff --git a/test_mcp_tools.py b/test_mcp_tools.py new file mode 100644 index 0000000..6902951 --- /dev/null +++ b/test_mcp_tools.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Test to see what schemas FastMCP generates for tools with $ref""" + +import json +import asyncio +from fastmcp import FastMCP +import httpx +import logging + +# Suppress info logs +logging.getLogger("fastmcp").setLevel(logging.WARNING) + +# Load the OpenAPI spec +with open("swagger.json") as f: + spec = json.load(f) + +# Create a dummy client +client = httpx.Client(base_url="https://api.getcortexapp.com") + +# Create MCP server from OpenAPI spec +mcp_server = FastMCP.from_openapi( + openapi_spec=spec, + client=client, + name="test-cortex" +) + +print(f"MCP server type: {type(mcp_server)}") +print(f"MCP server dir (non-private): {[x for x in dir(mcp_server) if not x.startswith('_')][:10]}") + +# Check for _mcp attribute (common internal attribute) +if hasattr(mcp_server, '_mcp'): + print("Found _mcp attribute") + inner = mcp_server._mcp + print(f"Inner type: {type(inner)}") + print(f"Inner dir: {[x for x in dir(inner) if not x.startswith('_')][:10]}") + + if hasattr(inner, '_server'): + print("Found _server in _mcp") + server = inner._server + if hasattr(server, 'request_handlers'): + handlers = server.request_handlers + print(f"Available handlers: {list(handlers.keys())}") + + if 'tools/list' in handlers: + async def get_tools(): + result = await handlers['tools/list']() + return result + + result = asyncio.run(get_tools()) + print(f"\nTotal tools found: {len(result.tools)}") + + # Check a specific tool + for tool in result.tools[:3]: # Just check first 3 + print(f"\nTool: {tool.name}") + if tool.inputSchema: + schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) + print(f"Schema type: {type(tool.inputSchema)}") + print(f"Schema: {json.dumps(schema, indent=2)[:200]}...") + + # Look specifically for our problem tool + azure_tool = [t for t in result.tools if t.name == "AzureActiveDirectorySaveConfiguration"] + if azure_tool: + tool = azure_tool[0] + print(f"\n{'='*60}") + print(f"FOUND PROBLEM TOOL: {tool.name}") + print(f"{'='*60}") + if tool.inputSchema: + schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) + print(f"Full Input Schema:") + print(json.dumps(schema, indent=2)) + + # Check if it has $ref + schema_str = json.dumps(schema) + if '$ref' in schema_str: + print("\n⚠️ ISSUE CONFIRMED: Schema contains unresolved $ref!") + print("The $ref should have been resolved to the actual schema.") + else: + print("\n✅ No $ref found in schema - it may be resolved correctly") + +# Also check what the schema SHOULD be +print(f"\n{'='*60}") +print("WHAT THE SCHEMA SHOULD BE:") +print(f"{'='*60}") + +endpoint = spec["paths"]["/api/v1/active-directory/configuration"]["post"] +req_body = endpoint.get('requestBody', {}) +if req_body: + schema_ref = req_body.get('content', {}).get('application/json', {}).get('schema', {}) + print(f"OpenAPI spec has: {schema_ref}") + + if '$ref' in schema_ref: + ref_path = schema_ref['$ref'] + schema_name = ref_path.split('/')[-1] + if 'components' in spec and 'schemas' in spec['components'] and schema_name in spec['components']['schemas']: + actual_schema = spec['components']['schemas'][schema_name] + print(f"\nThe {schema_name} schema should be:") + print(json.dumps(actual_schema, indent=2)[:600] + "...") \ No newline at end of file diff --git a/test_ref_issue.py b/test_ref_issue.py new file mode 100644 index 0000000..e973309 --- /dev/null +++ b/test_ref_issue.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Test script to investigate $ref handling in FastMCP.from_openapi""" + +import json +from fastmcp import FastMCP +import httpx + +# Load the OpenAPI spec +with open("swagger.json") as f: + spec = json.load(f) + +# Create a dummy client +client = httpx.Client(base_url="https://api.getcortexapp.com") + +# Create MCP server from OpenAPI spec +mcp_server = FastMCP.from_openapi( + openapi_spec=spec, + client=client, + name="test-cortex" +) + +import asyncio + +async def main(): + print("Getting tools from MCP server...") + tools_dict = await mcp_server.get_tools() + print(f"Total tools: {len(tools_dict)}") + return tools_dict + +tools_dict = asyncio.run(main()) + +# Find tools that likely use $ref in their schemas +interesting_tools = [ + "AzureActiveDirectorySaveConfiguration", + "CatalogGetEntity", + "CreateApiKey" +] + +for tool_name in interesting_tools: + if tool_name in tools_dict: + tool = tools_dict[tool_name] + print(f"\n{'='*60}") + print(f"Tool: {tool_name}") + + # Check the tool's function signature for input_model + if hasattr(tool, '__annotations__'): + print(f"Tool annotations: {tool.__annotations__}") + + # Try to get the input schema from the tool metadata + if hasattr(tool, '_tool_definition'): + print(f"Tool definition: {tool._tool_definition}") + + # Try another approach - look at the wrapped function + import inspect + sig = inspect.signature(tool) + print(f"Function signature: {sig}") + + # Get parameters + for param_name, param in sig.parameters.items(): + print(f" Parameter {param_name}: {param.annotation}") + if hasattr(param.annotation, '__annotations__'): + print(f" Annotations: {param.annotation.__annotations__}") + if hasattr(param.annotation, 'model_fields'): + print(f" Model fields: {param.annotation.model_fields}") + +# Let's also check the raw OpenAPI spec for comparison +print("\n\n" + "="*60) +print("Raw OpenAPI spec for comparison:") +print("="*60) + +# Check the AzureActiveDirectorySaveConfiguration endpoint +endpoint = spec["paths"]["/api/v1/active-directory/configuration"]["post"] +print("\nAzureActiveDirectorySaveConfiguration endpoint:") +req_schema = endpoint.get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema') +print(f"Request body schema: {req_schema}") + +# Look up the referenced schema +if req_schema and '$ref' in req_schema: + ref_path = req_schema['$ref'] + schema_name = ref_path.split("/")[-1] + if "components" in spec and "schemas" in spec["components"] and schema_name in spec["components"]["schemas"]: + print(f"\nActual schema for {schema_name}:") + actual_schema = spec["components"]["schemas"][schema_name] + print(json.dumps(actual_schema, indent=2)[:1000] + "...") + +# Now check what the tool expects vs what it should be +print("\n\n" + "="*60) +print("ISSUE INVESTIGATION:") +print("="*60) + +# Try to access the MCP internals to see what schema was generated +# The issue is likely in how FastMCP resolves the $ref when creating the tool input schema + +print("\nChecking if FastMCP properly resolved the $ref...") +print("The $ref points to:", req_schema) +print("The actual schema should have these properties:") +if "components" in spec and "schemas" in spec["components"]: + schema_name = req_schema['$ref'].split("/")[-1] if req_schema and '$ref' in req_schema else None + if schema_name and schema_name in spec["components"]["schemas"]: + actual = spec["components"]["schemas"][schema_name] + if 'properties' in actual: + print(f" Properties: {list(actual['properties'].keys())}") + if 'required' in actual: + print(f" Required: {actual['required']}") + +print("\n💡 The issue is likely that FastMCP is not properly resolving the $ref") +print(" and is passing the reference itself as the schema instead of the actual schema.") \ No newline at end of file diff --git a/test_resolution.py b/test_resolution.py new file mode 100644 index 0000000..68584b1 --- /dev/null +++ b/test_resolution.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Test the ref resolution directly.""" + +import json +from src.utils.openapi_resolver import resolve_refs + +# Load the original spec +with open("swagger.json") as f: + original_spec = json.load(f) + +# Resolve refs +resolved_spec = resolve_refs(original_spec) + +# Check a specific endpoint that uses $ref +endpoint = original_spec["paths"]["/api/v1/active-directory/configuration"]["post"] +resolved_endpoint = resolved_spec["paths"]["/api/v1/active-directory/configuration"]["post"] + +print("ORIGINAL:") +print(json.dumps(endpoint.get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema'), indent=2)) + +print("\nRESOLVED:") +print(json.dumps(resolved_endpoint.get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema'), indent=2)[:500] + "...") \ No newline at end of file diff --git a/tests/manual/client.py b/tests/manual/client.py index 0b9eb33..1e1ea91 100644 --- a/tests/manual/client.py +++ b/tests/manual/client.py @@ -30,10 +30,10 @@ async def test_server(server_url): print(f"\n📦 Found {len(resource_templates)} resource templates:") for resource in resources: - print(f" 📄 {resource.name}: {resource.description}") + print(f" 📄 {resource.name}: {resource}") for tool in tools: - print(f" 🔧 {tool.name}: {tool.description}") + print(f" 🔧 {tool.name}: {tool}") # Test the first tool with empty args # if tools: From abcca0029922ccd044072b8130cbcdcf6bbfff12 Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Wed, 24 Sep 2025 17:24:19 -0400 Subject: [PATCH 2/8] clean up code --- src/components/customizers.py | 11 ++++ src/server.py | 2 +- src/utils/openapi_resolver.py | 1 + test_fix.py | 55 ----------------- test_mcp_stdio.py | 85 --------------------------- test_mcp_tools.py | 97 ------------------------------ test_ref_issue.py | 107 ---------------------------------- test_resolution.py | 22 ------- tests/manual/client.py | 2 +- 9 files changed, 14 insertions(+), 368 deletions(-) delete mode 100644 test_fix.py delete mode 100644 test_mcp_stdio.py delete mode 100644 test_mcp_tools.py delete mode 100644 test_ref_issue.py delete mode 100644 test_resolution.py diff --git a/src/components/customizers.py b/src/components/customizers.py index 0d61936..c310a0c 100644 --- a/src/components/customizers.py +++ b/src/components/customizers.py @@ -51,6 +51,17 @@ def customize_components( # Hide this fully for now. component.output_schema = None + # SCHEMA REFERENCE HANDLING: + # OpenAPI-generated MCP tool schemas contain $defs with nested $ref references + # (e.g., $ref -> $defs -> $ref chains). + # FastMCP cannot resolve these complex reference chains. + # + # This code strips $defs from MCP tool schemas generated by OpenAPI docs. + # However, input schemas containing nested $ref references then fail to resolve, + # resulting in "PointerToNowhere" errors. + # Use openapi_resolver script to replace ALL $ref with inline schema definitions, + # and eliminate the need for $defs entirely. + # if hasattr(component, 'parameters') and isinstance(component.parameters, dict): if "$defs" in component.parameters: logger.debug(f" Found $defs with {len(component.parameters['$defs'])} definitions") diff --git a/src/server.py b/src/server.py index f6a3da4..1310563 100644 --- a/src/server.py +++ b/src/server.py @@ -39,7 +39,7 @@ def create_mcp_server() -> FastMCP: openapi_spec = load_openapi_spec() - # Resolve all $ref references to work around FastMCP issue + # Workaround to resolve all $ref references since FastMCP cannot resolve complex reference chains logger.info("Resolving OpenAPI $ref references...") openapi_spec = resolve_refs(openapi_spec) logger.info("OpenAPI $ref references resolved") diff --git a/src/utils/openapi_resolver.py b/src/utils/openapi_resolver.py index 2fe938b..bdd7961 100644 --- a/src/utils/openapi_resolver.py +++ b/src/utils/openapi_resolver.py @@ -72,6 +72,7 @@ def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any: return spec +# Use if context becomes too large for inline definitions def resolve_refs_with_defs(spec: dict[str, Any]) -> dict[str, Any]: """ Alternative approach: Keep $refs but ensure $defs section is populated. diff --git a/test_fix.py b/test_fix.py deleted file mode 100644 index 4fd4af0..0000000 --- a/test_fix.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -"""Test if the $ref resolution fix works.""" - -import json -import asyncio -from src.server import create_mcp_server - -async def test_fix(): - # Create the server with our fix - server = create_mcp_server() - - print(f"Server created: {type(server)}") - - # Check the created tools - tools = await server.get_tools() - - print(f"Total tools: {len(tools)}") - - # Check a tool that previously had $ref issues - tool_name = "AzureActiveDirectorySaveConfiguration" - if tool_name in tools: - print(f"\nChecking {tool_name}...") - - # Try to access the MCP protocol level to see the actual schema - # This is a bit hacky but necessary to verify the fix - for attr in ['_mcp', 'mcp']: - if hasattr(server, attr): - mcp = getattr(server, attr) - if hasattr(mcp, '_server'): - internal_server = mcp._server - if hasattr(internal_server, 'request_handlers'): - handlers = internal_server.request_handlers - if 'tools/list' in handlers: - result = await handlers['tools/list']() - - # Find our tool - for tool in result.tools: - if tool.name == tool_name: - print(f"\nFound tool: {tool.name}") - if tool.inputSchema: - schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) - - # Check if there are any $refs - schema_str = json.dumps(schema) - if '$ref' in schema_str: - print("❌ ISSUE STILL PRESENT: Schema contains $ref") - print(f"Schema: {json.dumps(schema, indent=2)[:500]}...") - else: - print("✅ FIX SUCCESSFUL: No $ref in schema") - print(f"Schema properties: {list(schema.get('properties', {}).keys())}") - return - else: - print(f"Tool {tool_name} not found") - -asyncio.run(test_fix()) \ No newline at end of file diff --git a/test_mcp_stdio.py b/test_mcp_stdio.py deleted file mode 100644 index 43d871b..0000000 --- a/test_mcp_stdio.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""Test the actual MCP protocol to see tool schemas""" - -import json -import sys -import asyncio - -# Simulate MCP client-server interaction -async def test_mcp_protocol(): - # Import the server module - from src.server import create_mcp_server - - # Create the server - server = create_mcp_server() - - # Access the internal MCP server to call tools/list - # FastMCP wraps an MCP server - print(f"Server type: {type(server)}") - - # Try different approaches to get at the MCP server - for attr in ['_mcp', 'mcp', '_server', 'server']: - if hasattr(server, attr): - print(f"Found attribute: {attr}") - inner = getattr(server, attr) - print(f" Type: {type(inner)}") - - # Check if this has request_handlers - if hasattr(inner, 'request_handlers'): - print(f" Has request_handlers!") - handlers = inner.request_handlers - - if 'tools/list' in handlers: - print("\n Calling tools/list handler...") - result = await handlers['tools/list']() - print(f" Got {len(result.tools)} tools") - - # Find our problematic tool - for tool in result.tools: - if 'Azure' in tool.name and 'Save' in tool.name: - print(f"\n Found tool: {tool.name}") - if tool.inputSchema: - schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) - print(f" Input Schema:") - print(json.dumps(schema, indent=4)) - - # Check for $ref - if '$ref' in json.dumps(schema): - print("\n ⚠️ PROBLEM: Unresolved $ref in schema!") - else: - print("\n ✅ Schema looks resolved") - break - break - - # Also check for _server nested deeper - if hasattr(inner, '_server'): - print(f" Has _server attribute") - deeper = inner._server - if hasattr(deeper, 'request_handlers'): - print(f" Has request_handlers!") - handlers = deeper.request_handlers - - if 'tools/list' in handlers: - print("\n Calling tools/list handler...") - result = await handlers['tools/list']() - print(f" Got {len(result.tools)} tools") - - # Find our problematic tool - for tool in result.tools: - if 'Azure' in tool.name and 'Save' in tool.name: - print(f"\n Found tool: {tool.name}") - if tool.inputSchema: - schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) - print(f" Input Schema:") - print(json.dumps(schema, indent=4)) - - # Check for $ref - if '$ref' in json.dumps(schema): - print("\n ⚠️ PROBLEM: Unresolved $ref in schema!") - else: - print("\n ✅ Schema looks resolved") - break - break - -# Run the test -asyncio.run(test_mcp_protocol()) \ No newline at end of file diff --git a/test_mcp_tools.py b/test_mcp_tools.py deleted file mode 100644 index 6902951..0000000 --- a/test_mcp_tools.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -"""Test to see what schemas FastMCP generates for tools with $ref""" - -import json -import asyncio -from fastmcp import FastMCP -import httpx -import logging - -# Suppress info logs -logging.getLogger("fastmcp").setLevel(logging.WARNING) - -# Load the OpenAPI spec -with open("swagger.json") as f: - spec = json.load(f) - -# Create a dummy client -client = httpx.Client(base_url="https://api.getcortexapp.com") - -# Create MCP server from OpenAPI spec -mcp_server = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - name="test-cortex" -) - -print(f"MCP server type: {type(mcp_server)}") -print(f"MCP server dir (non-private): {[x for x in dir(mcp_server) if not x.startswith('_')][:10]}") - -# Check for _mcp attribute (common internal attribute) -if hasattr(mcp_server, '_mcp'): - print("Found _mcp attribute") - inner = mcp_server._mcp - print(f"Inner type: {type(inner)}") - print(f"Inner dir: {[x for x in dir(inner) if not x.startswith('_')][:10]}") - - if hasattr(inner, '_server'): - print("Found _server in _mcp") - server = inner._server - if hasattr(server, 'request_handlers'): - handlers = server.request_handlers - print(f"Available handlers: {list(handlers.keys())}") - - if 'tools/list' in handlers: - async def get_tools(): - result = await handlers['tools/list']() - return result - - result = asyncio.run(get_tools()) - print(f"\nTotal tools found: {len(result.tools)}") - - # Check a specific tool - for tool in result.tools[:3]: # Just check first 3 - print(f"\nTool: {tool.name}") - if tool.inputSchema: - schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) - print(f"Schema type: {type(tool.inputSchema)}") - print(f"Schema: {json.dumps(schema, indent=2)[:200]}...") - - # Look specifically for our problem tool - azure_tool = [t for t in result.tools if t.name == "AzureActiveDirectorySaveConfiguration"] - if azure_tool: - tool = azure_tool[0] - print(f"\n{'='*60}") - print(f"FOUND PROBLEM TOOL: {tool.name}") - print(f"{'='*60}") - if tool.inputSchema: - schema = tool.inputSchema.model_dump() if hasattr(tool.inputSchema, 'model_dump') else dict(tool.inputSchema) - print(f"Full Input Schema:") - print(json.dumps(schema, indent=2)) - - # Check if it has $ref - schema_str = json.dumps(schema) - if '$ref' in schema_str: - print("\n⚠️ ISSUE CONFIRMED: Schema contains unresolved $ref!") - print("The $ref should have been resolved to the actual schema.") - else: - print("\n✅ No $ref found in schema - it may be resolved correctly") - -# Also check what the schema SHOULD be -print(f"\n{'='*60}") -print("WHAT THE SCHEMA SHOULD BE:") -print(f"{'='*60}") - -endpoint = spec["paths"]["/api/v1/active-directory/configuration"]["post"] -req_body = endpoint.get('requestBody', {}) -if req_body: - schema_ref = req_body.get('content', {}).get('application/json', {}).get('schema', {}) - print(f"OpenAPI spec has: {schema_ref}") - - if '$ref' in schema_ref: - ref_path = schema_ref['$ref'] - schema_name = ref_path.split('/')[-1] - if 'components' in spec and 'schemas' in spec['components'] and schema_name in spec['components']['schemas']: - actual_schema = spec['components']['schemas'][schema_name] - print(f"\nThe {schema_name} schema should be:") - print(json.dumps(actual_schema, indent=2)[:600] + "...") \ No newline at end of file diff --git a/test_ref_issue.py b/test_ref_issue.py deleted file mode 100644 index e973309..0000000 --- a/test_ref_issue.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to investigate $ref handling in FastMCP.from_openapi""" - -import json -from fastmcp import FastMCP -import httpx - -# Load the OpenAPI spec -with open("swagger.json") as f: - spec = json.load(f) - -# Create a dummy client -client = httpx.Client(base_url="https://api.getcortexapp.com") - -# Create MCP server from OpenAPI spec -mcp_server = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - name="test-cortex" -) - -import asyncio - -async def main(): - print("Getting tools from MCP server...") - tools_dict = await mcp_server.get_tools() - print(f"Total tools: {len(tools_dict)}") - return tools_dict - -tools_dict = asyncio.run(main()) - -# Find tools that likely use $ref in their schemas -interesting_tools = [ - "AzureActiveDirectorySaveConfiguration", - "CatalogGetEntity", - "CreateApiKey" -] - -for tool_name in interesting_tools: - if tool_name in tools_dict: - tool = tools_dict[tool_name] - print(f"\n{'='*60}") - print(f"Tool: {tool_name}") - - # Check the tool's function signature for input_model - if hasattr(tool, '__annotations__'): - print(f"Tool annotations: {tool.__annotations__}") - - # Try to get the input schema from the tool metadata - if hasattr(tool, '_tool_definition'): - print(f"Tool definition: {tool._tool_definition}") - - # Try another approach - look at the wrapped function - import inspect - sig = inspect.signature(tool) - print(f"Function signature: {sig}") - - # Get parameters - for param_name, param in sig.parameters.items(): - print(f" Parameter {param_name}: {param.annotation}") - if hasattr(param.annotation, '__annotations__'): - print(f" Annotations: {param.annotation.__annotations__}") - if hasattr(param.annotation, 'model_fields'): - print(f" Model fields: {param.annotation.model_fields}") - -# Let's also check the raw OpenAPI spec for comparison -print("\n\n" + "="*60) -print("Raw OpenAPI spec for comparison:") -print("="*60) - -# Check the AzureActiveDirectorySaveConfiguration endpoint -endpoint = spec["paths"]["/api/v1/active-directory/configuration"]["post"] -print("\nAzureActiveDirectorySaveConfiguration endpoint:") -req_schema = endpoint.get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema') -print(f"Request body schema: {req_schema}") - -# Look up the referenced schema -if req_schema and '$ref' in req_schema: - ref_path = req_schema['$ref'] - schema_name = ref_path.split("/")[-1] - if "components" in spec and "schemas" in spec["components"] and schema_name in spec["components"]["schemas"]: - print(f"\nActual schema for {schema_name}:") - actual_schema = spec["components"]["schemas"][schema_name] - print(json.dumps(actual_schema, indent=2)[:1000] + "...") - -# Now check what the tool expects vs what it should be -print("\n\n" + "="*60) -print("ISSUE INVESTIGATION:") -print("="*60) - -# Try to access the MCP internals to see what schema was generated -# The issue is likely in how FastMCP resolves the $ref when creating the tool input schema - -print("\nChecking if FastMCP properly resolved the $ref...") -print("The $ref points to:", req_schema) -print("The actual schema should have these properties:") -if "components" in spec and "schemas" in spec["components"]: - schema_name = req_schema['$ref'].split("/")[-1] if req_schema and '$ref' in req_schema else None - if schema_name and schema_name in spec["components"]["schemas"]: - actual = spec["components"]["schemas"][schema_name] - if 'properties' in actual: - print(f" Properties: {list(actual['properties'].keys())}") - if 'required' in actual: - print(f" Required: {actual['required']}") - -print("\n💡 The issue is likely that FastMCP is not properly resolving the $ref") -print(" and is passing the reference itself as the schema instead of the actual schema.") \ No newline at end of file diff --git a/test_resolution.py b/test_resolution.py deleted file mode 100644 index 68584b1..0000000 --- a/test_resolution.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -"""Test the ref resolution directly.""" - -import json -from src.utils.openapi_resolver import resolve_refs - -# Load the original spec -with open("swagger.json") as f: - original_spec = json.load(f) - -# Resolve refs -resolved_spec = resolve_refs(original_spec) - -# Check a specific endpoint that uses $ref -endpoint = original_spec["paths"]["/api/v1/active-directory/configuration"]["post"] -resolved_endpoint = resolved_spec["paths"]["/api/v1/active-directory/configuration"]["post"] - -print("ORIGINAL:") -print(json.dumps(endpoint.get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema'), indent=2)) - -print("\nRESOLVED:") -print(json.dumps(resolved_endpoint.get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema'), indent=2)[:500] + "...") \ No newline at end of file diff --git a/tests/manual/client.py b/tests/manual/client.py index 1e1ea91..914a91b 100644 --- a/tests/manual/client.py +++ b/tests/manual/client.py @@ -33,7 +33,7 @@ async def test_server(server_url): print(f" 📄 {resource.name}: {resource}") for tool in tools: - print(f" 🔧 {tool.name}: {tool}") + print(f" 🔧 {tool.name}: {tool.description}") # Test the first tool with empty args # if tools: From ffa393de5976e12f96d61dc2489e5cb6095e8496 Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Wed, 24 Sep 2025 17:24:30 -0400 Subject: [PATCH 3/8] tests --- tests/test_openapi_resolver.py | 285 +++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 tests/test_openapi_resolver.py diff --git a/tests/test_openapi_resolver.py b/tests/test_openapi_resolver.py new file mode 100644 index 0000000..641fa40 --- /dev/null +++ b/tests/test_openapi_resolver.py @@ -0,0 +1,285 @@ +"""Tests for OpenAPI $ref resolver.""" +import json +from unittest.mock import patch +import pytest + +from src.utils.openapi_resolver import resolve_refs, resolve_refs_with_defs + + +class TestOpenAPIResolver: + """Test suite for OpenAPI $ref resolver.""" + + def test_resolve_simple_ref(self): + """Test resolving a simple $ref to a schema.""" + spec = { + "paths": { + "/api/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/TestSchema"} + } + } + } + } + } + }, + "components": { + "schemas": { + "TestSchema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "integer"} + }, + "required": ["name"] + } + } + } + } + + resolved = resolve_refs(spec) + + # Check that the $ref was resolved + schema = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert "$ref" not in schema + assert schema["type"] == "object" + assert "name" in schema["properties"] + assert "value" in schema["properties"] + assert schema["required"] == ["name"] + + def test_resolve_nested_refs(self): + """Test resolving nested $refs.""" + spec = { + "paths": { + "/api/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ParentSchema"} + } + } + } + } + } + }, + "components": { + "schemas": { + "ParentSchema": { + "type": "object", + "properties": { + "child": {"$ref": "#/components/schemas/ChildSchema"} + } + }, + "ChildSchema": { + "type": "object", + "properties": { + "data": {"type": "string"} + } + } + } + } + } + + resolved = resolve_refs(spec) + + # Check that all $refs were resolved + schema = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert "$ref" not in json.dumps(schema) + assert schema["properties"]["child"]["properties"]["data"]["type"] == "string" + + def test_resolve_circular_refs(self): + """Test handling of circular references.""" + spec = { + "paths": { + "/api/test": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Node"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "parent": {"$ref": "#/components/schemas/Node"} + } + } + } + } + } + + # Should not raise an exception and should handle circular refs + resolved = resolve_refs(spec) + + # Should have resolved the top-level ref but left circular ref intact + schema = resolved["paths"]["/api/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert schema["type"] == "object" + assert "value" in schema["properties"] + # Circular ref should be preserved to prevent infinite recursion + assert schema["properties"]["parent"]["$ref"] == "#/components/schemas/Node" + + def test_resolve_refs_in_arrays(self): + """Test resolving $refs inside arrays.""" + spec = { + "paths": { + "/api/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/Item"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Item": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"} + } + } + } + } + } + + resolved = resolve_refs(spec) + + schema = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert schema["type"] == "array" + assert "$ref" not in schema["items"] + assert schema["items"]["properties"]["id"]["type"] == "integer" + + def test_preserve_non_schema_refs(self): + """Test that non-schema $refs are preserved.""" + spec = { + "paths": { + "/api/test": { + "get": { + "parameters": [ + {"$ref": "#/components/parameters/CommonParam"} + ] + } + } + }, + "components": { + "parameters": { + "CommonParam": { + "name": "test", + "in": "query", + "schema": {"type": "string"} + } + } + } + } + + resolved = resolve_refs(spec) + + # Non-schema refs should be preserved + param_ref = resolved["paths"]["/api/test"]["get"]["parameters"][0] + assert param_ref == {"$ref": "#/components/parameters/CommonParam"} + + def test_resolve_refs_with_defs(self): + """Test the alternative approach using $defs.""" + spec = { + "paths": { + "/api/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/TestSchema"} + } + } + } + } + } + }, + "components": { + "schemas": { + "TestSchema": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } + } + } + + resolved = resolve_refs_with_defs(spec) + + # Should have $defs section + assert "$defs" in resolved + assert "TestSchema" in resolved["$defs"] + + # Refs should be transformed to JSON Schema format + schema_ref = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert schema_ref["$ref"] == "#/$defs/TestSchema" + + def test_no_components_section(self): + """Test handling when components section is missing.""" + spec = { + "paths": { + "/api/test": { + "get": { + "responses": { + "200": { + "description": "OK" + } + } + } + } + } + } + + # Should not raise an exception + resolved = resolve_refs(spec) + assert resolved == spec + + def test_original_spec_unchanged(self): + """Test that the original spec is not modified.""" + spec = { + "paths": { + "/api/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/TestSchema"} + } + } + } + } + } + }, + "components": { + "schemas": { + "TestSchema": {"type": "object"} + } + } + } + + original_json = json.dumps(spec, sort_keys=True) + resolve_refs(spec) + + # Original should be unchanged + assert json.dumps(spec, sort_keys=True) == original_json \ No newline at end of file From 3582dece43eeb54f4d94a651080b6c05aa030fbc Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Thu, 25 Sep 2025 08:59:29 -0400 Subject: [PATCH 4/8] use existing swagger doc --- swagger.json | 892 +-------------------------------------------------- 1 file changed, 7 insertions(+), 885 deletions(-) diff --git a/swagger.json b/swagger.json index ec57bb2..3e9540b 100644 --- a/swagger.json +++ b/swagger.json @@ -157,12 +157,6 @@ }, { "name": "[Integrations] SonarQube" - }, - { - "name": "public-eng-intel-metrics-controller" - }, - { - "name": "public-eng-intel-registry-controller" } ], "paths": { @@ -2493,21 +2487,10 @@ "operationId": "deleteEntitiesByType", "parameters": [ { - "description": "A list of entity types or IDs delete", + "description": "A list of entity types to delete", "in": "query", "name": "types", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "in": "query", - "name": "ids", - "required": false, + "required": true, "schema": { "type": "array", "items": { @@ -2753,101 +2736,6 @@ "x-cortex-mcp-enabled": "true" } }, - "/api/v1/catalog/batch/{type}": { - "get": { - "description": "Gets a list of entities based on type and batch key plus identifier", - "operationId": "getEntitiesByBatchIdentifier", - "parameters": [ - { - "description": "The type name of the batches to retrieve. This corresponds to the `x-cortex-type` field in the entity descriptor.", - "in": "path", - "name": "type", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "The batch key to filter entities by. This is a custom field set as entity metadata `__cortex_batch.key`", - "in": "query", - "name": "key", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "The batch version. This is a custom field set as entity metadata `__cortex_batch.identifier`, required if `compare` is specified", - "in": "query", - "name": "version", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "The type over comparison to perform. Ignored unless `version` parameter must also be provided. Values can be `eq`, `ne`.", - "in": "query", - "name": "compare", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "includeArchived", - "required": false, - "schema": { - "type": "boolean", - "default": false - } - }, - { - "description": "Number of results to return per page, between 1 and 1000. Default 250.", - "in": "query", - "name": "pageSize", - "required": true, - "schema": { - "type": "integer", - "format": "int32", - "default": 250 - } - }, - { - "description": "Page number to return, 0-indexed. Default 0.", - "in": "query", - "name": "page", - "required": true, - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EntityCIDListResponse" - } - } - }, - "description": "List of entity identifiers matching the batch key and type, based on the specified compare parameter." - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - }, - "summary": "Get entities by batch identifier", - "tags": [ - "Catalog Entities" - ], - "x-cortex-mcp-enabled": "false" - } - }, "/api/v1/catalog/custom-data": { "delete": { "description": "Use this endpoint when attempting to delete custom data where the key contains non-alphanumeric characters. Otherwise, use the standard API under `Custom Data`.", @@ -9505,95 +9393,6 @@ "x-cortex-mcp-enabled": "false" } }, - "/api/v1/eng-intel/metrics/point-in-time": { - "post": { - "operationId": "pointInTimeMetrics", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Batch" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PointInTimeResponse" - } - } - }, - "description": "OK" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - }, - "tags": [ - "public-eng-intel-metrics-controller" - ], - "x-cortex-mcp-description": "Execute point-in-time queries for one or more engineering metrics in a single request.\n\n Returns current metric values for specified time periods, with support for batch processing\n and optional period-over-period comparisons.\n\n Request body supports:\n - Multiple metrics: Query several metrics efficiently in one call\n - Time range (startTime/endTime): Limited to 6 months maximum for performance\n - Flexible filtering: Use filters available for each metric (varies by source)\n - Grouping options: Group results by available dimensions (author, repo, team, etc.)\n - Aggregation methods: COUNT, SUM, AVG, MIN, MAX per metric requirements\n - Period comparisons: Optional comparison to previous time windows\n - Nested queries: Advanced nested metric calculations\n\n Response includes:\n - Lightweight metadata: Column definitions optimized for programmatic use\n - Row data: Actual metric values and dimensional data\n - No heavy schemas: Source definitions excluded (get from Registry API instead)\n\n Error responses:\n - 400: Invalid metric names, date range, validation errors, or unsupported metric combinations\n - 403: Feature not enabled (contact help@cortex.io)", - "x-cortex-mcp-enabled": "true" - } - }, - "/api/v1/eng-intel/registry/metrics/definitions": { - "get": { - "operationId": "listMetricDefinitions", - "parameters": [ - { - "in": "query", - "name": "view", - "required": false, - "schema": { - "type": "string", - "default": "basic" - } - }, - { - "in": "query", - "name": "key", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/BasicMetricDefinitionsResponse" - }, - { - "$ref": "#/components/schemas/FullMetricDefinitionsResponse" - } - ] - } - } - }, - "description": "OK" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - } - }, - "tags": [ - "public-eng-intel-registry-controller" - ], - "x-cortex-mcp-description": "List all available engineering metric definitions.\n\n Query parameters:\n - view: \u0027basic\u0027 (default) returns minimal info, \u0027full\u0027 includes sources and query metadata\n - key: Filter metrics by key (supports multiple values and comma-separated lists)\n\n Error responses:\n - 400: Invalid view parameter (must be \u0027basic\u0027 or \u0027full\u0027)\n - 403: Restricted Feature (contact help@cortex.io)\n\n Filter operators by type (for constructing queries):\n - STRING: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, LIKE, NOT_LIKE, IN, NOT_IN, ANY\n - INTEGER/DECIMAL/DOUBLE: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IN, NOT_IN, BETWEEN, ANY\n - DATETIME/DATE: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, BETWEEN\n - BOOLEAN: EQUAL, NOT_EQUAL, IS_NULL, IS_NOT_NULL, IN, NOT_IN\n - ARRAY: EQUAL, CONTAINS, IN", - "x-cortex-mcp-enabled": "true" - } - }, "/api/v1/github/configurations": { "delete": { "operationId": "GitHubDeleteConfigurations", @@ -15607,59 +15406,6 @@ "x-cortex-mcp-enabled": "false" } }, - "/api/v1/scorecards/{tag}/entity/{entityTag}/scores": { - "post": { - "description": "Triggers score evaluation for entity scorecard", - "operationId": "refreshScorecardScoreForEntity", - "parameters": [ - { - "description": "Unique tag for the Scorecard", - "example": "my-production-readiness-checklist", - "in": "path", - "name": "tag", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "The entity tag (`x-cortex-tag`) that identifies the entity.", - "in": "path", - "name": "entityTag", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Scorecard score evaluation triggered successfully" - }, - "403": { - "description": "Unauthorized" - }, - "404": { - "description": "Scorecard not found" - }, - "409": { - "description": "Already evaluating scorecard" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "description": "Scorecard evaluation failed" - } - }, - "summary": "Evaluate entity scorecard score", - "tags": [ - "Scorecards" - ], - "x-cortex-mcp-description": "Cortex Scorecards API - Measure and track service quality, compliance, and maturity levels across your organization with customizable scorecards and rules", - "x-cortex-mcp-enabled": "false" - } - }, "/api/v1/scorecards/{tag}/next-steps": { "get": { "operationId": "getScorecardNextStepsForEntity", @@ -18577,77 +18323,6 @@ } } }, - "Attribute": { - "required": [ - "name", - "type" - ], - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "Human-readable description of what this field contains" - }, - "name": { - "type": "string", - "description": "Field name for use in queries" - }, - "type": { - "type": "string", - "description": "Data type - determines which filter operators are supported (see endpoint description)", - "enum": [ - "STRING", - "BOOLEAN", - "INTEGER", - "DECIMAL", - "DOUBLE", - "DATE", - "TREE", - "DATETIME", - "JSON", - "ARRAY", - "UNSUPPORTED" - ] - } - }, - "description": "Queryable fields available on this dimension" - }, - "AttributeFilterObject": { - "required": [ - "attribute", - "operator", - "predicate" - ], - "type": "object", - "properties": { - "attribute": { - "type": "string" - }, - "operator": { - "type": "string", - "enum": [ - "ANY", - "BETWEEN", - "CONTAINS", - "EQUAL", - "GREATER_THAN", - "GREATER_THAN_OR_EQUAL", - "IN", - "IS_NOT_NULL", - "IS_NULL", - "LESS_THAN", - "LESS_THAN_OR_EQUAL", - "LIKE", - "NOT_EQUAL", - "NOT_IN", - "NOT_LIKE" - ] - }, - "predicate": { - "type": "object" - } - } - }, "AuditLogResponse": { "required": [ "action", @@ -19082,134 +18757,7 @@ } ] }, - "BasicMetricDefinition": { - "required": [ - "displayName", - "key", - "readiness" - ], - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "Description of what this metric measures" - }, - "displayName": { - "type": "string", - "description": "Human-readable name" - }, - "key": { - "type": "string", - "description": "Unique identifier for the metric (used in queries)" - }, - "readiness": { - "$ref": "#/components/schemas/Readiness" - } - }, - "description": "List of available metric definitions with basic metadata" - }, - "BasicMetricDefinitionsResponse": { - "required": [ - "metrics" - ], - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/MetricDefinitionsResponseObject" - }, - { - "type": "object", - "properties": { - "metrics": { - "type": "array", - "description": "List of available metric definitions with basic metadata", - "items": { - "$ref": "#/components/schemas/BasicMetricDefinition" - } - } - } - } - ] - }, - "Batch": { - "required": [ - "endTime", - "filters", - "groupBy", - "limit", - "metrics", - "orderBy", - "startTime" - ], - "type": "object", - "properties": { - "comparison": { - "oneOf": [ - { - "$ref": "#/components/schemas/TimeWindow" - } - ] - }, - "endTime": { - "type": "string", - "format": "date-time" - }, - "filters": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AttributeFilterObject" - } - }, - "groupBy": { - "type": "array", - "items": { - "type": "string" - } - }, - "limit": { - "type": "integer", - "format": "int32" - }, - "metrics": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricAggregation" - } - }, - "nestedGroupBy": { - "type": "array", - "items": { - "type": "string" - } - }, - "nestedMetrics": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricAggregation" - } - }, - "nestedTimeAttribute": { - "type": "string" - }, - "nextPage": { - "type": "string" - }, - "orderBy": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrderBy" - } - }, - "startTime": { - "type": "string", - "format": "date-time" - }, - "timeAttribute": { - "type": "string" - } - } - }, - "BulkAwsConfigurationsRequest": { + "BulkAwsConfigurationsRequest": { "required": [ "configurations" ], @@ -19717,33 +19265,6 @@ } } }, - "Column": { - "required": [ - "alias", - "name", - "ordinal" - ], - "type": "object", - "properties": { - "alias": { - "type": "string" - }, - "category": { - "type": "string" - }, - "description": { - "type": "string" - }, - "name": { - "type": "string" - }, - "ordinal": { - "type": "integer", - "format": "int32" - } - }, - "description": "List of column definitions describing each field in the result rows" - }, "CompoundFilter": { "type": "object", "properties": { @@ -21758,32 +21279,6 @@ } } }, - "Dimension": { - "required": [ - "attributes", - "description", - "name" - ], - "type": "object", - "properties": { - "attributes": { - "type": "array", - "description": "Queryable fields available on this dimension", - "items": { - "$ref": "#/components/schemas/Attribute" - } - }, - "description": { - "type": "string", - "description": "Description of the relationship this dimension represents" - }, - "name": { - "type": "string", - "description": "Dimension identifier for use in queries" - } - }, - "description": "Related entities that can be joined for additional context" - }, "DiscoveryAuditEvent": { "required": [ "isIgnored", @@ -22032,39 +21527,6 @@ }, "description": "Emphasized rules for the Initiative. Either emphasized levels or rules must be provided" }, - "EntityCIDListResponse": { - "required": [ - "ids", - "page", - "total", - "totalPages" - ], - "type": "object", - "properties": { - "ids": { - "type": "array", - "description": "Unique, immutable, 18-character auto-generated identifier for the entity.", - "example": "en2da8159dbeefb974", - "items": { - "type": "string", - "description": "Unique, immutable, 18-character auto-generated identifier for the entity.", - "example": "en2da8159dbeefb974" - } - }, - "page": { - "type": "integer", - "format": "int32" - }, - "total": { - "type": "integer", - "format": "int32" - }, - "totalPages": { - "type": "integer", - "format": "int32" - } - } - }, "EntityDestinationsResponse": { "required": [ "destinations" @@ -22950,115 +22412,6 @@ }, "description": "Filter applied to the Initiative" }, - "FilterOption": { - "required": [ - "displayName", - "filter", - "key" - ], - "type": "object", - "properties": { - "displayName": { - "type": "string", - "description": "Human-readable label for this filter" - }, - "filter": { - "type": "string", - "description": "Specific attribute to filter on" - }, - "key": { - "type": "string", - "description": "Field key to reference in queries" - } - }, - "description": "Available filtering options for querying metrics" - }, - "FullMetricDefinition": { - "required": [ - "displayName", - "key", - "readiness" - ], - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "Description of what this metric measures" - }, - "displayName": { - "type": "string", - "description": "Human-readable name" - }, - "key": { - "type": "string", - "description": "Unique identifier for the metric (used in queries)" - }, - "readiness": { - "$ref": "#/components/schemas/Readiness" - }, - "sourceRef": { - "type": "string", - "description": "Reference to the data source in the sources map - indicates which integration provides this metric" - }, - "timeAttribute": { - "$ref": "#/components/schemas/Attribute" - }, - "type": { - "type": "string", - "description": "Type of metric: COUNT (discrete values), DURATION (time-based), RATIO/RATE (percentages), VALUE (continuous)", - "enum": [ - "DURATION", - "COUNT", - "VALUE", - "RATE" - ] - }, - "units": { - "type": "string", - "description": "Units of measurement (e.g., \u0027seconds\u0027, \u0027count\u0027, \u0027percentage\u0027)" - }, - "valence": { - "type": "string", - "description": "Whether higher values are better (POSITIVE) or worse (NEGATIVE) for this metric", - "enum": [ - "POSITIVE", - "NEGATIVE" - ] - } - }, - "description": "List of available metric definitions with full metadata" - }, - "FullMetricDefinitionsResponse": { - "required": [ - "metrics", - "sources" - ], - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/MetricDefinitionsResponseObject" - }, - { - "type": "object", - "properties": { - "metrics": { - "type": "array", - "description": "List of available metric definitions with full metadata", - "items": { - "$ref": "#/components/schemas/FullMetricDefinition" - } - }, - "sources": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/Source" - }, - "description": "Map of source names to source metadata for query construction" - } - } - } - ] - }, "GCP": { "required": [ "projectId", @@ -23706,24 +23059,6 @@ } } }, - "GroupByOption": { - "required": [ - "displayName", - "key" - ], - "type": "object", - "properties": { - "displayName": { - "type": "string", - "description": "Human-readable label for this grouping" - }, - "key": { - "type": "string", - "description": "Field key to use in GROUP BY clause" - } - }, - "description": "Available grouping options for aggregating metrics" - }, "GroupFilter": { "type": "object", "properties": { @@ -24775,53 +24110,6 @@ }, "description": "Custom data key/values associated with the entity." }, - "MetricAggregation": { - "required": [ - "aggregation", - "metric" - ], - "type": "object", - "properties": { - "aggregation": { - "type": "string", - "enum": [ - "SUM", - "AVG", - "COUNT", - "RATIO", - "MIN", - "MAX", - "P50", - "P95", - "RANKING" - ] - }, - "metric": { - "type": "string" - } - } - }, - "MetricDefinitionsResponseObject": { - "required": [ - "metrics", - "view" - ], - "type": "object", - "properties": { - "metrics": { - "type": "array", - "items": { - "type": "object" - } - }, - "view": { - "type": "string" - } - }, - "discriminator": { - "propertyName": "view" - } - }, "ModifiedRuleExemptionsResponse": { "required": [ "exemptions" @@ -25772,25 +25060,6 @@ } } }, - "OrderBy": { - "required": [ - "attribute", - "direction" - ], - "type": "object", - "properties": { - "attribute": { - "type": "string" - }, - "direction": { - "type": "string", - "enum": [ - "ASC", - "DESC" - ] - } - } - }, "OwnerGroup": { "required": [ "groupName" @@ -26123,7 +25392,8 @@ }, "lastUpdated": { "type": "string", - "description": "When the plugin was last updated" + "description": "When the plugin was last updated", + "format": "date-time" }, "minimumRoleRequired": { "type": "string", @@ -26186,7 +25456,8 @@ }, "lastUpdated": { "type": "string", - "description": "When the plugin was last updated" + "description": "When the plugin was last updated", + "format": "date-time" }, "minimumRoleRequired": { "type": "string", @@ -26242,40 +25513,6 @@ } } }, - "PointInTimeComparison": { - "required": [ - "type" - ], - "type": "object", - "properties": { - "type": { - "type": "string" - } - }, - "discriminator": { - "propertyName": "type" - } - }, - "PointInTimeResponse": { - "required": [ - "metadata", - "rows" - ], - "type": "object", - "properties": { - "metadata": { - "$ref": "#/components/schemas/QueryMetadata" - }, - "rows": { - "type": "array", - "description": "Result rows containing metric values and dimensions - each row corresponds to the columns in metadata", - "items": { - "type": "object", - "description": "Result rows containing metric values and dimensions - each row corresponds to the columns in metadata" - } - } - } - }, "PrometheusConfiguration": { "required": [ "alias", @@ -26382,22 +25619,6 @@ } } }, - "QueryMetadata": { - "required": [ - "columns" - ], - "type": "object", - "properties": { - "columns": { - "type": "array", - "description": "List of column definitions describing each field in the result rows", - "items": { - "$ref": "#/components/schemas/Column" - } - } - }, - "description": "Query metadata describing the structure of returned columns" - }, "QueryRequest": { "required": [ "query" @@ -26477,21 +25698,6 @@ } } }, - "Readiness": { - "required": [ - "type" - ], - "type": "object", - "properties": { - "type": { - "type": "string" - } - }, - "description": "Whether this metric is ready to use - includes hints if configuration is needed", - "discriminator": { - "propertyName": "type" - } - }, "RejectRuleExemptionRequest": { "required": [ "reason", @@ -27410,56 +26616,6 @@ } } }, - "Source": { - "required": [ - "attributes", - "description", - "dimensions", - "filterOptions", - "groupByOptions", - "name" - ], - "type": "object", - "properties": { - "attributes": { - "type": "array", - "description": "Queryable fields available directly on this source", - "items": { - "$ref": "#/components/schemas/Attribute" - } - }, - "description": { - "type": "string", - "description": "Human-readable description of what data this source provides" - }, - "dimensions": { - "type": "array", - "description": "Related entities that can be joined for additional context", - "items": { - "$ref": "#/components/schemas/Dimension" - } - }, - "filterOptions": { - "type": "array", - "description": "Available filtering options for querying metrics", - "items": { - "$ref": "#/components/schemas/FilterOption" - } - }, - "groupByOptions": { - "type": "array", - "description": "Available grouping options for aggregating metrics", - "items": { - "$ref": "#/components/schemas/GroupByOption" - } - }, - "name": { - "type": "string", - "description": "Unique identifier for the source" - } - }, - "description": "Map of source names to source metadata for query construction" - }, "TEAM_FILTER": { "required": [ "type" @@ -27872,36 +27028,6 @@ } } }, - "TimeWindow": { - "required": [ - "decimalPlaces", - "endTime", - "startTime" - ], - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/PointInTimeComparison" - }, - { - "type": "object", - "properties": { - "decimalPlaces": { - "type": "integer", - "format": "int32" - }, - "endTime": { - "type": "string", - "format": "date-time" - }, - "startTime": { - "type": "string", - "format": "date-time" - } - } - } - ] - }, "TooManyRequestsProblemDetail": { "required": [ "type", @@ -29130,10 +28256,6 @@ "$ref": "#/components/schemas/WorkflowInput" } }, - "jsValidatorScript": { - "type": "string", - "description": "Optional JavaScript validator script to validate the provided inputs" - }, "type": { "type": "string" } From 9125cdf9a78e7409e4fb6920b490bad074fa822c Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Thu, 25 Sep 2025 11:23:14 -0400 Subject: [PATCH 5/8] Update comments to explain approach --- src/components/customizers.py | 11 ++++++----- src/utils/openapi_resolver.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/customizers.py b/src/components/customizers.py index c310a0c..8c87463 100644 --- a/src/components/customizers.py +++ b/src/components/customizers.py @@ -56,11 +56,12 @@ def customize_components( # (e.g., $ref -> $defs -> $ref chains). # FastMCP cannot resolve these complex reference chains. # - # This code strips $defs from MCP tool schemas generated by OpenAPI docs. - # However, input schemas containing nested $ref references then fail to resolve, - # resulting in "PointerToNowhere" errors. - # Use openapi_resolver script to replace ALL $ref with inline schema definitions, - # and eliminate the need for $defs entirely. + # The following code strips $defs from MCP tool input/output schemas generated by OpenAPI docs. + # However, stripping $defs still resulted in "PointerToNowhere" errors due to input schemas + # containing nested $ref references failing to resolve. + # + # Openapi_resolver script replaces ALL $ref with inline schema definitions, and eliminates the need for $defs entirely. + # Keep this code to prevent any remaining $defs from breaking MCP clients. # if hasattr(component, 'parameters') and isinstance(component.parameters, dict): if "$defs" in component.parameters: diff --git a/src/utils/openapi_resolver.py b/src/utils/openapi_resolver.py index bdd7961..9ac8066 100644 --- a/src/utils/openapi_resolver.py +++ b/src/utils/openapi_resolver.py @@ -1,4 +1,15 @@ -"""OpenAPI $ref resolver for FastMCP compatibility.""" +""" +OpenAPI $ref resolver for FastMCP compatibility. + +FastMCP cannot resolve complex reference chains (e.g., $ref -> $defs -> $ref chains). +This script replaces ALL $ref in the entire OpenAPI spec with inline schema definitions. + +Since customizers.py currently hides output schemas, $ref resolutions are only visible in INPUT schemas. + +Performance note: Currently processes all ~800 endpoints but only ~20 become MCP tools. +The dereferencing work is only visible in PointInTimeMetrics tool, since it's the only +MCP-enabled endpoint with $ref chains in its input schema (output schemas are hidden). +""" from typing import Any From a7f5d063d65759d1ff5239a09eaa97c79baf33f7 Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Thu, 25 Sep 2025 11:25:09 -0400 Subject: [PATCH 6/8] add todo --- src/utils/openapi_resolver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/openapi_resolver.py b/src/utils/openapi_resolver.py index 9ac8066..04743d2 100644 --- a/src/utils/openapi_resolver.py +++ b/src/utils/openapi_resolver.py @@ -9,6 +9,8 @@ Performance note: Currently processes all ~800 endpoints but only ~20 become MCP tools. The dereferencing work is only visible in PointInTimeMetrics tool, since it's the only MCP-enabled endpoint with $ref chains in its input schema (output schemas are hidden). + +TODO: Optimize to only process MCP-enabled paths """ from typing import Any From 9683b52aa524f82eb00d3faf9033e96440fedafe Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Thu, 25 Sep 2025 11:28:41 -0400 Subject: [PATCH 7/8] clean up client --- tests/manual/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/manual/client.py b/tests/manual/client.py index 914a91b..0b9eb33 100644 --- a/tests/manual/client.py +++ b/tests/manual/client.py @@ -30,7 +30,7 @@ async def test_server(server_url): print(f"\n📦 Found {len(resource_templates)} resource templates:") for resource in resources: - print(f" 📄 {resource.name}: {resource}") + print(f" 📄 {resource.name}: {resource.description}") for tool in tools: print(f" 🔧 {tool.name}: {tool.description}") From beed89cd9cd8973bc9b4f9232c91875710ecbdab Mon Sep 17 00:00:00 2001 From: Sarika Halarnakar Date: Thu, 25 Sep 2025 16:29:31 -0400 Subject: [PATCH 8/8] linter --- server.py | 2 +- src/components/customizers.py | 2 +- src/utils/openapi_resolver.py | 2 +- tests/test_openapi_resolver.py | 4 +--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/server.py b/server.py index a1834dc..bb572e3 100644 --- a/server.py +++ b/server.py @@ -4,4 +4,4 @@ from src.server import main if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/components/customizers.py b/src/components/customizers.py index 8c87463..dd2ccca 100644 --- a/src/components/customizers.py +++ b/src/components/customizers.py @@ -62,7 +62,7 @@ def customize_components( # # Openapi_resolver script replaces ALL $ref with inline schema definitions, and eliminates the need for $defs entirely. # Keep this code to prevent any remaining $defs from breaking MCP clients. - # + # if hasattr(component, 'parameters') and isinstance(component.parameters, dict): if "$defs" in component.parameters: logger.debug(f" Found $defs with {len(component.parameters['$defs'])} definitions") diff --git a/src/utils/openapi_resolver.py b/src/utils/openapi_resolver.py index 04743d2..f213536 100644 --- a/src/utils/openapi_resolver.py +++ b/src/utils/openapi_resolver.py @@ -133,4 +133,4 @@ def transform_refs(obj: Any) -> Any: if "paths" in spec: spec["paths"] = transform_refs(spec["paths"]) - return spec \ No newline at end of file + return spec diff --git a/tests/test_openapi_resolver.py b/tests/test_openapi_resolver.py index 641fa40..f0873d8 100644 --- a/tests/test_openapi_resolver.py +++ b/tests/test_openapi_resolver.py @@ -1,7 +1,5 @@ """Tests for OpenAPI $ref resolver.""" import json -from unittest.mock import patch -import pytest from src.utils.openapi_resolver import resolve_refs, resolve_refs_with_defs @@ -282,4 +280,4 @@ def test_original_spec_unchanged(self): resolve_refs(spec) # Original should be unchanged - assert json.dumps(spec, sort_keys=True) == original_json \ No newline at end of file + assert json.dumps(spec, sort_keys=True) == original_json