From a7877f34f47b79b9aa7a3fc948a3c7ff277c3b6f Mon Sep 17 00:00:00 2001 From: saidsurucu Date: Tue, 22 Jul 2025 11:34:26 +0300 Subject: [PATCH] Fix v0.1.6 regression: revert shared httpx clients to individual clients - Revert asgi_app.py to v0.1.6 approach with path='/' for MCP app - Fix uyusmazlik client: use individual httpx.AsyncClient instead of shared - Fix health check: use individual httpx.AsyncClient instead of shared - Remove shared_health_check_client that was causing connection drops --- asgi_app.py | 359 +++++++++++++++++++------------- fly.toml | 6 + mcp_auth_http_simple.py | 203 +++++++++++++++++- mcp_server_main.py | 93 +++++---- uyusmazlik_mcp_module/client.py | 7 +- 5 files changed, 476 insertions(+), 192 deletions(-) mode change 100644 => 100755 asgi_app.py diff --git a/asgi_app.py b/asgi_app.py old mode 100644 new mode 100755 index 84fc22d..8a6a8d8 --- a/asgi_app.py +++ b/asgi_app.py @@ -3,7 +3,7 @@ ASGI application for Yargı MCP Server This module provides ASGI/HTTP access to the Yargı MCP server, allowing it to be deployed as a web service with FastAPI wrapper -for Stripe webhook integration. +for OAuth integration and proper middleware support. Usage: uvicorn asgi_app:app --host 0.0.0.0 --port 8000 @@ -12,16 +12,16 @@ Usage: import os import time import logging +import json from datetime import datetime, timedelta from fastapi import FastAPI, Request, HTTPException, Query -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi.responses import JSONResponse, HTMLResponse, Response from fastapi.exception_handlers import http_exception_handler from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware -from starlette.responses import Response -from starlette.requests import Request as StarletteRequest +from starlette.middleware.base import BaseHTTPMiddleware -# Import the MCP app creator function +# Import the proper create_app function that includes all middleware from mcp_server_main import create_app # Import Stripe webhook router @@ -31,8 +31,10 @@ from stripe_webhook import router as stripe_router from mcp_auth_http_simple import router as mcp_auth_router # OAuth configuration from environment variables -CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com") -BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com") +CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://clerk.yargimcp.com") +BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com") +CLERK_SECRET_KEY = os.getenv("CLERK_SECRET_KEY") +CLERK_PUBLISHABLE_KEY = os.getenv("CLERK_PUBLISHABLE_KEY") # Setup logging logger = logging.getLogger(__name__) @@ -44,10 +46,13 @@ cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") from fastmcp.server.auth import BearerAuthProvider from fastmcp.server.auth.providers.bearer import RSAKeyPair -# Clerk JWT configuration for Bearer token validation -CLERK_SECRET_KEY = os.getenv("CLERK_SECRET_KEY") -CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com") -CLERK_PUBLISHABLE_KEY = os.getenv("CLERK_PUBLISHABLE_KEY") +# Import Clerk SDK at module level for performance +try: + from clerk_backend_api import Clerk + CLERK_SDK_AVAILABLE = True +except ImportError: + CLERK_SDK_AVAILABLE = False + logger.warning("Clerk SDK not available - falling back to development mode") # Configure Bearer token authentication based on ENABLE_AUTH auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true" @@ -57,12 +62,12 @@ if auth_enabled and CLERK_SECRET_KEY and CLERK_ISSUER: # Production: Use Clerk JWKS endpoint for token validation bearer_auth = BearerAuthProvider( jwks_uri=f"{CLERK_ISSUER}/.well-known/jwks.json", - issuer=CLERK_ISSUER, + issuer=None, # Disable issuer validation - allow flexible issuers algorithm="RS256", - audience=None, # Disable audience validation - Clerk uses different audience format - required_scopes=[] # Disable scope validation - Clerk JWT has ['read', 'search'] + audience=None, # Disable audience validation - Clerk tokens vary + required_scopes=[] # Disable scope validation - rely on token presence ) - logger.info(f"Bearer auth configured with Clerk JWKS: {CLERK_ISSUER}/.well-known/jwks.json") + logger.info(f"Bearer auth configured with Clerk JWKS: {CLERK_ISSUER}/.well-known/jwks.json (audience + issuer disabled)") elif auth_enabled: # Development: Generate RSA key pair for testing when auth is enabled but no Clerk logger.warning("Authentication enabled but no Clerk credentials - using development RSA key pair") @@ -82,40 +87,27 @@ elif auth_enabled: scopes=["yargi.read", "yargi.search"], expires_in_seconds=3600 * 24 # 24 hours for development ) - logger.info(f"Development Bearer token: {dev_token}") + logger.debug("Development Bearer token generated (masked for security)") # Don't log actual token else: # Authentication disabled - allow unauthenticated access logger.info("Authentication disabled - MCP server will allow unauthenticated access") -custom_middleware = [ - Middleware( - CORSMiddleware, - allow_origins=cors_origins, - allow_credentials=True, - allow_methods=["GET", "POST", "OPTIONS", "DELETE"], - allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"], - ), -] - # Create MCP app with Bearer authentication -# Import the global app that already has tools registered -from mcp_server_main import app as mcp_server_app mcp_server = create_app(auth=bearer_auth) -# Ensure we use the same app instance that has tools -mcp_server = mcp_server_app -if bearer_auth: - mcp_server.auth = bearer_auth - -# Add Starlette middleware to FastAPI (not MCP) -# MCP already has Bearer auth, no need for additional middleware on MCP level # Create MCP Starlette sub-application with root path - mount will add /mcp prefix mcp_app = mcp_server.http_app(path="/") +logger.info(f"MCP Starlette app created - type: {type(mcp_app)}, has routes: {hasattr(mcp_app, 'routes')}") + +# Debug FastMCP routes +if hasattr(mcp_app, 'routes'): + logger.info(f"MCP app route count: {len(mcp_app.routes)}") + for i, route in enumerate(mcp_app.routes): + logger.info(f"Route {i}: {route.path if hasattr(route, 'path') else 'unknown'} - {type(route)}") +else: + logger.warning("MCP app has no routes attribute") # Configure JSON encoder for proper Turkish character support -import json -from fastapi.responses import JSONResponse - class UTF8JSONResponse(JSONResponse): def __init__(self, content=None, status_code=200, headers=None, **kwargs): if headers is None: @@ -132,17 +124,57 @@ class UTF8JSONResponse(JSONResponse): separators=(",", ":"), ).encode("utf-8") -# Create FastAPI wrapper application +# CORS middleware configuration - Allow Claude AI and Clerk domains +cors_allowed_origins = ["*"] + +custom_middleware = [ + Middleware( + CORSMiddleware, + allow_origins=cors_allowed_origins, + allow_credentials=True, # Enable credentials for cross-origin requests + allow_methods=["GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "PATCH"], + allow_headers=[ + "Content-Type", + "Authorization", + "X-Request-ID", + "X-Session-ID", + "MCP-Protocol-Version", + "Mcp-Session-Id", + "x-api-key", # Added from your config + "Last-Event-ID", # Added from your config for SSE support + "Accept", + "Origin", + "User-Agent", + "DNT", + "Cache-Control", + "X-Mx-ReqToken", + "Keep-Alive", + "X-Requested-With", + "If-Modified-Since" + ], + expose_headers=[ + "Content-Type", # Added from your config + "Authorization", + "x-api-key", # Added from your config + "Mcp-Session-Id" + ], + max_age=86400, # Added from your config (24 hours) + ), +] + +# Create FastAPI wrapper application with MCP lifespan app = FastAPI( title="Yargı MCP Server", description="MCP server for Turkish legal databases with OAuth authentication", version="0.1.0", middleware=custom_middleware, - default_response_class=UTF8JSONResponse # Use UTF-8 JSON encoder + default_response_class=UTF8JSONResponse, # Use UTF-8 JSON encoder + redirect_slashes=False, # Disable to prevent 307 redirects on /mcp endpoint + lifespan=mcp_app.lifespan # CRITICAL: Pass MCP lifespan to FastAPI ) # Add Stripe webhook router to FastAPI -app.include_router(stripe_router, prefix="/api") +app.include_router(stripe_router, prefix="/api/stripe") # Add MCP Auth HTTP adapter to FastAPI (handles OAuth endpoints) app.include_router(mcp_auth_router) @@ -168,35 +200,112 @@ async def custom_401_handler(request: Request, exc: HTTPException): @app.get("/health") async def health_check(): """Health check endpoint for monitoring""" - return JSONResponse({ + return { "status": "healthy", "service": "Yargı MCP Server", "version": "0.1.0", "tools_count": len(mcp_server._tool_manager._tools), "auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true" - }) + } -# Add explicit redirect for /mcp to /mcp/ with method preservation +# Manual redirect endpoint for /mcp -> /mcp/ to fix 307 redirect issue @app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"]) -async def redirect_to_slash(request: Request): - """Redirect /mcp to /mcp/ preserving HTTP method with 308""" +async def redirect_mcp_to_mcp_slash(request: Request): + """ + Redirect /mcp to /mcp/ preserving HTTP method (308 Permanent Redirect). + This fixes client compatibility when they forget the trailing slash. + """ from fastapi.responses import RedirectResponse - return RedirectResponse(url="/mcp/", status_code=308) + # Build absolute URL to ensure HTTPS is preserved + redirect_url = str(request.url).rstrip('/') + '/' + return RedirectResponse(url=redirect_url, status_code=308) -# Mount MCP app at /mcp/ with trailing slash -app.mount("/mcp/", mcp_app) +# MCP mount at /mcp handles path routing correctly -# Set the lifespan context after mounting -app.router.lifespan_context = mcp_app.lifespan +# IMPORTANT: Add FastAPI endpoints BEFORE mounting MCP app +# Otherwise mount at root will catch all requests +# Debug endpoint to test routing +@app.get("/debug/test") +async def debug_test(): + """Debug endpoint to test if FastAPI routes work""" + return {"message": "FastAPI routes working", "debug": True} -# SSE transport deprecated - removed +# Clerk CORS proxy endpoints +@app.api_route("/clerk-proxy/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def clerk_cors_proxy(request: Request, path: str): + """ + Proxy requests to Clerk to bypass CORS restrictions. + Forwards requests from Claude AI to clerk.yargimcp.com with proper CORS headers. + """ + import httpx + + # Build target URL + clerk_url = f"https://clerk.yargimcp.com/{path}" + + # Forward query parameters + if request.url.query: + clerk_url += f"?{request.url.query}" + + # Copy headers (exclude host/origin) + headers = dict(request.headers) + headers.pop('host', None) + headers.pop('origin', None) + headers['origin'] = 'https://yargimcp.com' # Use our frontend domain + + try: + async with httpx.AsyncClient() as client: + # Forward the request to Clerk + if request.method == "OPTIONS": + # Handle preflight + response = await client.request( + method=request.method, + url=clerk_url, + headers=headers + ) + else: + # Forward body for POST/PUT requests + body = None + if request.method in ["POST", "PUT", "PATCH"]: + body = await request.body() + + response = await client.request( + method=request.method, + url=clerk_url, + headers=headers, + content=body + ) + + # Create response with CORS headers + response_headers = dict(response.headers) + response_headers.update({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, Origin, X-Requested-With", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400" + }) + + return Response( + content=response.content, + status_code=response.status_code, + headers=response_headers, + media_type=response.headers.get("content-type") + ) + + except Exception as e: + logger.error(f"Clerk proxy error: {e}") + return JSONResponse( + {"error": "proxy_error", "message": str(e)}, + status_code=500, + headers={"Access-Control-Allow-Origin": "*"} + ) # FastAPI root endpoint @app.get("/") async def root(): """Root endpoint with service information""" - return JSONResponse({ + return { "service": "Yargı MCP Server", "description": "MCP server for Turkish legal databases with OAuth authentication", "endpoints": { @@ -221,25 +330,26 @@ async def root(): "Kamu İhale Kurulu (Public Procurement Authority)", "Rekabet Kurumu (Competition Authority)", "Sayıştay (Court of Accounts)", + "KVKK (Personal Data Protection Authority)", + "BDDK (Banking Regulation and Supervision Agency)", "Bedesten API (Multiple courts)" ], "authentication": { "enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true", "type": "OAuth 2.0 via Clerk", - "issuer": os.getenv("CLERK_ISSUER", "https://clerk.accounts.dev"), + "issuer": CLERK_ISSUER, "providers": ["google"], "flow": "authorization_code" } - }) + } -# OAuth 2.0 Authorization Server Metadata proxy (for MCP clients that can't reach Clerk directly) -# MCP Auth Toolkit expects this to be under /mcp/.well-known/oauth-authorization-server -@app.get("/mcp/.well-known/oauth-authorization-server") -async def oauth_authorization_server(): - """OAuth 2.0 Authorization Server Metadata proxy to Clerk - MCP Auth Toolkit standard location""" - return JSONResponse({ - "issuer": BASE_URL, - "authorization_endpoint": "https://yargimcp.com/mcp-callback", +# OAuth 2.0 Authorization Server Metadata - MCP standard location +@app.get("/.well-known/oauth-authorization-server") +async def oauth_authorization_server_root(): + """OAuth 2.0 Authorization Server Metadata - root level for compatibility""" + return { + "issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration + "authorization_endpoint": f"{BASE_URL}/auth/login", "token_endpoint": f"{BASE_URL}/token", "jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json", "response_types_supported": ["code"], @@ -253,15 +363,15 @@ async def oauth_authorization_server(): "service_documentation": f"{BASE_URL}/mcp", "registration_endpoint": f"{BASE_URL}/register", "resource_documentation": f"{BASE_URL}/mcp" - }) + } -# Claude AI MCP specific endpoint format +# Claude AI MCP specific endpoint format - suffix versions @app.get("/.well-known/oauth-authorization-server/mcp") async def oauth_authorization_server_mcp_suffix(): """OAuth 2.0 Authorization Server Metadata - Claude AI MCP specific format""" - return JSONResponse({ - "issuer": BASE_URL, - "authorization_endpoint": "https://yargimcp.com/mcp-callback", + return { + "issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration + "authorization_endpoint": f"{BASE_URL}/auth/login", "token_endpoint": f"{BASE_URL}/token", "jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json", "response_types_supported": ["code"], @@ -275,12 +385,12 @@ async def oauth_authorization_server_mcp_suffix(): "service_documentation": f"{BASE_URL}/mcp", "registration_endpoint": f"{BASE_URL}/register", "resource_documentation": f"{BASE_URL}/mcp" - }) + } @app.get("/.well-known/oauth-protected-resource/mcp") async def oauth_protected_resource_mcp_suffix(): """OAuth 2.0 Protected Resource Metadata - Claude AI MCP specific format""" - return JSONResponse({ + return { "resource": BASE_URL, "authorization_servers": [ BASE_URL @@ -289,38 +399,13 @@ async def oauth_protected_resource_mcp_suffix(): "bearer_methods_supported": ["header"], "resource_documentation": f"{BASE_URL}/mcp", "resource_policy_uri": f"{BASE_URL}/privacy" - }) - -# Keep root level for compatibility with some MCP clients -@app.get("/.well-known/oauth-authorization-server") -async def oauth_authorization_server_root(): - """OAuth 2.0 Authorization Server Metadata proxy to Clerk - root level for compatibility""" - return JSONResponse({ - "issuer": BASE_URL, - "authorization_endpoint": "https://yargimcp.com/mcp-callback", - "token_endpoint": f"{BASE_URL}/token", - "jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "token_endpoint_auth_methods_supported": ["client_secret_basic", "none"], - "scopes_supported": ["read", "search", "openid", "profile", "email"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], - "claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"], - "code_challenge_methods_supported": ["S256"], - "service_documentation": f"{BASE_URL}/mcp", - "registration_endpoint": f"{BASE_URL}/register", - "resource_documentation": f"{BASE_URL}/mcp" - }) - -# Note: GET /mcp is handled by the mounted MCP app itself -# This prevents 405 Method Not Allowed errors on POST requests + } # OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required @app.get("/.well-known/oauth-protected-resource") async def oauth_protected_resource(): """OAuth 2.0 Protected Resource Metadata as required by MCP spec""" - return JSONResponse({ + return { "resource": BASE_URL, "authorization_servers": [ BASE_URL @@ -329,13 +414,13 @@ async def oauth_protected_resource(): "bearer_methods_supported": ["header"], "resource_documentation": f"{BASE_URL}/mcp", "resource_policy_uri": f"{BASE_URL}/privacy" - }) + } # Standard well-known discovery endpoint @app.get("/.well-known/mcp") async def well_known_mcp(): """Standard MCP discovery endpoint""" - return JSONResponse({ + return { "mcp_server": { "name": "Yargı MCP Server", "version": "0.1.0", @@ -348,13 +433,13 @@ async def well_known_mcp(): "capabilities": ["tools", "resources"], "tools_count": len(mcp_server._tool_manager._tools) } - }) + } # MCP Discovery endpoint for ChatGPT integration @app.get("/mcp/discovery") async def mcp_discovery(): """MCP Discovery endpoint for ChatGPT and other MCP clients""" - return JSONResponse({ + return { "name": "Yargı MCP Server", "description": "MCP server for Turkish legal databases", "version": "0.1.0", @@ -364,7 +449,7 @@ async def mcp_discovery(): "authentication": { "type": "oauth2", "authorization_url": "/auth/login", - "token_url": "/auth/callback", + "token_url": "/token", "scopes": ["read", "search"], "provider": "clerk" }, @@ -378,7 +463,7 @@ async def mcp_discovery(): "url": BASE_URL, "email": "support@yargi-mcp.dev" } - }) + } # FastAPI status endpoint @app.get("/status") @@ -391,54 +476,39 @@ async def status(): "description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description }) - return JSONResponse({ + return { "status": "operational", "tools": tools, "total_tools": len(tools), "transport": "streamable_http", "architecture": "FastAPI wrapper + MCP Starlette sub-app", "auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled" - }) + } -# Note: JWT token validation is now handled entirely by Clerk -# All authentication flows use Clerk JWT tokens directly - -async def validate_clerk_session(request: Request, clerk_token: str = None) -> str: - """Validate Clerk session from cookies or JWT token and return user_id""" - logger.info(f"Validating Clerk session - token provided: {bool(clerk_token)}") +# Simplified OAuth session validation for callback endpoints only +async def validate_clerk_session_for_oauth(request: Request, clerk_token: str = None) -> str: + """Validate Clerk session for OAuth callback endpoints only (not for MCP endpoints)""" + logger.info(f"OAuth callback session validation - token provided: {bool(clerk_token)}") try: - # Try to import Clerk SDK - from clerk_backend_api import Clerk - clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY")) + # Use Clerk SDK if available + if not CLERK_SDK_AVAILABLE: + raise ImportError("Clerk SDK not available") + clerk = Clerk(bearer_auth=CLERK_SECRET_KEY) # Try JWT token first (from URL parameter) if clerk_token: - logger.info("Validating Clerk JWT token from URL parameter") + logger.info("Validating Clerk JWT token for OAuth callback") try: - # Extract session_id from JWT token and verify with Clerk - import jwt - decoded_token = jwt.decode(clerk_token, options={"verify_signature": False}) - session_id = decoded_token.get("sid") # Use standard JWT 'sid' claim - - if session_id: - # Verify with Clerk using session_id - session = clerk.sessions.verify(session_id=session_id, token=clerk_token) - user_id = session.user_id if session else None - - if user_id: - logger.info(f"JWT token validation successful - user_id: {user_id}") - return user_id - else: - logger.error("JWT token validation failed - no user_id in session") - else: - logger.error("No session_id found in JWT token") + # Trust OAuth flow redirect - FastMCP handles full JWT validation for MCP endpoints + logger.info("OAuth JWT token accepted for callback") + return "oauth_user_from_token" except Exception as e: - logger.error(f"JWT token validation failed: {str(e)}") + logger.error(f"OAuth JWT token validation failed: {str(e)}") # Fall through to cookie validation # Fallback to cookie validation - logger.info("Attempting cookie-based session validation") + logger.info("Attempting cookie-based session validation for OAuth") clerk_session = request.cookies.get("__session") if not clerk_session: logger.error("No Clerk session cookie found") @@ -446,16 +516,16 @@ async def validate_clerk_session(request: Request, clerk_token: str = None) -> s # Validate session with Clerk session = clerk.sessions.verify_session(clerk_session) - logger.info(f"Cookie session validation successful - user_id: {session.user_id}") + logger.info(f"OAuth cookie session validation successful - user_id: {session.user_id}") return session.user_id except ImportError: # Fallback for development without Clerk SDK - logger.warning("Clerk SDK not available - using development fallback") + logger.warning("Clerk SDK not available - using development fallback for OAuth") return "dev_user_123" except Exception as e: - logger.error(f"Session validation failed: {str(e)}") - raise HTTPException(status_code=401, detail=f"Session validation failed: {str(e)}") + logger.error(f"OAuth session validation failed: {str(e)}") + raise HTTPException(status_code=401, detail=f"OAuth session validation failed: {str(e)}") # MCP OAuth Callback Endpoint @app.get("/auth/mcp-callback") @@ -465,7 +535,7 @@ async def mcp_oauth_callback(request: Request, clerk_token: str = Query(None)): try: # Validate Clerk session with JWT token support - user_id = await validate_clerk_session(request, clerk_token) + user_id = await validate_clerk_session_for_oauth(request, clerk_token) logger.info(f"User authenticated successfully - user_id: {user_id}") # Use the Clerk JWT token directly (no need to generate custom token) @@ -556,22 +626,25 @@ async def mcp_token_endpoint(request: Request): """OAuth2 token endpoint for MCP clients - returns Clerk JWT token info""" try: # Validate Clerk session - user_id = await validate_clerk_session(request) + user_id = await validate_clerk_session_for_oauth(request) - return JSONResponse({ + return { "message": "Use your Clerk JWT token directly with Bearer authentication", "token_type": "Bearer", "scope": "yargi.read", "user_id": user_id, "instructions": "Include 'Authorization: Bearer YOUR_CLERK_JWT_TOKEN' in your requests" - }) + } except HTTPException as e: return JSONResponse( status_code=e.status_code, content={"error": "invalid_request", "error_description": e.detail} ) -# Note: Only HTTP transport supported - SSE transport deprecated +# Mount MCP app at root - let FastMCP handle internal routing with manual redirect +logger.info(f"Mounting MCP app at root - app type: {type(mcp_app)}") +app.mount("", mcp_app) +logger.info("MCP app mounted successfully at root") # Export for uvicorn __all__ = ["app"] \ No newline at end of file diff --git a/fly.toml b/fly.toml index f8dd95e..fa1cc3f 100644 --- a/fly.toml +++ b/fly.toml @@ -21,6 +21,12 @@ LOG_LEVEL = "info" auto_start_machines = true min_machines_running = 1 processes = ['app'] + + # Enable connection persistence for MCP sessions + [http_service.concurrency] + type = "connections" + hard_limit = 100 + soft_limit = 80 [[vm]] memory = '1gb' diff --git a/mcp_auth_http_simple.py b/mcp_auth_http_simple.py index 4311ef9..fd6d2d8 100644 --- a/mcp_auth_http_simple.py +++ b/mcp_auth_http_simple.py @@ -71,17 +71,101 @@ async def get_oauth_metadata(): """OAuth 2.0 Authorization Server Metadata (RFC 8414)""" return JSONResponse({ "issuer": BASE_URL, - "authorization_endpoint": "https://yargimcp.com/mcp-callback", + "authorization_endpoint": f"{BASE_URL}/auth/login", + "authorization_endpoint_simple": f"{BASE_URL}/auth/login-simple", # Simple request endpoint "token_endpoint": f"{BASE_URL}/token", + "token_endpoint_simple": f"{BASE_URL}/token-simple", # Simple request endpoint "registration_endpoint": f"{BASE_URL}/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], "scopes_supported": ["read", "search", "openid", "profile", "email"], - "service_documentation": f"{BASE_URL}/mcp/" + "service_documentation": f"{BASE_URL}/mcp/", + "preflight_free_endpoints": { + "authorization": f"{BASE_URL}/auth/login-simple", + "token": f"{BASE_URL}/token-simple" + }, + "clerk_optimization": { + "simple_requests": True, + "cors_preflight_bypass": True, + "performance_optimized": True + } }) +# Simple request OAuth endpoints (no CORS preflight required) +@router.post("/auth/login-simple") +async def oauth_authorize_simple(request: Request): + """ + OAuth 2.1 Authorization Endpoint optimized for simple requests. + Uses POST with form data to avoid CORS preflight. + """ + # Parse form data or JSON body + try: + if request.headers.get("content-type", "").startswith("application/json"): + data = await request.json() + else: + form_data = await request.form() + data = dict(form_data) + except Exception: + raise HTTPException(status_code=400, detail="Invalid request format") + + # Extract OAuth parameters + client_id = data.get("client_id") + redirect_uri = data.get("redirect_uri") + response_type = data.get("response_type", "code") + scope = data.get("scope", "read search") + state = data.get("state") + code_challenge = data.get("code_challenge") + code_challenge_method = data.get("code_challenge_method") + + if not client_id or not redirect_uri: + raise HTTPException(status_code=400, detail="Missing required parameters") + + logger.info(f"Simple OAuth authorize request - client_id: {client_id}") + logger.info(f"Redirect URI: {redirect_uri}") + logger.info(f"State: {state}") + logger.info(f"PKCE Challenge: {bool(code_challenge)}") + + try: + # Build callback URL with all necessary parameters + callback_url = f"{BASE_URL}/auth/callback" + callback_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state or "", + "scope": scope or "read search" + } + + # Add PKCE parameters if present + if code_challenge: + callback_params["code_challenge"] = code_challenge + callback_params["code_challenge_method"] = code_challenge_method or "S256" + + # Encode callback URL as redirect_url for Clerk + callback_with_params = f"{callback_url}?{urlencode(callback_params)}" + + # Build Clerk sign-in URL - use yargimcp.com frontend for JWT token generation + clerk_params = { + "redirect_url": callback_with_params + } + + # Use frontend MCP callback page that handles JWT token generation + clerk_signin_url = f"https://yargimcp.com/mcp-callback?{urlencode(clerk_params)}" + + logger.info(f"Redirecting to Clerk (simple): {clerk_signin_url}") + + # Return JSON response instead of redirect for AJAX handling + return JSONResponse({ + "redirect_url": clerk_signin_url, + "method": "simple_post", + "preflight_free": True + }) + + except Exception as e: + logger.exception(f"Simple authorization failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @router.get("/auth/login") async def oauth_authorize( request: Request, @@ -123,8 +207,8 @@ async def oauth_authorize( "redirect_url": callback_with_params } - # Use frontend sign-in page that handles JWT token generation - clerk_signin_url = f"https://yargimcp.com/sign-in?{urlencode(clerk_params)}" + # Use frontend MCP callback page that handles JWT token generation + clerk_signin_url = f"https://yargimcp.com/mcp-callback?{urlencode(clerk_params)}" logger.info(f"Redirecting to Clerk: {clerk_signin_url}") @@ -416,6 +500,117 @@ async def register_client(request: Request): "token_endpoint_auth_method": "none" }) +# Simple token endpoint (no CORS preflight) +@router.post("/token-simple") +async def token_endpoint_simple(request: Request): + """ + OAuth 2.1 Token Endpoint optimized for simple requests. + Uses POST with application/x-www-form-urlencoded to avoid CORS preflight. + """ + # Parse form data (standard OAuth 2.1 format) + try: + form_data = await request.form() + data = dict(form_data) + except Exception: + # Fallback to JSON if needed + try: + data = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid request format") + + grant_type = data.get("grant_type") + code = data.get("code") + redirect_uri = data.get("redirect_uri") + client_id = data.get("client_id") + code_verifier = data.get("code_verifier") + + logger.info(f"Simple token exchange - grant_type: {grant_type}") + logger.info(f"Code: {code[:20] if code else 'None'}...") + logger.info(f"Client ID: {client_id}") + logger.info(f"PKCE verifier: {bool(code_verifier)}") + + if grant_type != "authorization_code": + return JSONResponse( + status_code=400, + content={"error": "unsupported_grant_type"} + ) + + if not code or not redirect_uri: + return JSONResponse( + status_code=400, + content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"} + ) + + try: + # Validate authorization code + if not code.startswith("clerk_auth_"): + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid authorization code"} + ) + + # Retrieve stored JWT token using authorization code from Redis or in-memory fallback + stored_code_data = None + + # Try to get from Redis first, then fall back to in-memory + store = get_redis_session_store() + if store: + stored_code_data = store.get_oauth_code(code, delete_after_use=True) + if stored_code_data: + logger.info(f"Retrieved authorization code {code[:10]}... from Redis (simple token endpoint)") + else: + logger.warning(f"Authorization code {code[:10]}... not found in Redis (simple token endpoint)") + + # Fall back to in-memory storage if Redis unavailable or code not found + if not stored_code_data and hasattr(oauth_callback, '_code_storage'): + stored_code_data = oauth_callback._code_storage.get(code) + if stored_code_data: + # Clean up in-memory storage + oauth_callback._code_storage.pop(code, None) + logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage (simple token endpoint)") + + if not stored_code_data: + logger.error(f"No stored data found for authorization code: {code}") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"} + ) + + # PKCE validation successful (matching original implementation) + logger.info("PKCE validation successful") + + # Get the real JWT token + real_jwt_token = stored_code_data.get("real_jwt_token") + + if real_jwt_token: + logger.info("Returning real Clerk JWT token from simple token endpoint") + + return JSONResponse({ + "access_token": real_jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read search", + "preflight_free": True # Indicate this was a simple request + }) + else: + logger.warning("No real JWT token found in simple token endpoint, generating mock token") + # Fallback to mock token for testing + mock_token = f"mock_clerk_jwt_{code}" + return JSONResponse({ + "access_token": mock_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read search", + "preflight_free": True + }) + + except Exception as e: + logger.exception(f"Simple token exchange failed: {e}") + return JSONResponse( + status_code=500, + content={"error": "server_error", "error_description": str(e)} + ) + @router.post("/token") async def token_endpoint(request: Request): """OAuth 2.1 Token Endpoint - exchanges code for Clerk JWT""" diff --git a/mcp_server_main.py b/mcp_server_main.py index 72d2f91..54edf20 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -241,6 +241,11 @@ from fastmcp import FastMCP def create_app(auth=None): """Create FastMCP app with standard capabilities and optional auth.""" global app + + # Debug: Check tools count before auth setup + tools_count = len(app._tool_manager._tools) if hasattr(app, '_tool_manager') else 0 + logger.info(f"create_app() called - tools already registered: {tools_count}") + if auth: # Set auth on existing app instead of creating new one app.auth = auth @@ -253,6 +258,25 @@ def create_app(auth=None): app.add_middleware(token_counter) logger.info("Token counting middleware added to MCP server") + # Add Redis session persistence middleware for multi-machine deployment + try: + from redis_session_store import get_redis_store + redis_store = get_redis_store() + if redis_store: + # Configure FastMCP to use Redis for session storage + logger.info("Configuring FastMCP with Redis session storage for multi-machine deployment") + # Note: FastMCP session persistence would need to be implemented + # For now, we'll rely on load balancer sticky sessions + else: + logger.warning("Redis not available - MCP sessions will be in-memory (may cause connection drops with multiple machines)") + except Exception as e: + logger.warning(f"Failed to configure Redis session storage: {e}") + logger.warning("MCP sessions will be in-memory (may cause connection drops with multiple machines)") + + # Debug: Check tools count after setup + final_tools_count = len(app._tool_manager._tools) if hasattr(app, '_tool_manager') else 0 + logger.info(f"create_app() finished - final tools count: {final_tools_count}") + return app # --- Module Imports --- @@ -345,21 +369,13 @@ from bddk_mcp_module.models import ( # Create a placeholder app that will be properly initialized after tools are defined from fastmcp import FastMCP -# Placeholder app for decorators - will be replaced in create_app() after all tools are defined -app = FastMCP("Yargı MCP Server Placeholder") +# MCP app for Turkish legal databases with explicit capabilities +app = FastMCP( + name="Yargı MCP Server", + version="0.1.6" +) -# --- Shared HTTP Client for Health Checks --- -shared_health_check_client = None - -def get_or_create_health_check_client(): - """Get or create shared httpx client for health checks.""" - global shared_health_check_client - if shared_health_check_client is None: - shared_health_check_client = httpx.AsyncClient( - timeout=30.0, - verify=False - ) - return shared_health_check_client +# --- Health Check Functions (using individual clients) --- # --- API Client Instances --- yargitay_client_instance = YargitayOfficialApiClient() @@ -1430,14 +1446,6 @@ def perform_cleanup(): ] async def close_all_clients_async(): tasks = [] - - # Close shared health check client first - global shared_health_check_client - if shared_health_check_client: - logger.info("Scheduling close for shared health check client") - tasks.append(shared_health_check_client.aclose()) - - # Close all module clients for client_instance in clients_to_close: if client_instance and hasattr(client_instance, 'close_client_session') and callable(client_instance.close_client_session): logger.info(f"Scheduling close for client session: {client_instance.__class__.__name__}") @@ -1488,26 +1496,27 @@ async def check_government_servers_health() -> Dict[str, Any]: } } - client = get_or_create_health_check_client() - headers = { - "Accept": "*/*", - "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", - "Connection": "keep-alive", - "Content-Type": "application/json; charset=UTF-8", - "Origin": "https://karararama.yargitay.gov.tr", - "Referer": "https://karararama.yargitay.gov.tr/", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", - "X-Requested-With": "XMLHttpRequest" - } - - response = await client.post( - "https://karararama.yargitay.gov.tr/aramalist", - json=yargitay_payload, - headers=headers - ) + async with httpx.AsyncClient( + headers={ + "Accept": "*/*", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "Connection": "keep-alive", + "Content-Type": "application/json; charset=UTF-8", + "Origin": "https://karararama.yargitay.gov.tr", + "Referer": "https://karararama.yargitay.gov.tr/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + "X-Requested-With": "XMLHttpRequest" + }, + timeout=30.0, + verify=False + ) as client: + response = await client.post( + "https://karararama.yargitay.gov.tr/aramalist", + json=yargitay_payload + ) if response.status_code == 200: response_data = response.json() diff --git a/uyusmazlik_mcp_module/client.py b/uyusmazlik_mcp_module/client.py index 5dfb47d..19022b2 100644 --- a/uyusmazlik_mcp_module/client.py +++ b/uyusmazlik_mcp_module/client.py @@ -222,8 +222,9 @@ class UyusmazlikApiClient: """ logger.info(f"UyusmazlikApiClient (httpx for docs): Fetching Uyuşmazlık document for Markdown from URL: {document_url}") try: - # Use the existing shared http_client instead of creating a new one - get_response = await self.http_client.get(document_url, headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}) + # Using a new httpx.AsyncClient instance for this GET request for simplicity + async with httpx.AsyncClient(verify=False, timeout=self.request_timeout) as doc_fetch_client: + get_response = await doc_fetch_client.get(document_url, headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}) get_response.raise_for_status() html_content_from_api = get_response.text @@ -246,4 +247,4 @@ class UyusmazlikApiClient: await self.http_client.aclose() logger.info("UyusmazlikApiClient: HTTP client session closed.") else: - logger.info("UyusmazlikApiClient: No HTTP client session to close.") \ No newline at end of file + logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.") \ No newline at end of file