Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5fa0076f8 | ||
|
|
7a346ef3f6 | ||
|
|
217103f0b6 | ||
|
|
1fbcb65031 | ||
|
|
9e40671798 | ||
|
|
6c8a614872 | ||
|
|
861d9e86ef | ||
|
|
c4b5d3608a | ||
|
|
38e0cc032b | ||
|
|
2c1b8c6f9d | ||
|
|
92f04fbab6 | ||
|
|
515347e29c | ||
|
|
ebefe22a4c | ||
|
|
c93244ee10 | ||
|
|
d84f8a2c88 |
+77
-189
@@ -19,9 +19,10 @@ 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
|
||||
|
||||
# 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
|
||||
@@ -36,23 +37,69 @@ BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||
# Setup logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure CORS middleware
|
||||
# Configure CORS and Auth middleware
|
||||
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
||||
|
||||
# Import FastMCP Bearer Auth Provider
|
||||
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")
|
||||
|
||||
# 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=None, # Disable audience validation - Clerk uses different audience format
|
||||
required_scopes=[] # Disable scope validation - Clerk JWT has ['read', 'search']
|
||||
)
|
||||
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(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
|
||||
allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"],
|
||||
),
|
||||
]
|
||||
|
||||
# Create MCP Starlette sub-application (without auth wrapper)
|
||||
mcp_app = mcp_server.http_app(
|
||||
path="/",
|
||||
middleware=custom_middleware
|
||||
)
|
||||
# Create MCP app with Bearer authentication
|
||||
mcp_server = create_app(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="/")
|
||||
|
||||
# Configure JSON encoder for proper Turkish character support
|
||||
import json
|
||||
@@ -74,13 +121,12 @@ class UTF8JSONResponse(JSONResponse):
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
# Create FastAPI wrapper application with MCP lifespan
|
||||
# Create FastAPI wrapper application
|
||||
app = FastAPI(
|
||||
title="Yargı MCP Server",
|
||||
description="MCP server for Turkish legal databases with OAuth authentication",
|
||||
version="0.1.0",
|
||||
middleware=custom_middleware,
|
||||
lifespan=mcp_app.lifespan, # MCP app lifespan
|
||||
default_response_class=UTF8JSONResponse # Use UTF-8 JSON encoder
|
||||
)
|
||||
|
||||
@@ -107,141 +153,7 @@ async def custom_401_handler(request: Request, exc: HTTPException):
|
||||
|
||||
return response
|
||||
|
||||
# Mount MCP app as sub-application at /mcp-server to avoid path conflicts
|
||||
app.mount("/mcp-server", mcp_app)
|
||||
|
||||
# Add custom route to handle /mcp requests and forward to mounted app
|
||||
@app.api_route("/mcp", methods=["POST", "DELETE", "OPTIONS"])
|
||||
@app.api_route("/mcp/", methods=["POST", "DELETE", "OPTIONS"])
|
||||
async def mcp_protocol_handler(request: Request):
|
||||
"""Handle MCP protocol requests by forwarding to mounted app"""
|
||||
|
||||
# Handle DELETE requests for session termination
|
||||
if request.method == "DELETE":
|
||||
logger.info("DELETE request received for session termination")
|
||||
# For session termination, we just return 200 OK
|
||||
# The actual session cleanup is handled by the underlying MCP transport
|
||||
from starlette.responses import Response
|
||||
return Response(
|
||||
status_code=200,
|
||||
content="Session terminated successfully"
|
||||
)
|
||||
|
||||
# REQUIRED: Validate Bearer JWT tokens for all MCP requests
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
logger.error("Missing or invalid Authorization header")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing or invalid Authorization header. Bearer token required."
|
||||
)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
try:
|
||||
# Check if this is a mock token for development/testing
|
||||
if token.startswith("mock_clerk_jwt_"):
|
||||
logger.info(f"Using mock JWT token for development: {token[:30]}...")
|
||||
# For mock tokens, we'll allow access with a mock user
|
||||
request.state.user_id = "mock_user_dev"
|
||||
request.state.session_id = "mock_session_dev"
|
||||
request.state.token_scopes = ["read", "search"]
|
||||
logger.info("Mock JWT token accepted for development")
|
||||
elif token.startswith("eyJ"):
|
||||
# This looks like a real JWT token (starts with eyJ which is base64 encoded '{"')
|
||||
logger.info(f"Processing real JWT token: {token[:30]}...")
|
||||
# Validate real Clerk JWT token
|
||||
from clerk_backend_api import Clerk, models
|
||||
import jwt
|
||||
|
||||
# Decode JWT token and extract user info
|
||||
try:
|
||||
decoded_token = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = decoded_token.get("user_id") or decoded_token.get("sub")
|
||||
user_email = decoded_token.get("email")
|
||||
token_scopes = decoded_token.get("scopes", ["read", "search"])
|
||||
session_id = decoded_token.get("sid", "jwt_session")
|
||||
|
||||
logger.info(f"JWT token claims - user_id: {user_id}, email: {user_email}, scopes: {token_scopes}")
|
||||
|
||||
if user_id and user_email:
|
||||
# JWT token is signed by Clerk and contains valid user info
|
||||
request.state.user_id = user_id
|
||||
request.state.user_email = user_email
|
||||
request.state.session_id = session_id
|
||||
request.state.token_scopes = token_scopes
|
||||
logger.info(f"Real JWT token accepted for user: {user_id}")
|
||||
else:
|
||||
logger.error(f"Missing required fields in JWT token - user_id: {bool(user_id)}, email: {bool(user_email)}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid token - missing user_id or email in claims"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"JWT token decoding failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid JWT token format"
|
||||
)
|
||||
else:
|
||||
# Invalid token format - doesn't start with expected patterns
|
||||
logger.error(f"Invalid token format: {token[:30]}...")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid token format - must be a valid JWT token"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPException as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Bearer token validation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Token validation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Forward the request to the mounted MCP app
|
||||
async def receive():
|
||||
return await request.receive()
|
||||
|
||||
# Create new scope for the mounted app
|
||||
scope = request.scope.copy()
|
||||
scope["path"] = "/" # Root path for mounted app
|
||||
scope["path_info"] = "/"
|
||||
|
||||
# Capture the response
|
||||
response_parts = {"status": 200, "headers": [], "body": b""}
|
||||
|
||||
async def send(message):
|
||||
if message["type"] == "http.response.start":
|
||||
response_parts["status"] = message["status"]
|
||||
response_parts["headers"] = message["headers"]
|
||||
elif message["type"] == "http.response.body":
|
||||
response_parts["body"] += message.get("body", b"")
|
||||
|
||||
# Call the mounted MCP app
|
||||
await mcp_app(scope, receive, send)
|
||||
|
||||
# Return the response
|
||||
from starlette.responses import Response
|
||||
|
||||
# Convert ASGI headers to dict
|
||||
headers = {}
|
||||
for name, value in response_parts["headers"]:
|
||||
headers[name.decode()] = value.decode()
|
||||
|
||||
return Response(
|
||||
content=response_parts["body"],
|
||||
status_code=response_parts["status"],
|
||||
headers=headers
|
||||
)
|
||||
|
||||
|
||||
# SSE transport deprecated - removed
|
||||
|
||||
|
||||
# FastAPI health check endpoint
|
||||
# FastAPI health check endpoint - BEFORE mounting MCP app
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for monitoring"""
|
||||
@@ -253,6 +165,22 @@ async def health_check():
|
||||
"auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
})
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
# FastAPI root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@@ -374,48 +302,8 @@ async def oauth_authorization_server_root():
|
||||
"resource_documentation": f"{BASE_URL}/mcp"
|
||||
})
|
||||
|
||||
# MCP endpoint info for GET requests (ChatGPT compatibility)
|
||||
@app.get("/mcp")
|
||||
async def mcp_info():
|
||||
"""MCP endpoint information for discovery"""
|
||||
return JSONResponse({
|
||||
"mcp_server": True,
|
||||
"name": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for Turkish legal databases",
|
||||
"protocol": "mcp/1.0",
|
||||
"transport": ["http"],
|
||||
"authentication_required": True,
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"authorization_url": "https://yargimcp.com/sign-in?redirect_url=https://api.yargimcp.com/auth/mcp-callback",
|
||||
"token_url": f"{BASE_URL}/auth/mcp-token",
|
||||
"scopes": ["read", "search"],
|
||||
"provider": "clerk"
|
||||
},
|
||||
"endpoints": {
|
||||
"mcp_protocol": "/mcp",
|
||||
"discovery": "/mcp/discovery",
|
||||
"well_known": "/.well-known/mcp",
|
||||
"health": "/health",
|
||||
"oauth_login": "/auth/login"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": True,
|
||||
"resources": True,
|
||||
"prompts": False
|
||||
},
|
||||
"tools_count": len(mcp_server._tool_manager._tools),
|
||||
"usage": {
|
||||
"note": "This is an MCP server. Use POST to /mcp/ with proper MCP protocol headers.",
|
||||
"headers_required": [
|
||||
"Content-Type: application/json",
|
||||
"Accept: application/json",
|
||||
"Authorization: Bearer <token>",
|
||||
"X-Session-ID: <session-id>"
|
||||
]
|
||||
}
|
||||
})
|
||||
# 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")
|
||||
|
||||
@@ -21,7 +21,7 @@ class BedestenSearchData(BaseModel):
|
||||
pageSize: int = Field(..., description="Results per page (1-10)")
|
||||
pageNumber: int = Field(..., description="Page number (1-indexed)")
|
||||
itemTypeList: List[str] = Field(..., description="Court type filter (YARGITAYKARARI/DANISTAYKARAR/YERELHUKUK/ISTINAFHUKUK/KYB)")
|
||||
phrase: str = Field(..., description="Search phrase (use \"exact phrase\" for precise matching)")
|
||||
phrase: str = Field(..., description="Search phrase. Supports: 'word', \"exact phrase\", +required, -exclude, AND/OR/NOT operators. No wildcards or regex.")
|
||||
birimAdi: BirimAdiEnum = Field("ALL", description="""
|
||||
Chamber filter (optional). Abbreviated values with Turkish names:
|
||||
• Yargıtay: H1-H23 (1-23. Hukuk Dairesi), C1-C23 (1-23. Ceza Dairesi), HGK (Hukuk Genel Kurulu), CGK (Ceza Genel Kurulu), BGK (Büyük Genel Kurulu), HBK (Hukuk Daireleri Başkanlar Kurulu), CBK (Ceza Daireleri Başkanlar Kurulu)
|
||||
|
||||
@@ -17,9 +17,9 @@ LOG_LEVEL = "info"
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_stop_machines = 'off'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
[[vm]]
|
||||
|
||||
+55
-16
@@ -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,9 +238,25 @@ class TokenCountingMiddleware(Middleware):
|
||||
# Create FastMCP app directly without authentication wrapper
|
||||
from fastmcp import FastMCP
|
||||
|
||||
def create_app():
|
||||
"""Create basic FastMCP app without authentication wrapper"""
|
||||
return FastMCP("Yargı MCP Server")
|
||||
def create_app(auth=None):
|
||||
"""Create FastMCP app with standard capabilities and optional auth."""
|
||||
global app
|
||||
if auth:
|
||||
# Set auth on existing app instead of creating new one
|
||||
app.auth = auth
|
||||
app.name = "Yargı MCP Server"
|
||||
logger.info("MCP server created with Bearer authentication enabled")
|
||||
else:
|
||||
# Update placeholder app name only
|
||||
app.name = "Yargı MCP Server"
|
||||
logger.info("MCP server created with standard capabilities (FastMCP handles tools.listChanged automatically)")
|
||||
|
||||
# Add token counting middleware
|
||||
token_counter = TokenCountingMiddleware()
|
||||
app.add_middleware(token_counter)
|
||||
logger.info("Token counting middleware added to MCP server")
|
||||
|
||||
return app
|
||||
|
||||
# --- Module Imports ---
|
||||
from yargitay_mcp_module.client import YargitayOfficialApiClient
|
||||
@@ -322,12 +345,11 @@ from bddk_mcp_module.models import (
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
# Create a placeholder app that will be properly initialized after tools are defined
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# --- Add Token Counting Middleware ---
|
||||
token_counter = TokenCountingMiddleware()
|
||||
app.add_middleware(token_counter)
|
||||
logger.info("Token counting middleware added to MCP server")
|
||||
# Placeholder app for decorators - will be replaced in create_app() after all tools are defined
|
||||
app = FastMCP("Yargı MCP Server Placeholder")
|
||||
|
||||
# --- Tool Documentation Resources ---
|
||||
@app.resource("docs://tools/yargitay")
|
||||
@@ -1773,17 +1795,16 @@ async def get_rekabet_kurumu_document(
|
||||
}
|
||||
)
|
||||
async def search_bedesten_unified(
|
||||
phrase: str = Field(..., description="""Search query in Turkish. WORKING EXAMPLES:
|
||||
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)
|
||||
• Required term: "+mülkiyet hakkı" (must contain mülkiyet)
|
||||
• Exclude term: "mülkiyet -kira" (contains mülkiyet but not kira)
|
||||
• Wildcard: "mülk*" (mülkiyet, mülk, etc.)
|
||||
• Fuzzy: "mülkiyet~" (similar spelling variations)
|
||||
• Multiple terms: "mülkiyet AND hak" (both terms required)
|
||||
• Either term: "mülkiyet OR tapu" (either term acceptable)
|
||||
• Proximity: "\"mülkiyet hakkı\"~5" (words within 5 positions)
|
||||
• Regex: "/mülk.*/" (pattern matching)
|
||||
• Boolean AND: "mülkiyet AND hak" (both terms required)
|
||||
• Boolean OR: "mülkiyet OR tapu" (either term acceptable)
|
||||
• Boolean NOT: "mülkiyet NOT satış" (contains mülkiyet but not satış)
|
||||
NOTE: Wildcards (*,?), regex patterns (/regex/), fuzzy search (~), and proximity search are NOT supported.
|
||||
For best results, use exact phrases with quotes for legal terms."""),
|
||||
court_types: List[BedestenCourtTypeEnum] = Field(
|
||||
default=["YARGITAYKARARI", "DANISTAYKARAR"],
|
||||
@@ -1801,6 +1822,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 +1854,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)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "yargi-mcp"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "MCP Server For Turkish Legal Databases"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
Reference in New Issue
Block a user