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
This commit is contained in:
saidsurucu
2025-07-22 11:34:26 +03:00
parent 364f3761d7
commit a7877f34f4
5 changed files with 476 additions and 192 deletions
Regular → Executable
+216 -143
View File
@@ -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"]
+6
View File
@@ -22,6 +22,12 @@ LOG_LEVEL = "info"
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'
cpu_kind = 'shared'
+199 -4
View File
@@ -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"""
+51 -42
View File
@@ -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()
+4 -3
View File
@@ -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.")
logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.")