15 Commits
Author SHA1 Message Date
saidsurucu f5fa0076f8 Release v0.1.6: Production deployment with full Claude AI integration 2025-07-21 21:21:19 +03:00
saidsurucu 7a346ef3f6 Update mcp_server_main.py 2025-07-21 21:11:09 +03:00
saidsurucu 217103f0b6 fix tool count issue 2025-07-21 20:46:54 +03:00
saidsurucu 1fbcb65031 Update asgi_app.py 2025-07-21 19:58:44 +03:00
saidsurucu 9e40671798 Update asgi_app.py 2025-07-21 19:51:21 +03:00
saidsurucu 6c8a614872 Update asgi_app.py 2025-07-21 19:30:56 +03:00
saidsurucu 861d9e86ef Update asgi_app.py 2025-07-21 19:26:38 +03:00
saidsurucu c4b5d3608a Update asgi_app.py 2025-07-21 19:17:43 +03:00
saidsurucu 38e0cc032b Update asgi_app.py 2025-07-21 18:50:56 +03:00
saidsurucu 2c1b8c6f9d fix remote mcp 2025-07-21 18:45:20 +03:00
saidsurucu 92f04fbab6 Update asgi_app.py 2025-07-21 18:05:04 +03:00
saidsurucu 515347e29c Update asgi_app.py 2025-07-21 17:51:29 +03:00
saidsurucu ebefe22a4c Update mcp_server_main.py 2025-07-21 17:37:22 +03:00
saidsurucu c93244ee10 Update fly.toml 2025-07-21 16:44:48 +03:00
saidsurucu d84f8a2c88 update working operators 2025-07-21 15:33:16 +03:00
6 changed files with 2213 additions and 209 deletions
+2077
View File
File diff suppressed because it is too large Load Diff
+77 -189
View File
@@ -19,9 +19,10 @@ from fastapi.exception_handlers import http_exception_handler
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.responses import Response from starlette.responses import Response
from starlette.requests import Request as StarletteRequest
# Import the fully configured MCP app with all tools # Import the MCP app creator function
from mcp_server_main import app as mcp_server from mcp_server_main import create_app
# Import Stripe webhook router # Import Stripe webhook router
from stripe_webhook import router as stripe_router from stripe_webhook import router as stripe_router
@@ -36,23 +37,69 @@ BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
# Setup logging # Setup logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configure CORS middleware # Configure CORS and Auth middleware
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") 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 = [ custom_middleware = [
Middleware( Middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=cors_origins, allow_origins=cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"], 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"],
), ),
] ]
# Create MCP Starlette sub-application (without auth wrapper) # Create MCP app with Bearer authentication
mcp_app = mcp_server.http_app( mcp_server = create_app(auth=bearer_auth)
path="/",
middleware=custom_middleware # 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 # Configure JSON encoder for proper Turkish character support
import json import json
@@ -74,13 +121,12 @@ class UTF8JSONResponse(JSONResponse):
separators=(",", ":"), separators=(",", ":"),
).encode("utf-8") ).encode("utf-8")
# Create FastAPI wrapper application with MCP lifespan # Create FastAPI wrapper application
app = FastAPI( app = FastAPI(
title="Yargı MCP Server", title="Yargı MCP Server",
description="MCP server for Turkish legal databases with OAuth authentication", description="MCP server for Turkish legal databases with OAuth authentication",
version="0.1.0", version="0.1.0",
middleware=custom_middleware, middleware=custom_middleware,
lifespan=mcp_app.lifespan, # MCP app lifespan
default_response_class=UTF8JSONResponse # Use UTF-8 JSON encoder default_response_class=UTF8JSONResponse # Use UTF-8 JSON encoder
) )
@@ -107,141 +153,7 @@ async def custom_401_handler(request: Request, exc: HTTPException):
return response return response
# Mount MCP app as sub-application at /mcp-server to avoid path conflicts # FastAPI health check endpoint - BEFORE mounting MCP app
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
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint for monitoring""" """Health check endpoint for monitoring"""
@@ -253,6 +165,22 @@ async def health_check():
"auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true" "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 # FastAPI root endpoint
@app.get("/") @app.get("/")
async def root(): async def root():
@@ -374,48 +302,8 @@ async def oauth_authorization_server_root():
"resource_documentation": f"{BASE_URL}/mcp" "resource_documentation": f"{BASE_URL}/mcp"
}) })
# MCP endpoint info for GET requests (ChatGPT compatibility) # Note: GET /mcp is handled by the mounted MCP app itself
@app.get("/mcp") # This prevents 405 Method Not Allowed errors on POST requests
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>"
]
}
})
# OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required # OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required
@app.get("/.well-known/oauth-protected-resource") @app.get("/.well-known/oauth-protected-resource")
+1 -1
View File
@@ -21,7 +21,7 @@ class BedestenSearchData(BaseModel):
pageSize: int = Field(..., description="Results per page (1-10)") pageSize: int = Field(..., description="Results per page (1-10)")
pageNumber: int = Field(..., description="Page number (1-indexed)") pageNumber: int = Field(..., description="Page number (1-indexed)")
itemTypeList: List[str] = Field(..., description="Court type filter (YARGITAYKARARI/DANISTAYKARAR/YERELHUKUK/ISTINAFHUKUK/KYB)") 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=""" birimAdi: BirimAdiEnum = Field("ALL", description="""
Chamber filter (optional). Abbreviated values with Turkish names: 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) • 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)
+2 -2
View File
@@ -17,9 +17,9 @@ LOG_LEVEL = "info"
[http_service] [http_service]
internal_port = 8000 internal_port = 8000
force_https = true force_https = true
auto_stop_machines = 'stop' auto_stop_machines = 'off'
auto_start_machines = true auto_start_machines = true
min_machines_running = 0 min_machines_running = 1
processes = ['app'] processes = ['app']
[[vm]] [[vm]]
+55 -16
View File
@@ -12,6 +12,13 @@ from typing import Optional, Dict, List, Literal, Any, Union
import urllib.parse import urllib.parse
import tiktoken import tiktoken
from fastmcp.server.middleware import Middleware, MiddlewareContext 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 --- # --- Logging Configuration Start ---
LOG_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") 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 # Create FastMCP app directly without authentication wrapper
from fastmcp import FastMCP from fastmcp import FastMCP
def create_app(): def create_app(auth=None):
"""Create basic FastMCP app without authentication wrapper""" """Create FastMCP app with standard capabilities and optional auth."""
return FastMCP("Yargı MCP Server") 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 --- # --- Module Imports ---
from yargitay_mcp_module.client import YargitayOfficialApiClient 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 --- # Placeholder app for decorators - will be replaced in create_app() after all tools are defined
token_counter = TokenCountingMiddleware() app = FastMCP("Yargı MCP Server Placeholder")
app.add_middleware(token_counter)
logger.info("Token counting middleware added to MCP server")
# --- Tool Documentation Resources --- # --- Tool Documentation Resources ---
@app.resource("docs://tools/yargitay") @app.resource("docs://tools/yargitay")
@@ -1773,17 +1795,16 @@ async def get_rekabet_kurumu_document(
} }
) )
async def search_bedesten_unified( 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) • Simple: "mülkiyet hakkı" (finds both words)
• Exact phrase: "\"mülkiyet hakkı\"" (finds exact phrase) • Exact phrase: "\"mülkiyet hakkı\"" (finds exact phrase)
• Required term: "+mülkiyet hakkı" (must contain mülkiyet) • Required term: "+mülkiyet hakkı" (must contain mülkiyet)
• Exclude term: "mülkiyet -kira" (contains mülkiyet but not kira) • Exclude term: "mülkiyet -kira" (contains mülkiyet but not kira)
Wildcard: "mülk*" (mülkiyet, mülk, etc.) Boolean AND: "mülkiyet AND hak" (both terms required)
Fuzzy: "mülkiyet~" (similar spelling variations) Boolean OR: "mülkiyet OR tapu" (either term acceptable)
Multiple terms: "mülkiyet AND hak" (both terms required) Boolean NOT: "mülkiyet NOT satış" (contains mülkiyet but not satış)
• Either term: "mülkiyet OR tapu" (either term acceptable) NOTE: Wildcards (*,?), regex patterns (/regex/), fuzzy search (~), and proximity search are NOT supported.
• Proximity: "\"mülkiyet hakkı\"~5" (words within 5 positions)
• Regex: "/mülk.*/" (pattern matching)
For best results, use exact phrases with quotes for legal terms."""), For best results, use exact phrases with quotes for legal terms."""),
court_types: List[BedestenCourtTypeEnum] = Field( court_types: List[BedestenCourtTypeEnum] = Field(
default=["YARGITAYKARARI", "DANISTAYKARAR"], default=["YARGITAYKARARI", "DANISTAYKARAR"],
@@ -1801,6 +1822,24 @@ For best results, use exact phrases with quotes for legal terms."""),
) -> dict: ) -> dict:
"""Search Turkish legal databases via unified Bedesten API.""" """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 pageSize = 10 # Default value
search_data = BedestenSearchData( search_data = BedestenSearchData(
@@ -1815,7 +1854,7 @@ For best results, use exact phrases with quotes for legal terms."""),
search_request = BedestenSearchRequest(data=search_data) 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: try:
response = await bedesten_client_instance.search_documents(search_request) response = await bedesten_client_instance.search_documents(search_request)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "yargi-mcp" name = "yargi-mcp"
version = "0.1.5" version = "0.1.6"
description = "MCP Server For Turkish Legal Databases" description = "MCP Server For Turkish Legal Databases"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"