mcp-core-auth 0.2.1__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.
mcp_core/__init__.py ADDED
@@ -0,0 +1,324 @@
1
+ """
2
+ mcp-core: Auth, billing, and logging infrastructure for MCP-first servers.
3
+
4
+ Usage:
5
+ from mcp_core import MCPCore
6
+
7
+ core = MCPCore(
8
+ product_name="myapp",
9
+ logto_endpoint="https://your-tenant.logto.app", # or self-hosted: https://auth.example.com
10
+ logto_api_resource="https://api.example.com",
11
+ mongodb_uri="mongodb+srv://...",
12
+ db_name="myapp",
13
+ stripe_secret_key="sk_test_...",
14
+ stripe_price_id="price_...",
15
+ stripe_meter_event="tool_calls",
16
+ free_credits=25,
17
+ tool_costs={"browse": 0, "generate": 2},
18
+ read_only_tools={"browse"},
19
+ )
20
+
21
+ # In your tool handler:
22
+ user = await core.auth_and_bill(request, "narrate_text")
23
+ """
24
+
25
+ import logging
26
+ import os
27
+ from typing import Any, Dict, List, Optional, Set
28
+
29
+ from fastapi import FastAPI, Request
30
+
31
+ from .auth import LogtoAuth
32
+ from .billing import StripeBilling
33
+ from .dcr import LogtoDCR
34
+ from .health import HealthCheck
35
+ from .mcp_mount import mount_mcp
36
+ from .routes import install_routes
37
+ from .tool_logging import ToolLogger
38
+
39
+ __all__ = [
40
+ "MCPCore", "LogtoAuth", "StripeBilling", "HealthCheck", "ToolLogger",
41
+ "LogtoDCR", "mount_mcp",
42
+ ]
43
+ __version__ = "0.1.3"
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class MCPCore:
49
+ """Facade that wires auth, billing, logging, and health together.
50
+
51
+ All parameters can also be provided via environment variables
52
+ with MCP_CORE_ prefix (e.g. MCP_CORE_PRODUCT_NAME).
53
+ Constructor args take precedence over env vars.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ product_name: str = "",
59
+ # Logto auth
60
+ logto_endpoint: str = "",
61
+ logto_api_resource: str = "",
62
+ free_credits: int = 0,
63
+ dev_auth_bypass: bool = False,
64
+ dev_user_id: str = "local-dev-user",
65
+ reject_m2m: bool = True,
66
+ # MongoDB
67
+ mongodb_uri: str = "",
68
+ db_name: str = "",
69
+ # Stripe billing
70
+ stripe_secret_key: str = "",
71
+ stripe_price_id: str = "",
72
+ stripe_meter_event: str = "mcp_tool_calls",
73
+ stripe_webhook_secret: str = "",
74
+ billing_success_url: str = "",
75
+ billing_cancel_url: str = "",
76
+ # Tools
77
+ tool_costs: Optional[Dict[str, int]] = None,
78
+ read_only_tools: Optional[Set[str]] = None,
79
+ # MCP OAuth
80
+ mcp_logto_app_id: str = "",
81
+ mcp_logto_app_secret: str = "",
82
+ oauth_scopes: Optional[List[str]] = None,
83
+ # Logto Management API (enables real RFC 7591 DCR when provided)
84
+ logto_mgmt_app_id: str = "",
85
+ logto_mgmt_app_secret: str = "",
86
+ logto_mgmt_api_resource: str = "",
87
+ # Set this when Management tokens are issued on a different origin than
88
+ # `logto_endpoint` (Logto OSS self-host with admin on a separate port).
89
+ # Cloud deployments leave this empty.
90
+ logto_mgmt_token_endpoint: str = "",
91
+ # When set, /oauth/authorize bounces unauthenticated users here for
92
+ # product-branded inline sign-in instead of showing Logto's hosted
93
+ # page. The product is responsible for implementing this URL as a
94
+ # page that (1) accepts ?return_to=<url>, (2) validates it is
95
+ # same-origin, (3) runs the product's sign-in UI, (4) redirects
96
+ # the browser back to return_to on success. Logto's session cookie
97
+ # must be emitted with `Domain=.<apex>` so the retry carries it
98
+ # to /oauth/authorize. Leave empty to keep using Logto's hosted UI.
99
+ branded_sign_in_url: str = "",
100
+ ):
101
+ def _env(key: str, default: str = "") -> str:
102
+ return os.getenv(f"MCP_CORE_{key}", default)
103
+
104
+ self.product_name = product_name or _env("PRODUCT_NAME", "mcp-server")
105
+ _read_only = read_only_tools or set()
106
+ _free = free_credits or int(_env("FREE_CREDITS", "30"))
107
+
108
+ # Auth
109
+ self.auth = LogtoAuth(
110
+ endpoint=logto_endpoint or _env("LOGTO_ENDPOINT"),
111
+ api_resource=logto_api_resource or _env("LOGTO_API_RESOURCE"),
112
+ free_credits=_free,
113
+ dev_bypass=dev_auth_bypass or _env("DEV_AUTH_BYPASS") == "1",
114
+ dev_user_id=dev_user_id,
115
+ read_only_tools=_read_only,
116
+ reject_m2m=reject_m2m,
117
+ )
118
+
119
+ # Billing
120
+ self.billing = StripeBilling(
121
+ stripe_secret_key=stripe_secret_key or _env("STRIPE_SECRET_KEY"),
122
+ price_id=stripe_price_id or _env("STRIPE_PRICE_ID"),
123
+ meter_event=stripe_meter_event or _env("STRIPE_METER_EVENT", "mcp_tool_calls"),
124
+ free_credits=_free,
125
+ tool_costs=tool_costs or {},
126
+ read_only_tools=_read_only,
127
+ success_url=billing_success_url or _env("BILLING_SUCCESS_URL"),
128
+ cancel_url=billing_cancel_url or _env("BILLING_CANCEL_URL"),
129
+ )
130
+
131
+ # MongoDB
132
+ self._mongodb_uri = mongodb_uri or _env("MONGODB_URI")
133
+ self._db_name = db_name or _env("DB_NAME", self.product_name)
134
+ self._db: Any = None # set in connect() or injected directly
135
+
136
+ # Logging
137
+ self.tool_logger = ToolLogger(
138
+ db=None, # set after connect()
139
+ product_name=self.product_name,
140
+ )
141
+
142
+ # Health
143
+ self.health = HealthCheck(product_name=self.product_name)
144
+
145
+ # MCP OAuth config
146
+ self._mcp_app_id = mcp_logto_app_id or _env("MCP_LOGTO_APP_ID")
147
+ self._mcp_app_secret = mcp_logto_app_secret or _env("MCP_LOGTO_APP_SECRET")
148
+ self._webhook_secret = stripe_webhook_secret or _env("STRIPE_WEBHOOK_SECRET")
149
+ self._oauth_scopes = oauth_scopes
150
+ self._branded_sign_in_url = (
151
+ branded_sign_in_url or _env("BRANDED_SIGN_IN_URL", "")
152
+ )
153
+
154
+ # Real DCR via Logto Management API (optional). If mgmt creds are
155
+ # provided, every /oauth/register call creates a fresh Logto app with
156
+ # the caller's redirect_uris baked in — fixing the dynamic-port
157
+ # loopback case that fake DCR can't handle.
158
+ _mgmt_id = logto_mgmt_app_id or _env("LOGTO_MGMT_APP_ID")
159
+ _mgmt_secret = logto_mgmt_app_secret or _env("LOGTO_MGMT_APP_SECRET")
160
+ self.dcr: Optional[LogtoDCR] = None
161
+ if _mgmt_id and _mgmt_secret and self.auth.endpoint:
162
+ self.dcr = LogtoDCR(
163
+ logto_endpoint=self.auth.endpoint,
164
+ mgmt_app_id=_mgmt_id,
165
+ mgmt_app_secret=_mgmt_secret,
166
+ mgmt_api_resource=(
167
+ logto_mgmt_api_resource
168
+ or _env("LOGTO_MGMT_API_RESOURCE", "")
169
+ ),
170
+ mgmt_token_endpoint=(
171
+ logto_mgmt_token_endpoint
172
+ or _env("LOGTO_MGMT_TOKEN_ENDPOINT", "")
173
+ ),
174
+ app_name_prefix=f"mcp-dcr-{self.product_name}",
175
+ )
176
+
177
+ # ── Database ──────────────────────────────────────────
178
+
179
+ @property
180
+ def db(self) -> Any:
181
+ return self._db
182
+
183
+ @db.setter
184
+ def db(self, value: Any) -> None:
185
+ self._db = value
186
+ self.tool_logger.db = value
187
+
188
+ async def connect_db(self) -> Any:
189
+ """Connect to MongoDB using configured URI. Returns the database."""
190
+ if not self._mongodb_uri:
191
+ logger.warning("[mcp-core] No MONGODB_URI — running without DB")
192
+ return None
193
+ import motor.motor_asyncio
194
+
195
+ client = motor.motor_asyncio.AsyncIOMotorClient(self._mongodb_uri)
196
+ self.db = client[self._db_name]
197
+ logger.info("[mcp-core] Connected to MongoDB: %s", self._db_name)
198
+ return self.db
199
+
200
+ # ── Main middleware ─────���──────────────────────────────
201
+
202
+ async def auth_and_bill(
203
+ self, request: Request, tool_name: str
204
+ ) -> Dict[str, Any]:
205
+ """Combined auth + billing check. The main entry point for tool handlers.
206
+
207
+ Returns user dict. Raises HTTPException on auth/billing failure.
208
+
209
+ On paid-tool calls the post-deduction billing result is attached to
210
+ the returned user as ``user["_billing"]`` = ``{"cost", "source",
211
+ "remaining_credits"}``. Tool handlers that want to surface a usage
212
+ block in their response can read it directly; callers that don't
213
+ care keep working unchanged (non-breaking addition).
214
+ """
215
+ user = await self.auth.require_auth(request, tool_name, self.db)
216
+ if user is None:
217
+ # Read-only tool, no auth provided
218
+ return {
219
+ "logto_user_id": "anonymous",
220
+ "free_credits": 0,
221
+ "credits_used": 0,
222
+ }
223
+ billing_info = await self.billing.check_and_deduct(
224
+ self.db, user, tool_name, request
225
+ )
226
+ if billing_info is not None:
227
+ user["_billing"] = billing_info
228
+ return user
229
+
230
+ # ── Logging shortcut ──────────────────────────────────
231
+
232
+ async def log_tool_call(
233
+ self,
234
+ request: Request,
235
+ tool: str,
236
+ user: Optional[Dict[str, Any]] = None,
237
+ duration_ms: int = 0,
238
+ status: str = "ok",
239
+ error: str = "",
240
+ meta: Optional[Dict[str, Any]] = None,
241
+ ) -> None:
242
+ """Log a tool call to the audit trail."""
243
+ user_id = (user or {}).get("logto_user_id", "")
244
+ cost = self.billing.get_tool_cost(tool)
245
+ await self.tool_logger.log(
246
+ request=request,
247
+ tool=tool,
248
+ user_id=user_id,
249
+ duration_ms=duration_ms,
250
+ status=status,
251
+ cost=cost,
252
+ error=error,
253
+ meta=meta,
254
+ )
255
+
256
+ # ── FastAPI integration ───────────────────────────────
257
+
258
+ def install_routes(self, app: FastAPI) -> None:
259
+ """Register standard routes: /health, /api/billing/credits, webhook, OAuth metadata."""
260
+ install_routes(app, self)
261
+
262
+ def mount_mcp(
263
+ self,
264
+ app: FastAPI,
265
+ *,
266
+ name: str,
267
+ description: str = "",
268
+ tags=("mcp",),
269
+ legacy_sse: bool = True,
270
+ mount_path_legacy: str = "/mcp",
271
+ mount_path_v2: str = "/mcp/v2",
272
+ instructions: str = "",
273
+ require_auth: bool = True,
274
+ ) -> Dict[str, Any]:
275
+ """Mount MCP transports (legacy SSE + stateless HTTP).
276
+
277
+ See `mcp_core.mcp_mount.mount_mcp` for full docs. This method
278
+ forwards `self` as `core` so callers don't repeat it.
279
+ """
280
+ return mount_mcp(
281
+ app,
282
+ core=self,
283
+ name=name,
284
+ description=description,
285
+ tags=tags,
286
+ legacy_sse=legacy_sse,
287
+ mount_path_legacy=mount_path_legacy,
288
+ mount_path_v2=mount_path_v2,
289
+ instructions=instructions,
290
+ require_auth=require_auth,
291
+ )
292
+
293
+ def mcp_auth_config(self) -> Any:
294
+ """Return an AuthConfig for fastapi-mcp.
295
+
296
+ Requires fastapi-mcp to be installed (it's a peer dependency).
297
+ """
298
+ if not self.auth.endpoint or not self._mcp_app_id:
299
+ return None
300
+ try:
301
+ from fastapi_mcp.types import AuthConfig
302
+ except ImportError:
303
+ from fastapi_mcp import AuthConfig
304
+ return AuthConfig(
305
+ issuer=f"{self.auth.endpoint}/oidc",
306
+ oauth_metadata_url=(
307
+ f"{self.auth.endpoint}/oidc/.well-known/openid-configuration"
308
+ ),
309
+ authorize_url=f"{self.auth.endpoint}/oidc/auth",
310
+ client_id=self._mcp_app_id,
311
+ client_secret=self._mcp_app_secret,
312
+ audience=self.auth.api_resource,
313
+ default_scope=" ".join(
314
+ self._oauth_scopes
315
+ or ["openid", "profile", "email"]
316
+ ),
317
+ setup_proxies=True,
318
+ # Keep fake DCR on so fastapi-mcp advertises `registration_endpoint`
319
+ # in the auth-server metadata (MCP SDK refuses servers without it).
320
+ # When real DCR is enabled, mcp-core's /oauth/register route is
321
+ # registered first via install_routes(), so FastAPI's first-match
322
+ # router dispatches there and fastapi-mcp's fake handler never runs.
323
+ setup_fake_dynamic_registration=True,
324
+ )
mcp_core/auth.py ADDED
@@ -0,0 +1,235 @@
1
+ """
2
+ Logto JWT validation and user provisioning for MCP-first servers.
3
+
4
+ Validates JWTs issued by Logto using JWKS endpoint.
5
+ Creates user records in MongoDB on first auth (race-condition-safe upsert).
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, Optional, Set
12
+
13
+ import jwt
14
+ from fastapi import HTTPException, Request
15
+ from jwt import PyJWKClient
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ __all__ = ["LogtoAuth"]
20
+
21
+
22
+ class LogtoAuth:
23
+ """Logto JWT validation and user provisioning.
24
+
25
+ Args:
26
+ endpoint: Logto tenant URL (e.g. "https://your-tenant.logto.app" or self-hosted "https://auth.example.com").
27
+ api_resource: Logto API resource / audience (e.g. "https://api.example.com").
28
+ free_credits: Credits granted to new users on first auth.
29
+ dev_bypass: Accept "Bearer dev-bypass" as a valid token (local dev only).
30
+ dev_user_id: User ID returned for dev-bypass tokens.
31
+ read_only_tools: Tool names that don't require authentication.
32
+ reject_m2m: Reject machine-to-machine tokens (sub == client_id) for paid tools.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ endpoint: str = "",
38
+ api_resource: str = "",
39
+ free_credits: int = 30,
40
+ dev_bypass: bool = False,
41
+ dev_user_id: str = "local-dev-user",
42
+ read_only_tools: Optional[Set[str]] = None,
43
+ reject_m2m: bool = True,
44
+ ):
45
+ self.endpoint = endpoint.rstrip("/") if endpoint else ""
46
+ self.api_resource = api_resource
47
+ self.free_credits = free_credits
48
+ self.dev_bypass = dev_bypass
49
+ self.dev_user_id = dev_user_id
50
+ self.read_only_tools = read_only_tools or set()
51
+ self.reject_m2m = reject_m2m
52
+
53
+ self._jwks_client: Optional[PyJWKClient] = None
54
+ self._jwks_last_init: float = 0.0
55
+
56
+ # ── JWKS ──────────────────────────────────────────────
57
+
58
+ def _get_jwks_client(self) -> Optional[PyJWKClient]:
59
+ if not self.endpoint:
60
+ return None
61
+ # Refresh JWKS client every hour
62
+ if self._jwks_client and (time.time() - self._jwks_last_init) < 3600:
63
+ return self._jwks_client
64
+ jwks_url = f"{self.endpoint}/oidc/jwks"
65
+ try:
66
+ self._jwks_client = PyJWKClient(jwks_url, cache_keys=True)
67
+ self._jwks_last_init = time.time()
68
+ logger.info("[auth] JWKS client initialized: %s", jwks_url)
69
+ return self._jwks_client
70
+ except Exception as e:
71
+ logger.error("[auth] Failed to initialize JWKS client: %s", e)
72
+ return None
73
+
74
+ # ── Token extraction ──────────────────────────────────
75
+
76
+ @staticmethod
77
+ def _extract_bearer_token(request: Request) -> Optional[str]:
78
+ auth = request.headers.get("authorization", "")
79
+ if auth.startswith("Bearer "):
80
+ return auth[7:]
81
+ return None
82
+
83
+ # ── Token validation ──────────────────────────────────
84
+
85
+ async def verify_token(self, request: Request) -> Optional[Dict[str, Any]]:
86
+ """Validate JWT from request Authorization header.
87
+
88
+ Returns decoded payload if valid, None if no token provided.
89
+ Raises HTTPException(401) if token is invalid/expired.
90
+ """
91
+ token = self._extract_bearer_token(request)
92
+ if not token:
93
+ return None
94
+
95
+ # Dev bypass
96
+ if self.dev_bypass and token == "dev-bypass":
97
+ return {"sub": self.dev_user_id, "email": "dev@localhost"}
98
+
99
+ jwks_client = self._get_jwks_client()
100
+ if not jwks_client:
101
+ logger.warning("[auth] Auth not configured, allowing request through")
102
+ return {"sub": "anonymous", "dev_mode": True}
103
+
104
+ try:
105
+ signing_key = jwks_client.get_signing_key_from_jwt(token)
106
+ payload = jwt.decode(
107
+ token,
108
+ signing_key.key,
109
+ algorithms=["RS256", "ES256", "ES384", "ES512"],
110
+ audience=self.api_resource,
111
+ issuer=f"{self.endpoint}/oidc",
112
+ options={"verify_exp": True},
113
+ leeway=30,
114
+ )
115
+ return payload
116
+ except jwt.ExpiredSignatureError:
117
+ raise HTTPException(status_code=401, detail="Token expired")
118
+ except (jwt.InvalidTokenError, jwt.exceptions.PyJWKClientError) as e:
119
+ raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
120
+
121
+ # ── User provisioning ─────────────────────────────────
122
+
123
+ async def get_or_create_user(
124
+ self, db: Any, token_payload: Dict[str, Any]
125
+ ) -> Dict[str, Any]:
126
+ """Get or create user record in MongoDB from token payload.
127
+
128
+ Uses find_one_and_update with upsert to avoid race conditions.
129
+ Rejects M2M tokens if reject_m2m is True.
130
+ """
131
+ sub = token_payload.get("sub", "")
132
+ client_id = token_payload.get("client_id", "")
133
+
134
+ # Reject M2M tokens (sub == client_id means it's an app, not a user)
135
+ if self.reject_m2m and sub and client_id and sub == client_id:
136
+ raise HTTPException(
137
+ status_code=403,
138
+ detail="Machine-to-machine tokens cannot call paid tools. "
139
+ "Use a per-user OAuth token.",
140
+ )
141
+
142
+ if db is None:
143
+ return self._ephemeral_user(token_payload)
144
+
145
+ if not sub:
146
+ raise HTTPException(status_code=401, detail="Token missing 'sub' claim")
147
+
148
+ result = await db["users"].find_one_and_update(
149
+ {"logto_user_id": sub},
150
+ {
151
+ "$setOnInsert": {
152
+ "logto_user_id": sub,
153
+ "email": token_payload.get("email", ""),
154
+ "free_credits": self.free_credits,
155
+ "credits_used": 0,
156
+ "stripe_customer_id": None,
157
+ "stripe_subscription_id": None,
158
+ "created_at": datetime.now(timezone.utc),
159
+ }
160
+ },
161
+ upsert=True,
162
+ return_document=True, # motor uses True, not ReturnDocument enum
163
+ )
164
+ return result
165
+
166
+ def _ephemeral_user(self, token_payload: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Return an in-memory user dict when no DB is available."""
168
+ return {
169
+ "logto_user_id": token_payload.get("sub", "anonymous"),
170
+ "email": token_payload.get("email", ""),
171
+ "free_credits": self.free_credits,
172
+ "credits_used": 0,
173
+ "stripe_customer_id": None,
174
+ "stripe_subscription_id": None,
175
+ }
176
+
177
+ # ── Require auth for tool ─────────────────────────────
178
+
179
+ async def require_auth(
180
+ self, request: Request, tool_name: str, db: Any = None
181
+ ) -> Optional[Dict[str, Any]]:
182
+ """Validate auth for a tool call.
183
+
184
+ Returns user dict for paid tools, None for read-only tools without a token.
185
+ Raises HTTPException(401) if a paid tool is called without valid auth.
186
+ """
187
+ if tool_name in self.read_only_tools:
188
+ payload = await self.verify_token(request)
189
+ # pymongo Database raises NotImplementedError on bool() (historical
190
+ # truthiness gotcha). The paid-tool branch below uses the correct
191
+ # `db is not None` — keep read-only consistent so authenticated
192
+ # read-only calls don't 500 on the first call.
193
+ if payload and db is not None:
194
+ return await self.get_or_create_user(db, payload)
195
+ return None
196
+
197
+ payload = await self.verify_token(request)
198
+ if payload is None:
199
+ raise HTTPException(
200
+ status_code=401,
201
+ detail=f"Authentication required for {tool_name}. "
202
+ "Provide a valid Bearer token.",
203
+ )
204
+ if db is not None:
205
+ return await self.get_or_create_user(db, payload)
206
+ return self._ephemeral_user(payload)
207
+
208
+ # ── OAuth metadata ────────────────────────────────────
209
+
210
+ def oauth_protected_resource_metadata(
211
+ self, scopes: Optional[list] = None, base_url: Optional[str] = None
212
+ ) -> Dict[str, Any]:
213
+ """RFC 9728 /.well-known/oauth-protected-resource response.
214
+
215
+ Args:
216
+ scopes: Supported OAuth scopes.
217
+ base_url: When set (typically from the request), use the server's
218
+ own URL as the authorization server. This is required when
219
+ fastapi-mcp's ``setup_proxies=True`` proxies OAuth routes
220
+ through the server itself.
221
+ """
222
+ if base_url:
223
+ auth_servers = [base_url.rstrip("/")]
224
+ elif self.endpoint:
225
+ auth_servers = [f"{self.endpoint}/oidc"]
226
+ else:
227
+ auth_servers = []
228
+
229
+ return {
230
+ "resource": self.api_resource,
231
+ "authorization_servers": auth_servers,
232
+ "scopes_supported": scopes
233
+ or ["openid", "profile", "email"],
234
+ "bearer_methods_supported": ["header"],
235
+ }