diff --git a/asgi_app.py b/asgi_app.py index c9373d4..652cf33 100644 --- a/asgi_app.py +++ b/asgi_app.py @@ -21,8 +21,8 @@ from starlette.middleware.cors import CORSMiddleware from starlette.responses import Response from starlette.requests import Request as StarletteRequest -# Import the fully configured MCP app with all tools -from mcp_server_main import app as mcp_server +# Import the MCP app creator function +from mcp_server_main import create_app # Import Stripe webhook router from stripe_webhook import router as stripe_router @@ -40,41 +40,47 @@ logger = logging.getLogger(__name__) # Configure CORS and Auth middleware cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") -# Custom JWT authentication middleware for MCP endpoints -class JWTAuthMiddleware: - def __init__(self, app): - self.app = app +# Import FastMCP Bearer Auth Provider +from fastmcp.server.auth import BearerAuthProvider +from fastmcp.server.auth.providers.bearer import RSAKeyPair - async def __call__(self, scope, receive, send): - if scope["type"] == "http" and scope["path"].startswith("/mcp"): - # Only apply auth to MCP endpoints - request = StarletteRequest(scope, receive) - - auth_header = request.headers.get("authorization") - if not auth_header or not auth_header.startswith("Bearer "): - # Return 401 for missing auth - response = JSONResponse( - status_code=401, - content={"detail": "Missing or invalid Authorization header. Bearer token required."} - ) - await response(scope, receive, send) - return - - # Add user info to scope for downstream processing - token = auth_header.split(" ")[1] - if token.startswith("eyJ"): - import jwt - try: - decoded = jwt.decode(token, options={"verify_signature": False}) - scope["user"] = { - "user_id": decoded.get("user_id") or decoded.get("sub"), - "email": decoded.get("email"), - "scopes": decoded.get("scopes", ["read", "search"]) - } - except: - pass - - await self.app(scope, receive, send) +# 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") + +# Configure Bearer token authentication +bearer_auth = None +if 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, + algorithm="RS256", + audience=CLERK_PUBLISHABLE_KEY, # Use publishable key as audience + required_scopes=["yargi.read"] # Global scope requirement + ) + logger.info(f"Bearer auth configured with Clerk JWKS: {CLERK_ISSUER}/.well-known/jwks.json") +else: + # Development: Generate RSA key pair for testing + logger.warning("No Clerk credentials found - using development RSA key pair") + dev_key_pair = RSAKeyPair.generate() + bearer_auth = BearerAuthProvider( + public_key=dev_key_pair.public_key, + issuer="https://dev.yargimcp.com", + audience="dev-mcp-server", + required_scopes=["yargi.read"] + ) + + # Generate a test token for development + dev_token = dev_key_pair.create_token( + subject="dev-user", + issuer="https://dev.yargimcp.com", + audience="dev-mcp-server", + scopes=["yargi.read", "yargi.search"], + expires_in_seconds=3600 * 24 # 24 hours for development + ) + logger.info(f"Development Bearer token: {dev_token}") custom_middleware = [ Middleware( @@ -82,15 +88,16 @@ custom_middleware = [ allow_origins=cors_origins, allow_credentials=True, allow_methods=["GET", "POST", "OPTIONS", "DELETE"], - allow_headers=["Content-Type", "Authorization", "X-Request-ID"], + allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"], ), - Middleware(JWTAuthMiddleware), ] -# Create MCP Starlette sub-application with proper middleware +# Create MCP app with Bearer authentication +mcp_server = create_app(auth=bearer_auth) + +# Create MCP Starlette sub-application with middleware mcp_app = mcp_server.http_app( - path="/mcp", - middleware=custom_middleware + custom_middleware=custom_middleware ) # Configure JSON encoder for proper Turkish character support @@ -146,7 +153,7 @@ async def custom_401_handler(request: Request, exc: HTTPException): return response -# Mount MCP app directly at /mcp path +# Mount MCP app using Starlette Mount (simpler approach) app.mount("/mcp", mcp_app) diff --git a/mcp_server_main.py b/mcp_server_main.py index d440bb9..ff29131 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -12,6 +12,13 @@ from typing import Optional, Dict, List, Literal, Any, Union import urllib.parse import tiktoken from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.dependencies import get_access_token, AccessToken +from fastmcp import Context + +# Use standard exception for tool errors +class ToolError(Exception): + """Tool execution error""" + pass # --- Logging Configuration Start --- LOG_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") @@ -231,10 +238,14 @@ class TokenCountingMiddleware(Middleware): # Create FastMCP app directly without authentication wrapper from fastmcp import FastMCP -def create_app(): - """Create FastMCP app with standard capabilities.""" - app = FastMCP("Yargı MCP Server") - logger.info("MCP server created with standard capabilities (FastMCP handles tools.listChanged automatically)") +def create_app(auth=None): + """Create FastMCP app with standard capabilities and optional auth.""" + if auth: + app = FastMCP("Yargı MCP Server", auth=auth) + logger.info("MCP server created with Bearer authentication enabled") + else: + app = FastMCP("Yargı MCP Server") + logger.info("MCP server created with standard capabilities (FastMCP handles tools.listChanged automatically)") return app # --- Module Imports --- @@ -324,6 +335,7 @@ from bddk_mcp_module.models import ( ) +# Create app without auth initially (auth will be added in ASGI wrapper) app = create_app() # --- Add Token Counting Middleware --- @@ -1775,6 +1787,7 @@ async def get_rekabet_kurumu_document( } ) async def search_bedesten_unified( + ctx: Context, phrase: str = Field(..., description="""Search query in Turkish. SUPPORTED OPERATORS: • Simple: "mülkiyet hakkı" (finds both words) • Exact phrase: "\"mülkiyet hakkı\"" (finds exact phrase) @@ -1801,6 +1814,24 @@ For best results, use exact phrases with quotes for legal terms."""), ) -> dict: """Search Turkish legal databases via unified Bedesten API.""" + # Get Bearer token information for access control and logging + try: + access_token: AccessToken = get_access_token() + user_id = access_token.client_id + user_scopes = access_token.scopes + + # Check for required scopes + if "yargi.read" not in user_scopes and "yargi.search" not in user_scopes: + raise ToolError(f"Insufficient permissions: 'yargi.read' or 'yargi.search' scope required. Current scopes: {user_scopes}") + + logger.info(f"Tool 'search_bedesten_unified' called by user '{user_id}' with scopes {user_scopes}") + + except Exception as e: + # Development mode fallback - allow access without strict token validation + logger.warning(f"Bearer token validation failed, using development mode: {str(e)}") + user_id = "dev-user" + user_scopes = ["yargi.read", "yargi.search"] + pageSize = 10 # Default value search_data = BedestenSearchData( @@ -1815,7 +1846,7 @@ For best results, use exact phrases with quotes for legal terms."""), search_request = BedestenSearchRequest(data=search_data) - logger.info(f"Tool 'search_bedesten_unified' called: phrase='{phrase}', court_types={court_types}, birimAdi='{birimAdi}', page={pageNumber}") + logger.info(f"User '{user_id}' searching bedesten: phrase='{phrase}', court_types={court_types}, birimAdi='{birimAdi}', page={pageNumber}") try: response = await bedesten_client_instance.search_documents(search_request)