diff --git a/.github/workflows/api-deploy-production-ecs.yml b/.github/workflows/api-deploy-production-ecs.yml index d06d5d7ab1b6..25e7fd93595e 100644 --- a/.github/workflows/api-deploy-production-ecs.yml +++ b/.github/workflows/api-deploy-production-ecs.yml @@ -1,5 +1,8 @@ name: API Deploy to Production ECS +permissions: + contents: read + on: push: tags: @@ -15,3 +18,42 @@ jobs: with: environment: production secrets: inherit + + mcp-schema-push: + name: Push MCP Schema to Gram + runs-on: depot-ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v4 + + - name: Install Poetry + run: make install-poetry + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: poetry + + - name: Install dependencies + run: | + echo "https://${{ secrets.GH_PRIVATE_ACCESS_TOKEN }}:@github.com" > ${HOME}/.git-credentials + git config --global credential.helper store + make install-packages opts="--with saml,auth-controller,workflows,release-pipelines" + make install-private-modules + rm -rf ${HOME}/.git-credentials + + - name: Generate MCP schema + run: make generate-mcp-spec + + - name: Install Gram CLI + run: curl -fsSL https://go.getgram.ai/cli.sh | bash + + - name: Push to Gram + env: + GRAM_API_KEY: ${{ secrets.GRAM_API_KEY }} + GRAM_ORG: ${{ secrets.GRAM_ORG }} + GRAM_PROJECT: ${{ secrets.GRAM_PROJECT }} + run: gram push --api-key "$GRAM_API_KEY" --org "$GRAM_ORG" --project "$GRAM_PROJECT" --config gram.json diff --git a/api/Makefile b/api/Makefile index 5e550dae2421..db8d8287855e 100644 --- a/api/Makefile +++ b/api/Makefile @@ -156,3 +156,7 @@ generate-docs: .PHONY: add-known-sdk-version add-known-sdk-version: poetry run python scripts/add-known-sdk-version.py $(opts) + +.PHONY: generate-mcp-spec +generate-mcp-spec: + poetry run python manage.py spectacular --generator-class api.openapi.MCPSchemaGenerator --file mcp_openapi.yaml diff --git a/api/api/openapi.py b/api/api/openapi.py index 4537499e897d..6594600332b1 100644 --- a/api/api/openapi.py +++ b/api/api/openapi.py @@ -51,7 +51,91 @@ def get_schema( self, request: Request | None = None, public: bool = False ) -> dict[str, Any]: schema: dict[str, Any] = super().get_schema(request, public) # type: ignore[no-untyped-call] - schema["$schema"] = "https://spec.openapis.org/oas/3.1/dialect/base" + return { + "$schema": "https://spec.openapis.org/oas/3.1/dialect/base", + **schema, + } + + +class MCPSchemaGenerator(SchemaGenerator): + """ + Schema generator that filters to only include operations tagged with "mcp". + + Supports custom extensions: + - x-mcp-name: Override the operationId for MCP tools + - x-mcp-description: Override the description for MCP tools + """ + + MCP_TAG = "mcp" + MCP_SERVER_URL = "https://api.flagsmith.com" + + def get_schema( + self, request: Request | None = None, public: bool = False + ) -> dict[str, Any]: + schema = super().get_schema(request, public) + schema["paths"] = self._filter_paths(schema.get("paths", {})) + schema = self._update_security_for_mcp(schema) + schema.pop("$schema", None) + info = schema.pop("info") + info["title"] = "mcp_openapi" + return { + "openapi": schema.pop("openapi"), + "info": info, + "servers": [{"url": self.MCP_SERVER_URL}], + **schema, + } + + def _filter_paths(self, paths: dict[str, Any]) -> dict[str, Any]: + """Filter paths to only include operations tagged with 'mcp'.""" + filtered_paths: dict[str, Any] = {} + + for path, path_item in paths.items(): + filtered_operations: dict[str, Any] = {} + + for method, operation in path_item.items(): + if not isinstance(operation, dict): + filtered_operations[method] = operation + continue + + tags = operation.get("tags", []) + if self.MCP_TAG in tags: + filtered_operations[method] = self._transform_for_mcp(operation) + + if any(isinstance(op, dict) for op in filtered_operations.values()): + filtered_paths[path] = filtered_operations + + return filtered_paths + + def _transform_for_mcp(self, operation: dict[str, Any]) -> dict[str, Any]: + """Apply MCP-specific transformations to an operation.""" + operation = operation.copy() + + # Override operationId if x-mcp-name provided + if mcp_name := operation.pop("x-mcp-name", None): + operation["operationId"] = mcp_name + + # Override description if x-mcp-description provided + if mcp_desc := operation.pop("x-mcp-description", None): + operation["description"] = mcp_desc + + # Remove operation-level security (use global MCP security instead) + operation.pop("security", None) + + return operation + + def _update_security_for_mcp(self, schema: dict[str, Any]) -> dict[str, Any]: + """Update security schemes for MCP (Organisation API Key).""" + schema = schema.copy() + schema["components"] = schema.get("components", {}).copy() + schema["components"]["securitySchemes"] = { + "TOKEN_AUTH": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Organisation API Key. Format: Api-Key ", + }, + } + schema["security"] = [{"TOKEN_AUTH": []}] return schema diff --git a/api/api/openapi_views.py b/api/api/openapi_views.py new file mode 100644 index 000000000000..e17a27f1f4b3 --- /dev/null +++ b/api/api/openapi_views.py @@ -0,0 +1,36 @@ +from typing import Any + +from drf_spectacular.views import SpectacularJSONAPIView, SpectacularYAMLAPIView +from rest_framework.request import Request +from rest_framework.response import Response + +from api.openapi import MCPSchemaGenerator, SchemaGenerator + + +class CustomSpectacularJSONAPIView(SpectacularJSONAPIView): # type: ignore[misc] + """ + JSON schema view that supports ?mcp=true query parameter for MCP-filtered output. + """ + + def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: + if request.query_params.get("mcp", "").lower() == "true": + self.generator_class = MCPSchemaGenerator + else: + self.generator_class = SchemaGenerator + return super().get(request, *args, **kwargs) + + +class CustomSpectacularYAMLAPIView(SpectacularYAMLAPIView): # type: ignore[misc] + """ + YAML schema view that supports ?mcp=true query parameter for MCP-filtered output. + """ + + def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: + if request.query_params.get("mcp", "").lower() == "true": + self.generator_class = MCPSchemaGenerator + response = super().get(request, *args, **kwargs) + response["Content-Disposition"] = 'attachment; filename="mcp_openapi.yaml"' + return response + else: + self.generator_class = SchemaGenerator + return super().get(request, *args, **kwargs) diff --git a/api/api/urls/v1.py b/api/api/urls/v1.py index eb9edb937c75..7418673d96b1 100644 --- a/api/api/urls/v1.py +++ b/api/api/urls/v1.py @@ -1,12 +1,9 @@ from django.conf import settings from django.urls import include, path, re_path -from drf_spectacular.views import ( - SpectacularJSONAPIView, - SpectacularSwaggerView, - SpectacularYAMLAPIView, -) +from drf_spectacular.views import SpectacularSwaggerView from rest_framework import permissions, routers +from api.openapi_views import CustomSpectacularJSONAPIView, CustomSpectacularYAMLAPIView from app_analytics.views import SDKAnalyticsFlags, SelfHostedTelemetryAPIView from environments.identities.traits.views import SDKTraits from environments.identities.views import SDKIdentities @@ -73,14 +70,14 @@ # API documentation path( "swagger.json", - SpectacularJSONAPIView.as_view( + CustomSpectacularJSONAPIView.as_view( permission_classes=[schema_view_permission_class], ), name="schema-json", ), path( "swagger.yaml", - SpectacularYAMLAPIView.as_view( + CustomSpectacularYAMLAPIView.as_view( permission_classes=[schema_view_permission_class], ), name="schema-yaml", diff --git a/api/environments/views.py b/api/environments/views.py index 2258542151db..5f3a98737a2b 100644 --- a/api/environments/views.py +++ b/api/environments/views.py @@ -69,6 +69,7 @@ @method_decorator( name="list", decorator=extend_schema( + tags=["mcp"], parameters=[ OpenApiParameter( name="project", @@ -77,7 +78,11 @@ required=False, type=int, ) - ] + ], + extensions={ + "x-mcp-name": "list_environments", + "x-mcp-description": "Lists all environments the user has access to.", + }, ), ) class EnvironmentViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 5d931c16403f..9b27b321c3b6 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -1,6 +1,8 @@ import re from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.response import Response @@ -17,6 +19,16 @@ from .serializers import FeatureExternalResourceSerializer +@method_decorator( + name="list", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_feature_external_resources", + "x-mcp-description": "Retrieves external resources linked to the feature flag.", + }, + ), +) class FeatureExternalResourceViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] serializer_class = FeatureExternalResourceSerializer permission_classes = [FeatureExternalResourcePermissions] diff --git a/api/features/feature_health/views.py b/api/features/feature_health/views.py index b25efb369801..cd1721d71071 100644 --- a/api/features/feature_health/views.py +++ b/api/features/feature_health/views.py @@ -6,6 +6,7 @@ ) from django.db.models import QuerySet from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import mixins, status, viewsets from rest_framework.decorators import action, api_view, permission_classes @@ -34,6 +35,16 @@ from users.models import FFAdminUser +@method_decorator( + name="list", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_feature_health_events", + "x-mcp-description": "Retrieves feature health monitoring events and metrics for the project.", + }, + ), +) class FeatureHealthEventViewSet( mixins.ListModelMixin, viewsets.GenericViewSet[FeatureHealthEvent], diff --git a/api/features/multivariate/views.py b/api/features/multivariate/views.py index 6881f5331131..21bc053508a2 100644 --- a/api/features/multivariate/views.py +++ b/api/features/multivariate/views.py @@ -2,6 +2,7 @@ CREATE_FEATURE, VIEW_PROJECT, ) +from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import viewsets from rest_framework.decorators import api_view @@ -15,6 +16,46 @@ from .serializers import MultivariateFeatureOptionSerializer +@method_decorator( + name="list", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "list_feature_multivariate_options", + "x-mcp-description": "Retrieves all multivariate options for a feature flag.", + }, + ), +) +@method_decorator( + name="create", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "create_feature_multivariate_option", + "x-mcp-description": "Creates a new multivariate option for a feature flag.", + }, + ), +) +@method_decorator( + name="update", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "update_feature_multivariate_option", + "x-mcp-description": "Updates an existing multivariate option.", + }, + ), +) +@method_decorator( + name="destroy", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "delete_feature_multivariate_option", + "x-mcp-description": "Deletes a multivariate option.", + }, + ), +) class MultivariateFeatureOptionViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] serializer_class = MultivariateFeatureOptionSerializer diff --git a/api/features/versioning/views.py b/api/features/versioning/views.py index 34581bdd6bbd..f5471ba7a028 100644 --- a/api/features/versioning/views.py +++ b/api/features/versioning/views.py @@ -7,6 +7,8 @@ from django.db.models import BooleanField, ExpressionWrapper, Q, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema from rest_framework.decorators import action from rest_framework.generics import RetrieveAPIView from rest_framework.mixins import ( @@ -45,6 +47,26 @@ from users.models import FFAdminUser +@method_decorator( + name="list", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_environment_feature_versions", + "x-mcp-description": "Retrieves version information for a feature flag in a specific environment.", + }, + ), +) +@method_decorator( + name="create", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "create_environment_feature_version", + "x-mcp-description": "Creates a new version for a feature flag in a specific environment.", + }, + ), +) class EnvironmentFeatureVersionViewSet( GenericViewSet, # type: ignore[type-arg] ListModelMixin, @@ -184,6 +206,36 @@ def get_queryset(self): # type: ignore[no-untyped-def] return EnvironmentFeatureVersion.objects.all() +@method_decorator( + name="list", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_environment_feature_version_states", + "x-mcp-description": "Retrieves feature state information for a specific version in an environment.", + }, + ), +) +@method_decorator( + name="create", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "create_environment_feature_version_state", + "x-mcp-description": "Creates a new feature state for a specific version in an environment.", + }, + ), +) +@method_decorator( + name="update", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "update_environment_feature_version_state", + "x-mcp-description": "Updates an existing feature state for a specific version in an environment.", + }, + ), +) class EnvironmentFeatureVersionFeatureStatesViewSet( GenericViewSet, # type: ignore[type-arg] ListModelMixin, diff --git a/api/features/views.py b/api/features/views.py index 98f202a54a36..17fdbb01f54d 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -114,7 +114,44 @@ def get_feature_by_uuid(request, uuid): # type: ignore[no-untyped-def] @method_decorator( name="list", - decorator=extend_schema(parameters=[FeatureQuerySerializer]), + decorator=extend_schema( + tags=["mcp"], + parameters=[FeatureQuerySerializer], + extensions={ + "x-mcp-name": "list_project_features", + "x-mcp-description": "Retrieves all feature flags within the specified project with pagination.", + }, + ), +) +@method_decorator( + name="create", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "create_feature", + "x-mcp-description": "Creates a new feature flag in the specified project with default settings.", + }, + ), +) +@method_decorator( + name="retrieve", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_feature_flag", + "x-mcp-description": "Retrieves detailed information about a specific feature flag.", + }, + ), +) +@method_decorator( + name="update", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "update_feature", + "x-mcp-description": "Updates feature flag properties such as name and description.", + }, + ), ) class FeatureViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] permission_classes = [FeaturePermissions] @@ -410,8 +447,13 @@ def get_influx_data(self, request, pk, project_pk): # type: ignore[no-untyped-d return Response(serializer.data) @extend_schema( + tags=["mcp"], parameters=[GetUsageDataQuerySerializer], responses={200: FeatureEvaluationDataSerializer()}, + extensions={ + "x-mcp-name": "get_feature_evaluation_data", + "x-mcp-description": "Retrieves evaluation data and analytics for a specific feature flag.", + }, ) @action(detail=True, methods=["GET"], url_path="evaluation-data") @throttle_classes([InfluxQueryThrottle]) diff --git a/api/gram.json b/api/gram.json new file mode 100644 index 000000000000..c97c1d5ce930 --- /dev/null +++ b/api/gram.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0.0", + "type": "deployment", + "sources": [ + { + "type": "openapiv3", + "location": "mcp_openapi.yaml", + "name": "Flagsmith MCP", + "slug": "flagsmith-mcp" + } + ] +} diff --git a/api/organisations/invites/views.py b/api/organisations/invites/views.py index 51a9eaa357d7..54a0574aae9b 100644 --- a/api/organisations/invites/views.py +++ b/api/organisations/invites/views.py @@ -2,6 +2,7 @@ from django.conf import settings from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action, api_view from rest_framework.exceptions import PermissionDenied @@ -107,6 +108,22 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] serializer.save(organisation_id=self.kwargs.get("organisation_pk")) +@extend_schema_view( + list=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "list_organization_invites", + "x-mcp-description": "Retrieves all pending invitations for the organization.", + }, + ), + create=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "create_organization_invite", + "x-mcp-description": "Send an invitation to join the organization with specified role and permissions.", + }, + ), +) class InviteViewSet( ListModelMixin, CreateModelMixin, diff --git a/api/organisations/views.py b/api/organisations/views.py index 211fc45061cd..6df5f8b53add 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta from django.utils import timezone -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status, viewsets from rest_framework.authentication import BasicAuthentication from rest_framework.decorators import action, api_view, authentication_classes @@ -64,6 +64,15 @@ logger = logging.getLogger(__name__) +@extend_schema_view( + list=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "list_organizations", + "x-mcp-description": "Lists all organizations accessible with the provided user API key.", + }, + ), +) class OrganisationViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] permission_classes = (IsAuthenticated, OrganisationPermission) @@ -134,6 +143,13 @@ def get_by_uuid(self, request, uuid): # type: ignore[no-untyped-def] serializer = self.get_serializer(organisation) return Response(serializer.data) + @extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "list_projects_in_organization", + "x-mcp-description": "Retrieves all projects within a specified organization.", + }, + ) @action(detail=True, permission_classes=[IsAuthenticated]) def projects(self, request, pk): # type: ignore[no-untyped-def] organisation = self.get_object() diff --git a/api/projects/code_references/views.py b/api/projects/code_references/views.py index a9c1b7a478e2..95b67e669ebd 100644 --- a/api/projects/code_references/views.py +++ b/api/projects/code_references/views.py @@ -1,6 +1,7 @@ from typing import Any from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema from rest_framework import generics, response from features.models import Feature @@ -35,6 +36,13 @@ def perform_create( # type: ignore[override] serializer.save(project_id=self.kwargs["project_pk"]) +@extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_feature_code_references", + "x-mcp-description": "Retrieves code references and usage information for the feature flag.", + }, +) class FeatureFlagCodeReferenceDetailAPIView( generics.RetrieveAPIView[FeatureFlagCodeReferencesRepositorySummary], # type: ignore[type-var] ): diff --git a/api/projects/views.py b/api/projects/views.py index 19c96a61fd36..fcd2c5c8af28 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -7,7 +7,7 @@ ) from django.conf import settings from django.utils.decorators import method_decorator -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.exceptions import PermissionDenied, ValidationError @@ -52,24 +52,23 @@ @method_decorator( - name="list", + name="retrieve", decorator=extend_schema( - parameters=[ - OpenApiParameter( - name="organisation", - location=OpenApiParameter.QUERY, - description="ID of the organisation to filter by.", - required=False, - type=int, - ), - OpenApiParameter( - name="uuid", - location=OpenApiParameter.QUERY, - description="uuid of the project to filter by.", - required=False, - type=str, - ), - ] + tags=["mcp"], + extensions={ + "x-mcp-name": "get_project", + "x-mcp-description": "Retrieves comprehensive information about a specific project including configuration and statistics.", + }, + ), +) +@method_decorator( + name="update", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "update_project", + "x-mcp-description": "Updates project configuration settings such as name and feature visibility.", + }, ), ) class ProjectViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] @@ -123,6 +122,13 @@ def get_by_uuid(self, request, uuid): # type: ignore[no-untyped-def] serializer = self.get_serializer(project) return Response(serializer.data) + @extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "list_project_environments", + "x-mcp-description": "Retrieves all environments configured for the specified project.", + }, + ) @action(detail=True) def environments(self, request, pk): # type: ignore[no-untyped-def] project = self.get_object() diff --git a/api/segments/views.py b/api/segments/views.py index 800e5a8d2d88..8cfff2f3a8bc 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -36,7 +36,44 @@ @method_decorator( name="list", - decorator=extend_schema(parameters=[SegmentListQuerySerializer]), + decorator=extend_schema( + tags=["mcp"], + parameters=[SegmentListQuerySerializer], + extensions={ + "x-mcp-name": "list_project_segments", + "x-mcp-description": "Retrieves all user segments defined for audience targeting within the project.", + }, + ), +) +@method_decorator( + name="create", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "create_project_segment", + "x-mcp-description": "Creates a new user segment for audience targeting within the project.", + }, + ), +) +@method_decorator( + name="retrieve", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "get_project_segment", + "x-mcp-description": "Retrieves detailed information about a specific user segment.", + }, + ), +) +@method_decorator( + name="update", + decorator=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "update_project_segment", + "x-mcp-description": "Updates an existing user segment's properties and rules.", + }, + ), ) class SegmentViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] serializer_class = SegmentSerializer diff --git a/api/tests/unit/api/test_mcp_openapi.py b/api/tests/unit/api/test_mcp_openapi.py new file mode 100644 index 000000000000..76fe747ba66b --- /dev/null +++ b/api/tests/unit/api/test_mcp_openapi.py @@ -0,0 +1,295 @@ +from typing import Any +from unittest.mock import MagicMock + +from api.openapi import MCPSchemaGenerator, SchemaGenerator +from api.openapi_views import CustomSpectacularJSONAPIView, CustomSpectacularYAMLAPIView + + +def test_mcp_filter_paths__includes_operations_with_mcp_tag() -> None: + # Given + paths: dict[str, Any] = { + "/api/v1/organisations/": { + "get": { + "operationId": "organisations_list", + "tags": ["mcp", "organisations"], + "description": "List organisations", + }, + }, + } + generator = MCPSchemaGenerator() + + # When + filtered = generator._filter_paths(paths) + + # Then + assert "/api/v1/organisations/" in filtered + assert "get" in filtered["/api/v1/organisations/"] + + +def test_mcp_filter_paths__excludes_operations_without_mcp_tag() -> None: + # Given + paths: dict[str, Any] = { + "/api/v1/users/": { + "get": { + "operationId": "users_list", + "tags": ["users"], + "description": "List users", + }, + }, + } + generator = MCPSchemaGenerator() + + # When + filtered = generator._filter_paths(paths) + + # Then + assert "/api/v1/users/" not in filtered + + +def test_mcp_filter_paths__mixed_operations() -> None: + # Given + paths: dict[str, Any] = { + "/api/v1/organisations/": { + "get": { + "operationId": "organisations_list", + "tags": ["mcp", "organisations"], + }, + "post": { + "operationId": "organisations_create", + "tags": ["organisations"], # No mcp tag + }, + }, + } + generator = MCPSchemaGenerator() + + # When + filtered = generator._filter_paths(paths) + + # Then + assert "/api/v1/organisations/" in filtered + assert "get" in filtered["/api/v1/organisations/"] + assert "post" not in filtered["/api/v1/organisations/"] + + +def test_mcp_transform_for_mcp__overrides_operation_id_with_x_mcp_name() -> None: + # Given + operation: dict[str, Any] = { + "operationId": "organisations_list", + "tags": ["mcp"], + "x-mcp-name": "list_organisations", + } + generator = MCPSchemaGenerator() + + # When + transformed = generator._transform_for_mcp(operation) + + # Then + assert transformed["operationId"] == "list_organisations" + assert "x-mcp-name" not in transformed + + +def test_mcp_transform_for_mcp__overrides_description_with_x_mcp_description() -> None: + # Given + operation: dict[str, Any] = { + "operationId": "organisations_list", + "tags": ["mcp"], + "description": "Original description", + "x-mcp-description": "MCP-specific description", + } + generator = MCPSchemaGenerator() + + # When + transformed = generator._transform_for_mcp(operation) + + # Then + assert transformed["description"] == "MCP-specific description" + assert "x-mcp-description" not in transformed + + +def test_mcp_transform_for_mcp__preserves_original_when_no_extensions() -> None: + # Given + operation: dict[str, Any] = { + "operationId": "organisations_list", + "tags": ["mcp"], + "description": "Original description", + } + generator = MCPSchemaGenerator() + + # When + transformed = generator._transform_for_mcp(operation) + + # Then + assert transformed["operationId"] == "organisations_list" + assert transformed["description"] == "Original description" + + +def test_mcp_update_security_for_mcp__sets_api_key_security_scheme() -> None: + # Given + schema: dict[str, Any] = { + "components": { + "securitySchemes": { + "Private": {"type": "apiKey"}, + }, + }, + "security": [{"Private": []}], + } + generator = MCPSchemaGenerator() + + # When + updated = generator._update_security_for_mcp(schema) + + # Then + assert "ApiKey" in updated["components"]["securitySchemes"] + assert updated["security"] == [{"ApiKey": []}] + # Original scheme should be replaced + assert "Private" not in updated["components"]["securitySchemes"] + + +def test_mcp_get_schema__filters_and_transforms() -> None: + # Given + generator = MCPSchemaGenerator() + + # When + schema = generator.get_schema(request=None, public=True) + + # Then + assert "openapi" in schema + assert "paths" in schema + assert "components" in schema + assert "ApiKey" in schema["components"]["securitySchemes"] + + +def test_custom_json_view__returns_mcp_generator_when_mcp_param_is_true() -> None: + # Given + view = CustomSpectacularJSONAPIView() + view.request = MagicMock() + view.request.query_params = {"mcp": "true"} + + # When + generator_class = view.get_generator_class() + + # Then + assert generator_class is MCPSchemaGenerator + + +def test_custom_json_view__returns_schema_generator_when_mcp_param_is_false() -> None: + # Given + view = CustomSpectacularJSONAPIView() + view.request = MagicMock() + view.request.query_params = {"mcp": "false"} + + # When + generator_class = view.get_generator_class() + + # Then + assert generator_class is SchemaGenerator + + +def test_custom_json_view__returns_schema_generator_when_no_mcp_param() -> None: + # Given + view = CustomSpectacularJSONAPIView() + view.request = MagicMock() + view.request.query_params = {} + + # When + generator_class = view.get_generator_class() + + # Then + assert generator_class is SchemaGenerator + + +def test_custom_yaml_view__returns_mcp_generator_when_mcp_param_is_true() -> None: + # Given + view = CustomSpectacularYAMLAPIView() + view.request = MagicMock() + view.request.query_params = {"mcp": "true"} + + # When + generator_class = view.get_generator_class() + + # Then + assert generator_class is MCPSchemaGenerator + + +def test_custom_yaml_view__returns_schema_generator_when_no_mcp_param() -> None: + # Given + view = CustomSpectacularYAMLAPIView() + view.request = MagicMock() + view.request.query_params = {} + + # When + generator_class = view.get_generator_class() + + # Then + assert generator_class is SchemaGenerator + + +def test_custom_json_view__case_insensitive_mcp_param() -> None: + # Given + view = CustomSpectacularJSONAPIView() + view.request = MagicMock() + view.request.query_params = {"mcp": "TRUE"} + + # When + generator_class = view.get_generator_class() + + # Then + assert generator_class is MCPSchemaGenerator + + +def test_mcp_schema__includes_organisations_endpoint() -> None: + # Given + generator = MCPSchemaGenerator() + + # When + schema = generator.get_schema(request=None, public=True) + + # Then + assert "/api/v1/organisations/" in schema["paths"] + org_list = schema["paths"]["/api/v1/organisations/"]["get"] + assert org_list["operationId"] == "list_organizations" + assert ( + org_list["description"] + == "Lists all organizations accessible with the provided user API key." + ) + + +def test_mcp_schema__includes_organisation_projects_endpoint() -> None: + # Given + generator = MCPSchemaGenerator() + + # When + schema = generator.get_schema(request=None, public=True) + + # Then + assert "/api/v1/organisations/{id}/projects/" in schema["paths"] + projects_list = schema["paths"]["/api/v1/organisations/{id}/projects/"]["get"] + assert projects_list["operationId"] == "list_projects_in_organization" + assert ( + projects_list["description"] + == "Retrieves all projects within a specified organization." + ) + + +def test_mcp_schema__excludes_non_mcp_endpoints() -> None: + # Given + generator = MCPSchemaGenerator() + + # When + schema = generator.get_schema(request=None, public=True) + + # Then + # Users endpoint should not be in MCP schema (not tagged) + assert "/api/v1/users/" not in schema["paths"] + + +def test_mcp_schema__includes_https_server() -> None: + # Given + generator = MCPSchemaGenerator() + + # When + schema = generator.get_schema(request=None, public=True) + + # Then + assert "servers" in schema + assert schema["servers"] == [{"url": "https://api.flagsmith.com"}] diff --git a/api/users/views.py b/api/users/views.py index d3ab197c7d8f..78ce9a2914d4 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404, redirect from django.views import View from django.views.generic.edit import FormView -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import mixins, status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.exceptions import PermissionDenied @@ -160,6 +160,15 @@ def password_reset_redirect( return redirect(f"{current_site_url}/password-reset/{uidb64}/{token}") +@extend_schema_view( + list=extend_schema( + tags=["mcp"], + extensions={ + "x-mcp-name": "list_organization_groups", + "x-mcp-description": "Retrieves all permission groups within the organization.", + }, + ), +) class UserPermissionGroupViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] permission_classes = [IsAuthenticated, UserPermissionGroupPermission]