Skip to content

Commit 97bb4f4

Browse files
feat: open-api-specs-describe-oauth (#7148)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8dd8c46 commit 97bb4f4

File tree

5 files changed

+99
-29
lines changed

5 files changed

+99
-29
lines changed

api/api/openapi.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from rest_framework.request import Request
1212
from typing_extensions import is_typeddict
1313

14+
from oauth2_metadata.dataclasses import OAuthConfig
15+
1416

1517
def append_meta(schema: dict[str, Any], meta: dict[str, Any]) -> dict[str, Any]:
1618
"""
@@ -73,21 +75,21 @@ class MCPSchemaGenerator(SchemaGenerator):
7375
"""
7476

7577
MCP_TAG = "mcp"
76-
MCP_SERVER_URL = "https://api.flagsmith.com"
7778

7879
def get_schema(
7980
self, request: Request | None = None, public: bool = False
8081
) -> dict[str, Any]:
82+
oauth = OAuthConfig.from_settings()
8183
schema = super().get_schema(request, public)
8284
schema["paths"] = self._filter_paths(schema.get("paths", {}))
83-
schema = self._update_security_for_mcp(schema)
85+
schema = self._update_security_for_mcp(schema, oauth)
8486
schema.pop("$schema", None)
8587
info = schema.pop("info").copy()
8688
info["title"] = "mcp_openapi"
8789
return {
8890
"openapi": schema.pop("openapi"),
8991
"info": info,
90-
"servers": [{"url": self.MCP_SERVER_URL}],
92+
"servers": [{"url": oauth.api_url}],
9193
**schema,
9294
}
9395

@@ -121,19 +123,34 @@ def _transform_for_mcp(self, operation: dict[str, Any]) -> dict[str, Any]:
121123
operation.pop("security", None)
122124
return operation
123125

124-
def _update_security_for_mcp(self, schema: dict[str, Any]) -> dict[str, Any]:
125-
"""Update security schemes for MCP (Organisation API Key)."""
126+
def _update_security_for_mcp(
127+
self, schema: dict[str, Any], oauth: OAuthConfig
128+
) -> dict[str, Any]:
129+
"""Update security schemes for MCP (OAuth + API Key fallback)."""
126130
schema = schema.copy()
127131
schema["components"] = schema.get("components", {}).copy()
128132
schema["components"]["securitySchemes"] = {
133+
"oauth2": {
134+
"type": "oauth2",
135+
"flows": {
136+
"authorizationCode": {
137+
"authorizationUrl": f"{oauth.frontend_url}/oauth/authorize/",
138+
"tokenUrl": f"{oauth.api_url}/o/token/",
139+
"scopes": oauth.scopes,
140+
},
141+
},
142+
},
129143
"TOKEN_AUTH": {
130144
"type": "apiKey",
131145
"in": "header",
132146
"name": "Authorization",
133147
"description": "Organisation API Key. Format: Api-Key <key>",
134148
},
135149
}
136-
schema["security"] = [{"TOKEN_AUTH": []}]
150+
schema["security"] = [
151+
{"oauth2": list(oauth.scopes.keys())},
152+
{"TOKEN_AUTH": []},
153+
]
137154
return schema
138155

139156

api/oauth2_metadata/dataclasses.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from django.conf import settings
7+
8+
9+
@dataclass(frozen=True)
10+
class OAuthConfig:
11+
"""Base OAuth configuration derived from Django settings."""
12+
13+
api_url: str
14+
frontend_url: str
15+
scopes: dict[str, str]
16+
17+
@classmethod
18+
def from_settings(cls) -> OAuthConfig:
19+
oauth2_provider: dict[str, Any] = settings.OAUTH2_PROVIDER
20+
return cls(
21+
api_url=settings.FLAGSMITH_API_URL.rstrip("/"),
22+
frontend_url=settings.FLAGSMITH_FRONTEND_URL.rstrip("/"),
23+
scopes=oauth2_provider.get("SCOPES", {}),
24+
)

api/oauth2_metadata/views.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import Any
22
from urllib.parse import urlencode, urlparse, urlunparse
33

4-
from django.conf import settings
54
from django.http import HttpRequest, JsonResponse, QueryDict
65
from django.views.decorators.csrf import csrf_exempt
76
from django.views.decorators.http import require_GET
@@ -17,6 +16,7 @@
1716
from rest_framework.throttling import ScopedRateThrottle
1817
from rest_framework.views import APIView
1918

19+
from oauth2_metadata.dataclasses import OAuthConfig
2020
from oauth2_metadata.serializers import DCRRequestSerializer, OAuthConsentSerializer
2121
from oauth2_metadata.services import create_oauth2_application
2222

@@ -25,19 +25,16 @@
2525
@require_GET
2626
def authorization_server_metadata(request: HttpRequest) -> JsonResponse:
2727
"""RFC 8414 OAuth 2.0 Authorization Server Metadata."""
28-
api_url: str = settings.FLAGSMITH_API_URL.rstrip("/")
29-
frontend_url: str = settings.FLAGSMITH_FRONTEND_URL.rstrip("/")
30-
oauth2_settings: dict[str, Any] = settings.OAUTH2_PROVIDER
31-
scopes: dict[str, str] = oauth2_settings.get("SCOPES", {})
28+
oauth = OAuthConfig.from_settings()
3229

3330
metadata = {
34-
"issuer": api_url,
35-
"authorization_endpoint": f"{frontend_url}/oauth/authorize/",
36-
"token_endpoint": f"{api_url}/o/token/",
37-
"registration_endpoint": f"{api_url}/o/register/",
38-
"revocation_endpoint": f"{api_url}/o/revoke_token/",
39-
"introspection_endpoint": f"{api_url}/o/introspect/",
40-
"scopes_supported": list(scopes.keys()),
31+
"issuer": oauth.api_url,
32+
"authorization_endpoint": f"{oauth.frontend_url}/oauth/authorize/",
33+
"token_endpoint": f"{oauth.api_url}/o/token/",
34+
"registration_endpoint": f"{oauth.api_url}/o/register/",
35+
"revocation_endpoint": f"{oauth.api_url}/o/revoke_token/",
36+
"introspection_endpoint": f"{oauth.api_url}/o/introspect/",
37+
"scopes_supported": list(oauth.scopes.keys()),
4138
"response_types_supported": ["code"],
4239
"grant_types_supported": ["authorization_code", "refresh_token"],
4340
"code_challenge_methods_supported": ["S256"],

api/tests/unit/api/test_mcp_openapi.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from unittest.mock import MagicMock
33

44
import pytest
5+
from pytest_django.fixtures import SettingsWrapper
56

67
from api.openapi import MCPSchemaGenerator, SchemaGenerator
78
from api.openapi_views import CustomSpectacularJSONAPIView, CustomSpectacularYAMLAPIView
9+
from oauth2_metadata.dataclasses import OAuthConfig
810

911

1012
def test_mcp_filter_paths__mcp_tagged_operation__includes_path() -> None:
@@ -143,10 +145,15 @@ def test_mcp_transform_for_mcp__security_present__removes_operation_level_securi
143145
assert "security" not in transformed
144146

145147

146-
def test_mcp_update_security_for_mcp__existing_scheme__sets_token_auth_security() -> (
148+
def test_mcp_update_security_for_mcp__existing_scheme__sets_oauth_and_token_auth() -> (
147149
None
148150
):
149151
# Given
152+
oauth = OAuthConfig(
153+
api_url="https://api.flagsmith.example.com",
154+
frontend_url="https://app.flagsmith.example.com",
155+
scopes={"mcp": "MCP access"},
156+
)
150157
schema: dict[str, Any] = {
151158
"components": {
152159
"securitySchemes": {
@@ -158,12 +165,21 @@ def test_mcp_update_security_for_mcp__existing_scheme__sets_token_auth_security(
158165
generator = MCPSchemaGenerator()
159166

160167
# When
161-
updated = generator._update_security_for_mcp(schema)
168+
updated = generator._update_security_for_mcp(schema, oauth)
162169

163170
# Then
164-
assert "TOKEN_AUTH" in updated["components"]["securitySchemes"]
165-
assert updated["security"] == [{"TOKEN_AUTH": []}]
166171
assert "Private" not in updated["components"]["securitySchemes"]
172+
assert "TOKEN_AUTH" in updated["components"]["securitySchemes"]
173+
oauth2_scheme = updated["components"]["securitySchemes"]["oauth2"]
174+
assert oauth2_scheme["type"] == "oauth2"
175+
auth_code_flow = oauth2_scheme["flows"]["authorizationCode"]
176+
assert (
177+
auth_code_flow["authorizationUrl"]
178+
== "https://app.flagsmith.example.com/oauth/authorize/"
179+
)
180+
assert auth_code_flow["tokenUrl"] == "https://api.flagsmith.example.com/o/token/"
181+
assert auth_code_flow["scopes"] == {"mcp": "MCP access"}
182+
assert updated["security"] == [{"oauth2": ["mcp"]}, {"TOKEN_AUTH": []}]
167183

168184

169185
@pytest.mark.parametrize(
@@ -267,24 +283,32 @@ def test_mcp_schema__full_schema_generated__includes_expected_endpoints_only() -
267283
assert "/api/v1/users/" not in paths
268284

269285

270-
def test_mcp_schema__full_schema_generated__includes_https_server() -> None:
286+
def test_mcp_schema__full_schema_generated__includes_server_from_settings(
287+
settings: SettingsWrapper,
288+
) -> None:
271289
# Given
290+
settings.FLAGSMITH_API_URL = "https://flagsmith.example.com/"
272291
generator = MCPSchemaGenerator()
273292

274293
# When
275294
schema = generator.get_schema(request=None, public=True)
276295

277296
# Then
278-
assert schema["servers"] == [{"url": "https://api.flagsmith.com"}]
297+
assert schema["servers"] == [{"url": "https://flagsmith.example.com"}]
279298

280299

281-
def test_mcp_schema__full_schema_generated__includes_token_auth_security() -> None:
300+
def test_mcp_schema__full_schema_generated__includes_oauth_and_token_auth(
301+
settings: SettingsWrapper,
302+
) -> None:
282303
# Given
304+
settings.FLAGSMITH_API_URL = "https://api.flagsmith.example.com/"
305+
settings.FLAGSMITH_FRONTEND_URL = "https://app.flagsmith.example.com/"
283306
generator = MCPSchemaGenerator()
284307

285308
# When
286309
schema = generator.get_schema(request=None, public=True)
287310

288311
# Then
289312
assert "TOKEN_AUTH" in schema["components"]["securitySchemes"]
290-
assert schema["security"] == [{"TOKEN_AUTH": []}]
313+
assert "oauth2" in schema["components"]["securitySchemes"]
314+
assert schema["security"] == [{"oauth2": ["mcp"]}, {"TOKEN_AUTH": []}]

docs/docs/integrating-with-flagsmith/mcp-server.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,17 @@ Head to our installation page and pick your client:
2727

2828
We support Cursor, Claude Code, Claude Desktop, Windsurf, Gemini CLI, Codex CLI, and any other client that supports MCP servers.
2929

30-
### Configuration
30+
### Authentication
3131

32-
You'll need an **Organisation API Key** from Flagsmith:
32+
The MCP Server supports two authentication methods. You can use either one — both work side by side.
33+
34+
#### OAuth (Recommended)
35+
36+
OAuth lets you authenticate directly in your browser — no API keys to manage. When you first connect, your MCP client will open a browser window where you log in to Flagsmith and authorise access.
37+
38+
#### Organisation API Key
39+
40+
Alternatively, you can authenticate using an Organisation API Key:
3341

3442
1. Go to **Organisation Settings** in your Flagsmith dashboard
3543
2. Generate a new API Key
@@ -48,7 +56,7 @@ Running your own Flagsmith instance? Point the MCP Server at your API by adding
4856
```bash
4957
claude mcp add --transport http "flagsmith" \
5058
"https://app.getgram.ai/mcp/flagsmith-mcp" \
51-
--header 'Mcp-Flagsmith-Token-Auth:${MCP_FLAGSMITH_TOKEN_AUTH}'
59+
--header 'Mcp-Flagsmith-Token-Auth:${MCP_FLAGSMITH_TOKEN_AUTH}' \
5260
--header 'Mcp-Flagsmith-Server-Url:https://your-flagsmith-instance.com'
5361
```
5462

0 commit comments

Comments
 (0)