Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
be43ede
feat: added-mcp-filtered-tag-openapi-schema-generation
Zaimwa9 Jan 7, 2026
893ee8b
feat: added-iso-endpoints-to-current-gram
Zaimwa9 Jan 8, 2026
c887686
feat: added-list-environment-endpoint
Zaimwa9 Jan 8, 2026
8347c94
feat: try-ci-locally
Zaimwa9 Jan 8, 2026
25a58df
feat: removed-useless-comments
Zaimwa9 Jan 8, 2026
6d2deac
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 8, 2026
8e1a64d
feat: install-with-no-root
Zaimwa9 Jan 9, 2026
46efa8e
Merge branch 'feat/synchronize-openapi-schema-with-gram' of github.co…
Zaimwa9 Jan 9, 2026
7b0ecb4
feat: use-make-install
Zaimwa9 Jan 9, 2026
b5fc717
feat: changed-install-gram-step
Zaimwa9 Jan 9, 2026
fab2768
feat: added-gram-org
Zaimwa9 Jan 9, 2026
c3b8f97
feat: moved-gram-push-to-deploy-workflow
Zaimwa9 Jan 9, 2026
c27833e
add-permissions-read-to-gram-step
Zaimwa9 Jan 9, 2026
e58ea58
feat: finetuning-open-api-generation
Zaimwa9 Jan 12, 2026
9d2351a
feat: integrate-security-scheme-and-missing-endpoints
Zaimwa9 Jan 12, 2026
47b03de
feat: bumped-workflows-and-private
Zaimwa9 Jan 16, 2026
52c002f
feat: bumped-workflows-and-private
Zaimwa9 Jan 16, 2026
444a4fa
feat: moved-to-using-x-gram
Zaimwa9 Jan 19, 2026
ce1244d
feat: added-gram-push-to-pr-workflow
Zaimwa9 Jan 19, 2026
b3c5467
feat: added-permission-checks
Zaimwa9 Jan 19, 2026
8c4c7fd
feat: trigger-pipeline
Zaimwa9 Jan 19, 2026
71a4800
feat: fixed-conflicts
Zaimwa9 Jan 19, 2026
890cfb9
feat: re-added-job
Zaimwa9 Jan 19, 2026
1f89181
feat: resynced-lock
Zaimwa9 Jan 19, 2026
6ccc3c4
feat: re-added-private-module
Zaimwa9 Jan 19, 2026
4d2fb11
feat: cleanup-site-packagers
Zaimwa9 Jan 19, 2026
1d312be
feat: bumped-private-modules-versions
Zaimwa9 Jan 19, 2026
80ce3ce
feat: check-auth-prefix
Zaimwa9 Jan 19, 2026
a7188b6
feat: fixed-mypy
Zaimwa9 Jan 19, 2026
3c1fd00
feat: removed-dot
Zaimwa9 Jan 19, 2026
27bd651
feat: removed-gram-on-platform-pull-request
Zaimwa9 Jan 19, 2026
4cbf4d5
feat: clean-site-packages-before-mv
Zaimwa9 Jan 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/api-deploy-production-ecs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,33 @@
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: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: make install

- 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
4 changes: 4 additions & 0 deletions api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
83 changes: 82 additions & 1 deletion api/api/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,88 @@ 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"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info dict mutated without copying causing potential state corruption

Medium Severity

The info dict is retrieved from the schema and mutated directly (info["title"] = "mcp_openapi") without creating a copy first. Since _update_security_for_mcp only performs a shallow copy of the schema, the info object remains shared with the parent schema from drf-spectacular. If drf-spectacular caches the schema internally, this mutation could corrupt the cached data, causing the regular schema endpoint to incorrectly display "mcp_openapi" as the title after the MCP endpoint has been accessed.

Fix in Cursor Fix in Web

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

return operation

def _update_security_for_mcp(self, schema: dict[str, Any]) -> dict[str, Any]:
"""Update security schemes for MCP (admin API key)."""
schema = schema.copy()
schema["components"] = schema.get("components", {}).copy()
schema["components"]["securitySchemes"] = {
"ApiKey": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "API key for MCP access. Format: Api-Key <key>",
},
}
schema["security"] = [{"ApiKey": []}]
return schema


Expand Down
36 changes: 36 additions & 0 deletions api/api/openapi_views.py
Original file line number Diff line number Diff line change
@@ -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]

Check failure on line 10 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Unused "type: ignore" comment

Check failure on line 10 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Unused "type: ignore" comment
"""
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)

Check failure on line 20 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Call to untyped function "get" of "SpectacularAPIView" in typed context

Check failure on line 20 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Returning Any from function declared to return "Response"

Check failure on line 20 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Call to untyped function "get" of "SpectacularAPIView" in typed context

Check failure on line 20 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Returning Any from function declared to return "Response"


class CustomSpectacularYAMLAPIView(SpectacularYAMLAPIView): # type: ignore[misc]

Check failure on line 23 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Unused "type: ignore" comment

Check failure on line 23 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Unused "type: ignore" comment
"""
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)

Check failure on line 31 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Call to untyped function "get" of "SpectacularAPIView" in typed context

Check failure on line 31 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Call to untyped function "get" of "SpectacularAPIView" in typed context
response["Content-Disposition"] = 'attachment; filename="mcp_openapi.yaml"'
return response

Check failure on line 33 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Returning Any from function declared to return "Response"

Check failure on line 33 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Returning Any from function declared to return "Response"
else:
self.generator_class = SchemaGenerator
return super().get(request, *args, **kwargs)

Check failure on line 36 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Call to untyped function "get" of "SpectacularAPIView" in typed context

Check failure on line 36 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Returning Any from function declared to return "Response"

Check failure on line 36 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Call to untyped function "get" of "SpectacularAPIView" in typed context

Check failure on line 36 in api/api/openapi_views.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Returning Any from function declared to return "Response"
11 changes: 4 additions & 7 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion api/environments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
@method_decorator(
name="list",
decorator=extend_schema(
tags=["mcp"],
parameters=[
OpenApiParameter(
name="project",
Expand All @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions api/features/feature_external_resources/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions api/features/feature_health/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
41 changes: 41 additions & 0 deletions api/features/multivariate/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
32 changes: 32 additions & 0 deletions api/features/versioning/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -184,6 +206,16 @@ 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.",
},
),
)
class EnvironmentFeatureVersionFeatureStatesViewSet(
GenericViewSet, # type: ignore[type-arg]
ListModelMixin,
Expand Down
Loading
Loading