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:
Regular → Executable
+220
-147
@@ -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"
|
||||
}
|
||||
|
||||
# Manual redirect endpoint for /mcp -> /mcp/ to fix 307 redirect issue
|
||||
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
|
||||
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
|
||||
# Build absolute URL to ensure HTTPS is preserved
|
||||
redirect_url = str(request.url).rstrip('/') + '/'
|
||||
return RedirectResponse(url=redirect_url, status_code=308)
|
||||
|
||||
# MCP mount at /mcp handles path routing correctly
|
||||
|
||||
# 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}
|
||||
|
||||
# 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"
|
||||
})
|
||||
|
||||
# Add explicit redirect for /mcp to /mcp/ with method preservation
|
||||
@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"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/mcp/", status_code=308)
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
# Mount MCP app at /mcp/ with trailing slash
|
||||
app.mount("/mcp/", mcp_app)
|
||||
|
||||
# Set the lifespan context after mounting
|
||||
app.router.lifespan_context = mcp_app.lifespan
|
||||
|
||||
|
||||
# SSE transport deprecated - removed
|
||||
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"]
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
+36
-27
@@ -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")
|
||||
|
||||
# --- 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
|
||||
# MCP app for Turkish legal databases with explicit capabilities
|
||||
app = FastMCP(
|
||||
name="Yargı MCP Server",
|
||||
version="0.1.6"
|
||||
)
|
||||
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,7 +1496,7 @@ async def check_government_servers_health() -> Dict[str, Any]:
|
||||
}
|
||||
}
|
||||
|
||||
client = get_or_create_health_check_client()
|
||||
async with httpx.AsyncClient(
|
||||
headers={
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
@@ -1501,12 +1509,13 @@ async def check_government_servers_health() -> Dict[str, Any]:
|
||||
"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,
|
||||
headers=headers
|
||||
json=yargitay_payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user