axmp-openapi-fastmcp-server 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """This is the MCP server for the ZMP OpenAPI."""
2
+
3
+ import logging
4
+
5
+ # for stdio
6
+ logging.basicConfig(
7
+ level=logging.DEBUG,
8
+ format="[%(asctime)s.%(msecs)d] [%(levelname)s] [%(name)s] [%(threadName)s:%(thread)d] [%(module)s:%(funcName)s:%(lineno)d] - %(message)s",
9
+ datefmt="%Y-%m-%d %H:%M:%S",
10
+ handlers=[logging.StreamHandler()],
11
+ )
12
+
13
+ # for streamable-http
14
+ # logging.config.fileConfig("logging.conf", disable_existing_loggers=False)
15
+
16
+ logging.getLogger("httpcore.http11").setLevel(logging.INFO)
17
+ logging.getLogger("sse_starlette.sse").setLevel(logging.INFO)
18
+ # logging.getLogger("axmp_openapi_helper").setLevel(logging.DEBUG)
19
+ # logging.getLogger("axmp_openapi_mcp_server.openapi_mcp_server").setLevel(logging.DEBUG)
@@ -0,0 +1,7 @@
1
+ """This is the main entry point for the MCP server."""
2
+
3
+ import sys
4
+
5
+ from axmp_openapi_mcp_server.runner.cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,320 @@
1
+ """Keycloak OAuth provider for FastMCP.
2
+
3
+ This module provides a complete Keycloak OAuth integration that's ready to use
4
+ with just a client ID and client secret. It handles all the complexity of
5
+ Keycloak's OAuth flow, token validation, and user management.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ import httpx
13
+ from fastmcp.server.auth import TokenVerifier
14
+ from fastmcp.server.auth.auth import AccessToken
15
+ from fastmcp.server.auth.oauth_proxy import OAuthProxy
16
+ from fastmcp.utilities.auth import parse_scopes
17
+ from fastmcp.utilities.logging import get_logger
18
+ from key_value.aio.protocols import AsyncKeyValue
19
+ from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class KeyCloakEndpoints(BaseModel):
25
+ """Keycloak endpoints."""
26
+
27
+ issuer_url: AnyHttpUrl | str = Field(...)
28
+ token_endpoint: AnyHttpUrl | str | None = None
29
+ authorization_endpoint: AnyHttpUrl | str | None = None
30
+ user_info_endpoint: AnyHttpUrl | str | None = None
31
+ jwks_uri: AnyHttpUrl | str | None = None
32
+ introspection_endpoint: AnyHttpUrl | str | None = None
33
+ revocation_endpoint: AnyHttpUrl | str | None = None
34
+
35
+ @model_validator(mode="after")
36
+ def set_defaults_from_issuer_url(self) -> KeyCloakEndpoints:
37
+ """Set default endpoint URLs based on the actual issuer_url value."""
38
+ base = str(self.issuer_url).rstrip("/")
39
+ if self.token_endpoint is None:
40
+ self.token_endpoint = f"{base}/protocol/openid-connect/token"
41
+ if self.authorization_endpoint is None:
42
+ self.authorization_endpoint = f"{base}/protocol/openid-connect/auth"
43
+ if self.user_info_endpoint is None:
44
+ self.user_info_endpoint = f"{base}/protocol/openid-connect/userinfo"
45
+ if self.jwks_uri is None:
46
+ self.jwks_uri = f"{base}/protocol/openid-connect/certs"
47
+ if self.introspection_endpoint is None:
48
+ self.introspection_endpoint = (
49
+ f"{base}/protocol/openid-connect/token/introspect"
50
+ )
51
+ if self.revocation_endpoint is None:
52
+ self.revocation_endpoint = (
53
+ f"{base}/protocol/openid-connect/token/revocation"
54
+ )
55
+ return self
56
+
57
+
58
+ class KeyCloakTokenVerifier(TokenVerifier):
59
+ """Token verifier for Keycloak OAuth tokens.
60
+
61
+ Keycloak OAuth tokens are opaque (not JWTs), so we verify them
62
+ by calling Keycloak's tokeninfo API to check if they're valid and get user info.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ issuer_url: AnyHttpUrl | str,
69
+ client_id: str,
70
+ client_secret: str,
71
+ required_scopes: list[str] | None = None,
72
+ timeout_seconds: int = 10,
73
+ ):
74
+ """Initialize the Keycloak token verifier.
75
+
76
+ Args:
77
+ client_id: Client ID for introspection authentication
78
+ client_secret: Client secret for introspection authentication
79
+ required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
80
+ timeout_seconds: HTTP request timeout
81
+ """
82
+ super().__init__(required_scopes=required_scopes)
83
+ self.client_id = client_id
84
+ self.client_secret = client_secret
85
+ self.timeout_seconds = timeout_seconds
86
+ self.keycloak_endpoints = KeyCloakEndpoints(issuer_url=issuer_url)
87
+
88
+ async def verify_token(self, token: str) -> AccessToken | None:
89
+ """Verify Keycloak OAuth token by calling Keycloak's tokeninfo API."""
90
+ try:
91
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
92
+ # Use Keycloak's introspection endpoint to validate the token
93
+ # Keycloak expects POST with basic auth for confidential clients
94
+ response = await client.post(
95
+ self.keycloak_endpoints.introspection_endpoint,
96
+ data={"token": token},
97
+ auth=(self.client_id, self.client_secret),
98
+ headers={"User-Agent": "FastMCP-Keycloak-OAuth"},
99
+ )
100
+
101
+ if response.status_code != 200:
102
+ logger.debug(
103
+ "Keycloak token verification failed: %d",
104
+ response.status_code,
105
+ )
106
+ return None
107
+
108
+ token_info = response.json()
109
+
110
+ # Check if token is active
111
+ if not token_info.get("active"):
112
+ logger.debug("Token is inactive")
113
+ return None
114
+
115
+ # Check if token is expired (backup check)
116
+ expires_in = token_info.get("expires_in")
117
+ if expires_in and int(expires_in) <= 0:
118
+ logger.debug("Access token has expired")
119
+ return None
120
+
121
+ # Extract scopes from token info
122
+ scope_string = token_info.get("scope", "")
123
+ token_scopes = [
124
+ scope.strip() for scope in scope_string.split(" ") if scope.strip()
125
+ ]
126
+
127
+ # Check required scopes
128
+ if self.required_scopes:
129
+ token_scopes_set = set(token_scopes)
130
+ required_scopes_set = set(self.required_scopes)
131
+ if not required_scopes_set.issubset(token_scopes_set):
132
+ logger.debug(
133
+ "Access token missing required scopes. Has %d, needs %d",
134
+ len(token_scopes_set),
135
+ len(required_scopes_set),
136
+ )
137
+ return None
138
+
139
+ # Get additional user info if we have the right scopes
140
+ user_data = {}
141
+ if "openid" in token_scopes or "profile" in token_scopes:
142
+ try:
143
+ userinfo_response = await client.get(
144
+ self.keycloak_endpoints.user_info_endpoint,
145
+ headers={
146
+ "Authorization": f"Bearer {token}",
147
+ "User-Agent": "FastMCP-Keycloak-OAuth",
148
+ },
149
+ )
150
+ if userinfo_response.status_code == 200:
151
+ user_data = userinfo_response.json()
152
+ except Exception as e:
153
+ logger.debug("Failed to fetch Keycloak user info: %s", e)
154
+
155
+ # Calculate expiration time
156
+ expires_at = None
157
+ if expires_in:
158
+ expires_at = int(time.time() + int(expires_in))
159
+
160
+ # Create AccessToken with Keycloak user info
161
+ access_token = AccessToken(
162
+ token=token,
163
+ client_id=token_info.get(
164
+ "audience", "unknown"
165
+ ), # Use audience as client_id
166
+ scopes=token_scopes,
167
+ expires_at=expires_at,
168
+ claims={
169
+ "sub": user_data.get("sub"),
170
+ "email": user_data.get("email"),
171
+ "username": user_data.get("preferred_username")
172
+ if user_data.get("preferred_username")
173
+ else user_data.get("email"),
174
+ "given_name": user_data.get("given_name"),
175
+ "family_name": user_data.get("family_name"),
176
+ "keycloak_user_data": user_data,
177
+ "keycloak_token_info": token_info,
178
+ },
179
+ )
180
+ logger.debug("Keycloak token verified successfully")
181
+ return access_token
182
+
183
+ except httpx.RequestError as e:
184
+ logger.debug("Failed to verify Keycloak token: %s", e)
185
+ return None
186
+ except Exception as e:
187
+ logger.debug("Keycloak token verification error: %s", e)
188
+ return None
189
+
190
+
191
+ class KeyCloakProvider(OAuthProxy):
192
+ """Complete Keycloak OAuth provider for FastMCP.
193
+
194
+ This provider makes it trivial to add Keycloak OAuth protection to any
195
+ FastMCP server. Just provide your Keycloak OAuth app credentials and
196
+ a base URL, and you're ready to go.
197
+
198
+ Features:
199
+ - Transparent OAuth proxy to Keycloak
200
+ - Automatic token validation via Keycloak's tokeninfo API
201
+ - User information extraction from Keycloak APIs
202
+ - Minimal configuration required
203
+
204
+ Example:
205
+ ```python
206
+ from fastmcp import FastMCP
207
+ from fastmcp.server.auth.providers.keycloak import KeycloakProvider
208
+
209
+ auth = KeycloakProvider(
210
+ client_id="123456789.apps.googleusercontent.com",
211
+ client_secret="GOCSPX-abc123...",
212
+ base_url="https://my-server.com"
213
+ )
214
+
215
+ mcp = FastMCP("My App", auth=auth)
216
+ ```
217
+ """
218
+
219
+ def __init__(
220
+ self,
221
+ *,
222
+ client_id: str,
223
+ client_secret: str,
224
+ base_url: AnyHttpUrl | str,
225
+ issuer_url: AnyHttpUrl | str,
226
+ redirect_path: str | None = None,
227
+ required_scopes: list[str] | None = None,
228
+ timeout_seconds: int = 10,
229
+ allowed_client_redirect_uris: list[str] | None = None,
230
+ client_storage: AsyncKeyValue | None = None,
231
+ jwt_signing_key: str | bytes | None = None,
232
+ require_authorization_consent: bool = True,
233
+ extra_authorize_params: dict[str, str] | None = None,
234
+ ):
235
+ """Initialize KeyCloak OAuth provider.
236
+
237
+ Args:
238
+ client_id: KeyCloak OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
239
+ client_secret: KeyCloak OAuth client secret (e.g., "GOCSPX-abc123...")
240
+ base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
241
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
242
+ to avoid 404s during discovery when mounting under a path.
243
+ redirect_path: Redirect path configured in KeyCloak OAuth app (defaults to "/auth/callback")
244
+ required_scopes: Required KeyCloak scopes (defaults to ["openid"]). Common scopes include:
245
+ - "openid" for OpenID Connect (default)
246
+ - "https://www.googleapis.com/auth/userinfo.email" for email access
247
+ - "https://www.googleapis.com/auth/userinfo.profile" for profile info
248
+ timeout_seconds: HTTP request timeout for KeyCloak API calls (defaults to 10)
249
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
250
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
251
+ client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
252
+ If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
253
+ disk store will be encrypted using a key derived from the JWT Signing Key.
254
+ jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
255
+ they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
256
+ provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
257
+ require_authorization_consent: Whether to require user consent before authorizing clients (default True).
258
+ When True, users see a consent screen before being redirected to KeyCloak.
259
+ When False, authorization proceeds directly without user confirmation.
260
+ SECURITY WARNING: Only disable for local development or testing environments.
261
+ extra_authorize_params: Additional parameters to forward to KeyCloak's authorization endpoint.
262
+ By default, KeyCloakProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
263
+ refresh tokens are returned. You can override these defaults or add additional parameters.
264
+ Example: {"prompt": "select_account"} to let users choose their KeyCloak account.
265
+ """
266
+ # Parse scopes if provided as string
267
+ # KeyCloak requires at least one scope - openid is the minimal OIDC scope
268
+ required_scopes_final = (
269
+ parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
270
+ )
271
+
272
+ # Create Keycloak token verifier
273
+ token_verifier = KeyCloakTokenVerifier(
274
+ issuer_url=issuer_url,
275
+ client_id=client_id,
276
+ client_secret=client_secret,
277
+ required_scopes=required_scopes_final,
278
+ timeout_seconds=timeout_seconds,
279
+ )
280
+
281
+ # Get Keycloak endpoints
282
+ keycloak_endpoints = KeyCloakEndpoints(
283
+ issuer_url=issuer_url,
284
+ )
285
+
286
+ # Set Keycloak-specific defaults for extra authorize params
287
+ # access_type=offline ensures refresh tokens are returned
288
+ # prompt=consent forces consent screen to get refresh token (KeyCloak only issues on first auth otherwise)
289
+ # keycloak_defaults = {
290
+ # "access_type": "offline",
291
+ # "prompt": "consent",
292
+ # }
293
+ # User-provided params override defaults
294
+ # if extra_authorize_params:
295
+ # keycloak_defaults.update(extra_authorize_params)
296
+ # extra_authorize_params_final = keycloak_defaults
297
+
298
+ # Initialize OAuth proxy with KeyCloak endpoints
299
+ super().__init__(
300
+ upstream_authorization_endpoint=keycloak_endpoints.authorization_endpoint,
301
+ upstream_token_endpoint=keycloak_endpoints.token_endpoint,
302
+ upstream_client_id=client_id,
303
+ upstream_client_secret=client_secret,
304
+ token_verifier=token_verifier,
305
+ base_url=base_url,
306
+ redirect_path=redirect_path,
307
+ # issuer_url=issuer_url or base_url, # Default to base_url if not specified
308
+ issuer_url=base_url, # NOTE: should be the same as base_url for the DCR
309
+ allowed_client_redirect_uris=allowed_client_redirect_uris,
310
+ client_storage=client_storage,
311
+ jwt_signing_key=jwt_signing_key,
312
+ require_authorization_consent=require_authorization_consent,
313
+ extra_authorize_params=extra_authorize_params,
314
+ )
315
+
316
+ logger.debug(
317
+ "Initialized Keycloak OAuth provider for client %s with scopes: %s",
318
+ client_id,
319
+ required_scopes_final,
320
+ )
@@ -0,0 +1,185 @@
1
+ """This module provides a model for the mixed API specification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import List
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class AuthenticationType(str, Enum):
13
+ """Authentication type model."""
14
+
15
+ NONE = "None"
16
+ BASIC = "Basic"
17
+ BEARER_TOKEN = "BearerToken"
18
+ API_KEY = "ApiKey"
19
+
20
+
21
+ class MethodSpec(BaseModel):
22
+ """A model for the method specification."""
23
+
24
+ method: str
25
+ tool_name: str | None = None
26
+ description: str | None = None
27
+
28
+
29
+ class APIMap(BaseModel):
30
+ """A model for the API simple specification."""
31
+
32
+ path: str
33
+ methods: List[str | MethodSpec]
34
+
35
+
36
+ class RouteMap(BaseModel):
37
+ """A model for the route map."""
38
+
39
+ methods: List[str] | None = None
40
+ tags: List[str] | None = None
41
+ pattern: str | None = None
42
+
43
+
44
+ class ToolConfig(BaseModel):
45
+ """A model for the tool configuration."""
46
+
47
+ api_maps: List[APIMap] | None = None
48
+ route_maps: List[RouteMap] | None = None
49
+
50
+
51
+ class AuthConfig(BaseModel):
52
+ """A model for the authentication configuration."""
53
+
54
+ type: AuthenticationType = AuthenticationType.NONE
55
+ username: str | None = None
56
+ password: str | None = None
57
+ api_key_name: str | None = None
58
+ api_key_value: str | None = None
59
+ bearer_token: str | None = None
60
+
61
+
62
+ class APIServerConfig(BaseModel):
63
+ """A model for the API server configuration."""
64
+
65
+ server_name: str
66
+ endpoint: str
67
+ base_path: str | None = None
68
+ timeout: float = 10
69
+ tls_verify: bool = True
70
+ spec_file_path: str | Path
71
+ # NOTE: open_api_spec is not needed any more
72
+ # open_api_spec: AxmpOpenAPI | None = None
73
+ tool_config: ToolConfig
74
+ auth_config: AuthConfig
75
+
76
+
77
+ class MultiOpenAPISpecConfig(BaseModel):
78
+ """MultiOpenAPISpecConfig is a model that contains the configuration for the multi-server API specification.
79
+
80
+ ```json
81
+ {
82
+ "backends": [
83
+ {
84
+ "server_name": "zcp-alert-backend",
85
+ "endpoint": "https://zcp-alert-backend.com",
86
+ "base_path": "/api/alert/v1",
87
+ "tls_verify": false,
88
+ "timeout": 10,
89
+ "auth_config": {
90
+ "type": "basic",
91
+ "username": "admin",
92
+ "password": "password"
93
+ },
94
+ "spec_file_path": "openapi_spec/zcp_spec/alert_openapi_spec.json",
95
+ "tool_config": {
96
+ "api_maps": [
97
+ {
98
+ "path":"/api/alert/v1/alerts",
99
+ "methods": ["get", "post"]
100
+ },
101
+ {
102
+ "path":"/api/alert/v1/alerts/webhook",
103
+ "methods": ["post"]
104
+ },
105
+ {
106
+ "path":"/api/alert/v1/alert/priorities",
107
+ "methods": ["get"]
108
+ }
109
+ ]
110
+ }
111
+ },
112
+ {
113
+ "server_name": "example-backend",
114
+ "endpoint": "https://example-backend.com",
115
+ "base_path": "/api/example/v1",
116
+ "spec_file_path": "openapi_spec/example_spec/test_spec.json",
117
+ "tls_verify": true,
118
+ "timeout": 10,
119
+ "auth_config": {
120
+ "type": "api_key",
121
+ "custom_header_api_key_name": "X-API-KEY",
122
+ "custom_header_api_key_value": "1234567890"
123
+ },
124
+ "tool_config": {
125
+ "api_maps": [
126
+ {
127
+ "path":"/api/example/v1/test",
128
+ "methods": [
129
+ {
130
+ "method": "post",
131
+ "tool_name": "create_test",
132
+ "description": "Create test"
133
+ }
134
+ ]
135
+ }
136
+ ],
137
+ "route_maps": [
138
+ {
139
+ "pattern": r".*",
140
+ "methods": ["get"],
141
+ "tags": ["*"]
142
+ }
143
+ ]
144
+ }
145
+ },
146
+ {
147
+ "server_name": "zcp-alert-backend",
148
+ "endpoint": "https://zcp-alert-backend.com",
149
+ "base_path": "/api/alert/v1",
150
+ "spec_file_path": "openapi_spec/zcp_spec/alert_openapi_spec.json",
151
+ "tls_verify": true,
152
+ "timeout": 10,
153
+ "auth_config": {
154
+ "type": "bearer",
155
+ "bearer_token": "1234567890"
156
+ },
157
+ "tool_config": {
158
+ "route_maps": [
159
+ {
160
+ "pattern": "^/api/alert/v1/channels/.*",
161
+ "methods": ["get"],
162
+ "tags": []
163
+ },
164
+ {
165
+ "pattern": "^/api/alert/v1/admin/.*",
166
+ "methods": ["post"],
167
+ "tags": ["alerts"]
168
+ }
169
+ ]
170
+ }
171
+ }
172
+ ]
173
+ }
174
+ ```
175
+ """
176
+
177
+ backends: List[APIServerConfig]
178
+
179
+ @classmethod
180
+ def from_multi_server_spec_file(
181
+ cls, file_path: str | Path
182
+ ) -> MultiOpenAPISpecConfig:
183
+ """Load the multi-server API specification from a file."""
184
+ with open(file_path) as f:
185
+ return cls.model_validate_json(f.read())