Remove auth and Fly.io deployment

This commit is contained in:
saidsurucu
2026-04-03 02:08:56 +03:00
parent a4d9e2e53d
commit a66a3f2053
23 changed files with 361 additions and 3133 deletions
@@ -0,0 +1,278 @@
# Constitutional Court (Anayasa Mahkemesi) Implementation - Architecture Analysis
## Overview
The Anayasa Mahkemesi module provides comprehensive access to Turkish Constitutional Court decisions through two separate systems:
1. **Norm Denetimi** (Norm Control) - Judicial review of laws
2. **Bireysel Başvuru** (Individual Applications) - Individual constitutional complaints
Both systems have been **unified** into a single MCP interface (Phase 6 optimization - 361 tokens saved).
## Current Architecture
### 1. Module Structure
```
anayasa_mcp_module/
├── __init__.py # Empty
├── models.py # Pydantic data models (230 lines)
├── client.py # Norm Denetimi client (356 lines)
├── bireysel_client.py # Bireysel Başvuru client (355 lines)
└── unified_client.py # Unified routing logic (122 lines)
```
### 2. API Endpoints
**Norm Denetimi API:**
- Base: `https://normkararlarbilgibankasi.anayasa.gov.tr`
- Search: GET `/Ara` (with query parameters)
- Document: Dynamic URLs from search results
**Bireysel Başvuru API:**
- Base: `https://kararlarbilgibankasi.anayasa.gov.tr`
- Search: GET `/Ara?KararBulteni=1` (with query parameters for report-style results)
- Document: Dynamic paths like `/BB/YYYY/NNNN`
### 3. Current Search Implementation (Keyword-Based)
**Norm Denetimi Search Parameters (19 parameters):**
- Keyword logic: `keywords_all[]`, `keywords_any[]`, `keywords_exclude[]` (AND/OR/NOT)
- Identifiers: case_number_esas, decision_number_karar
- Dates: first_review_date_start/end, decision_date_start/end, official_gazette_date_start/end
- Structural filters: period, application_type, rapporteur_name, norm_type, review_outcomes, reason_for_final_outcome
- Boolean filters: has_press_release, has_dissenting_opinion, has_different_reasoning
- Other: basis_constitution_article_numbers, attending_members_names
- Pagination: results_per_page (1-10), page_to_fetch, sort_by_criteria
**Bireysel Başvuru Search Parameters (simple):**
- keywords[] (AND logic only)
- page_to_fetch for pagination
**Search Architecture (client.py):**
- `_build_search_query_params_for_aym()`: Converts Pydantic model to URL query parameters (tuples list)
- `search_norm_denetimi_decisions()`: Makes HTTP GET request with params, parses HTML response
- Uses BeautifulSoup to find:
- Decision count: div.bulunankararsayisi (regex: "(\d+)\s*Karar Bulundu")
- Individual decisions: div.birkarar (contains reference number, metadata, keyword count)
- Decision details: Next sibling div.col-sm-12 with table containing norm information
- Returns AnayasaSearchResult with parsed decisions list
### 4. Document Retrieval (Full Text Conversion)
**HTML to Markdown Conversion Process:**
1. Fetch document from URL
2. Parse HTML with BeautifulSoup
3. Extract main content:
- Find div#Karar (decision tab) or fallback to div.KararMetni or div.WordSection1
- Remove: scripts, styles, .item.col-sm-12 divs, .modal.fade divs
4. Convert to Markdown using MarkItDown with BytesIO stream (no temp files)
5. Extract metadata during fetch:
- Esas No./Karar No.: Find bold text in <p> tags containing "Esas No.:" and "Karar No.:"
- Karar Tarihi: Find bold text containing "Karar tarihi:" or regex "Karar Tarihi\s*:\s*([\d\.]+)"
- Resmi Gazete: Find text containing "Resmî Gazete tarih ve sayısı:" or "Resmi Gazete tarih/sayı:"
**Pagination & Chunking:**
- Split markdown into 5,000 character chunks
- Calculate: total_pages = ceil(len(markdown) / 5000)
- Return current_page_clamped (max 1, min total_pages)
- Include pagination metadata: current_page, total_pages, is_paginated flag
### 5. Data Models (models.py - 230 lines)
**Norm Denetimi Models:**
- `AnayasaNormDenetimiSearchRequest`: 19 search parameters
- `AnayasaReviewedNormInfo`: norm_name_or_number, article_number, review_type_and_outcome, outcome_reason, basis_constitution_articles_cited[], postponement_period
- `AnayasaDecisionSummary`: decision_reference_no, decision_page_url, keywords_found_count, application_type_summary, applicant_summary, decision_outcome_summary, decision_date_summary, reviewed_norms[]
- `AnayasaSearchResult`: decisions[], total_records_found, retrieved_page_number
- `AnayasaDocumentMarkdown`: source_url, decision_reference_no_from_page, decision_date_from_page, official_gazette_info_from_page, markdown_chunk, current_page, total_pages, is_paginated
**Bireysel Başvuru Models:**
- `AnayasaBireyselReportSearchRequest`: keywords[], page_to_fetch
- `AnayasaBireyselReportDecisionDetail`: hak, mudahale_iddiası, sonuç, giderim (4 fields per right examined)
- `AnayasaBireyselReportDecisionSummary`: title, decision_reference_no, decision_page_url, decision_type_summary, decision_making_body, application_date_summary, decision_date_summary, application_subject_summary, details[]
- `AnayasaBireyselReportSearchResult`: decisions[], total_records_found, retrieved_page_number
- `AnayasaBireyselBasvuruDocumentMarkdown`: source_url, basvuru_no_from_page, karar_tarihi_from_page, basvuru_tarihi_from_page, karari_veren_birim_from_page, karar_turu_from_page, resmi_gazete_info_from_page, markdown_chunk, current_page, total_pages, is_paginated
**Unified Models:**
- `AnayasaUnifiedSearchRequest`: decision_type (norm_denetimi|bireysel_basvuru), keywords[], page_to_fetch, results_per_page, + type-specific parameters
- `AnayasaUnifiedSearchResult`: decision_type, decisions[] (Dict[str, Any]), total_records_found, retrieved_page_number
- `AnayasaUnifiedDocumentMarkdown`: decision_type, source_url, document_data (Dict), markdown_chunk, current_page, total_pages, is_paginated
### 6. Unified Client Routing (unified_client.py - 122 lines)
**AnayasaUnifiedClient class:**
- Maintains instances of both norm_client and bireysel_client
- `search_unified()`: Routes based on decision_type parameter
- norm_denetimi: Converts to AnayasaNormDenetimiSearchRequest, calls norm_client.search_norm_denetimi_decisions()
- bireysel_basvuru: Converts to AnayasaBireyselReportSearchRequest, calls bireysel_client.search_bireysel_basvuru_report()
- Returns unified AnayasaUnifiedSearchResult
- `get_document_unified()`: Auto-detects decision type from URL
- Checks for "normkararlarbilgibankasi" in netloc or "/ND/" in path → norm_denetimi
- Checks for "kararlarbilgibankasi" in netloc or "/BB/" in path → bireysel_basvuru
- Calls appropriate client, wraps result in unified model
### 7. MCP Tool Integration (mcp_server_main.py)
**Active Tools (2 tools - Phase 6 optimization):**
```python
@app.tool(
description="Search Constitutional Court decisions from either Norm Control or Individual Applications",
annotations={"readOnlyHint": True, "openWorldHint": True, "idempotentHint": True}
)
async def search_anayasa_unified(
decision_type: Literal["norm_denetimi", "bireysel_basvuru"],
keywords: List[str],
page_to_fetch: int (1-100),
# Norm Denetimi specific (ignored for bireysel_basvuru)
keywords_all: List[str],
keywords_any: List[str],
decision_type_norm: Literal["ALL", "1", "2", "3"],
application_date_start: str,
application_date_end: str,
# Bireysel Başvuru specific (ignored for norm_denetimi)
decision_start_date: str,
decision_end_date: str,
norm_type: Literal["ALL", "1", "2", ...],
subject_category: str
) -> str (JSON)
```
```python
@app.tool(
description="Retrieve full text of Constitutional Court decision. Auto-detects decision type from URL",
annotations={"readOnlyHint": True, "openWorldHint": False, "idempotentHint": True}
)
async def get_anayasa_document_unified(
document_url: str,
page_number: int (1-indexed)
) -> str (JSON)
```
**Deactivated Tools (4 tools - Phase 6 optimization, marked with DEACTIVATED):**
- search_anayasa_norm_denetimi_decisions
- get_anayasa_norm_denetimi_document_markdown
- search_anayasa_bireysel_basvuru_report
- get_anayasa_bireysel_basvuru_document_markdown
## Search Capabilities Analysis
### Current Keyword-Based Search Strengths
**Norm Denetimi - Rich Structural Filtering:**
1. Multi-keyword logic with AND/OR/NOT operators
2. Case/decision number search (exact matching)
3. Date range filtering (review, decision, gazette dates)
4. Norm categorization (14 norm types)
5. Application type filtering (3 categories)
6. Constitutional period selection (1961 vs 1982 constitutions)
7. Decision outcome filtering (8 outcome types)
8. Reasoning/grounds filtering (30 different grounds)
9. Member/rapporteur filtering
10. Constitutional articles cited filtering
**Bireysel Başvuru - Report Format:**
1. Simple keyword search
2. Rights/claims detailed in structured table format
3. Remedy/solution tracking
### Limitations of Current Keyword Search
1. **No semantic understanding**: Different words for same concept ("mülkiyet hakkı" vs "property rights")
2. **No concept hierarchy**: Can't find related legal principles
3. **No cross-language**: Turkish-only, no English queries
4. **No abbreviation matching**: "HADD" vs "Hukuk Alanında Değerli Dosya Denetimi"
5. **No synonym support**: Formal vs informal terminology
6. **No semantic similarity**: Can't find similar cases with different terminology
7. **No legal concept graph**: Can't traverse related principles or doctrines
8. **No fuzzy matching**: Typos or spelling variations fail completely
9. **No legal reasoning search**: Can't query by legal arguments or doctrinal approaches
10. **No cross-system semantic linking**: Norm Denetimi and Bireysel Başvuru not semantically linked
11. **Order dependency**: Query order may affect results
12. **No ranking by relevance**: Just keyword presence/absence
13. **No query expansion**: No automatic synonym/related term expansion
### HTML Document Structure
**Norm Denetimi Search Results HTML:**
```
div.birkarar (repeated for each decision)
├── div.bkararbaslik (header with E./K. numbers)
│ └── div.BulunanKelimeSayisi (keyword count)
└── div.kararbilgileri (metadata with | separators: application_type|applicant|outcome|date)
Next sibling:
div.col-sm-12
└── table.table > tbody > tr (one row per reviewed norm with 6 columns)
├── td: norm name/number
├── td: article number
├── td: review type and outcome
├── td: outcome reason
├── td: constitutional articles cited (comma-separated)
└── td: postponement period
```
**Full Decision Content (both types):**
```
div#Karar (decision tab)
└── div.KararMetni or div.WordSection1
└── HTML content in MS Word format (many nested divs with styles)
Metadata extracted from:
<p><b>Esas No.:</b> [number]</p>
<p><b>Karar No.:</b> [number]</p>
<p><b>Karar tarihi:</b> [date]</p>
<p>Resmî Gazete tarih ve sayısı: [info]</p>
```
### Document Content Characteristics
- **Language**: Turkish legal language (specialized terminology)
- **Format**: Microsoft Word-generated HTML (nested divs, complex styles)
- **Content types**:
- Norm Denetimi: Constitutional principle analysis, legal reasoning, comparison with challenged norm
- Bireysel Başvuru: Right violated, remedy granted, procedural requirements
- **Typical length**: 5,000-50,000+ characters
- **Citations**: Internal cross-references to constitutional articles
- **Structure**: Formal legal document with sections, subsections, reasoning
## Key Technical Insights for Semantic Search
### Content Encoding
- Currently: HTML → BeautifulSoup parsing → MarkItDown → Markdown
- Extraction: Specific div/class/id selectors
- Metadata: Regex patterns and text parsing
### Search Query Flow
1. User provides keywords/filters
2. Convert Pydantic model to URL query parameters
3. HTTP GET request to Constitutional Court API
4. HTML response parsed with BeautifulSoup
5. Decision summaries extracted and validated
6. Results returned as JSON
### Document Retrieval Flow
1. Get document URL from search results
2. HTTP GET request to URL
3. Parse HTML for metadata extraction
4. MarkItDown converts HTML to Markdown
5. Chunk by 5,000 characters
6. Return paginated Markdown with metadata
## Performance Baseline
- **Search**: ~1-5 seconds (HTML parsing + regex extraction)
- **Document**: ~2-10 seconds (fetch + parse + MarkItDown + chunking)
- **Memory**: Minimal (5,000 char chunks, no full document in memory)
- **API Response Size**: Typically 50-500 KB HTML for search, 100-1000 KB for full decision
## Next Steps for Semantic Search Integration
1. **Vector Embeddings**: Embed decisions using Turkish legal model
2. **Concept Extraction**: Identify and tag legal concepts (rights, procedures, principles)
3. **Semantic Queries**: Convert natural language questions to embeddings
4. **Hybrid Search**: Combine keyword + semantic similarity
5. **Legal Ontology**: Map Turkish Constitutional Court concepts and relationships
6. **Cross-system Linking**: Semantically link Norm Denetimi and Bireysel Başvuru decisions
7. **Precedent Graph**: Extract citations and create legal precedent relationships
8. **Fine-tuned Embeddings**: Train embeddings specifically on Turkish Constitutional law
9. **Ranking**: Re-rank results by semantic relevance to user's legal intent
10. **Explanation**: Provide semantic reasoning for why result is relevant
+56 -1
View File
@@ -79,6 +79,61 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: "" initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "yargi-mcp" project_name: "yargi-mcp"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: [] included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
-34
View File
@@ -1,34 +0,0 @@
# -------- BASE IMAGE ---------------------------------------------------------
FROM python:3.12-slim
# -------- Runtime setup ----------------------------------------------------
WORKDIR /app
# Copy dependency manifests first for layer-cache
COPY pyproject.toml poetry.lock* requirements*.txt* ./
# Fast, deterministic install with `uv`
RUN pip install --no-cache-dir uv && \
uv pip install --system --no-cache-dir . && \
uv pip install --system --no-cache-dir .[asgi,saas]
# Cache buster - force rebuild
ARG CACHE_BUST=202510061202
RUN echo "Cache bust: $CACHE_BUST"
# Copy application source
COPY . .
# -------- Environment ------------------------------------------------------
ENV PYTHONUNBUFFERED=1
ENV ENABLE_AUTH=true
ENV PORT=8000
# -------- Health check -----------------------------------------------------
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import httpx, os, sys; r=httpx.get(f'http://localhost:{os.getenv(\"PORT\",\"8000\")}/health'); sys.exit(0 if r.status_code==200 else 1)"
EXPOSE 8000
# -------- Entrypoint -------------------------------------------------------
CMD ["uvicorn", "asgi_app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
-1
View File
@@ -1 +0,0 @@
web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT
+22 -476
View File
@@ -2,112 +2,32 @@
ASGI application for Yargı MCP Server ASGI application for Yargı MCP Server
This module provides ASGI/HTTP access to the 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 allowing it to be deployed as a web service with FastAPI wrapper.
for OAuth integration and proper middleware support.
Usage: Usage:
uvicorn asgi_app:app --host 0.0.0.0 --port 8000 uvicorn asgi_app:app --host 0.0.0.0 --port 8000
""" """
import os import os
import time
import logging
import json import json
from datetime import datetime, timedelta import logging
from fastapi import FastAPI, Request, HTTPException, Query from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, HTMLResponse, Response from fastapi.responses import JSONResponse
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.middleware.base import BaseHTTPMiddleware
# Import the proper create_app function that includes all middleware
from mcp_server_main import create_app from mcp_server_main import create_app
# Conditional auth-related imports (only if auth enabled)
_auth_check = os.getenv("ENABLE_AUTH", "false").lower() == "true"
if _auth_check:
mcp_auth_router = None
# Import Stripe webhook router
try:
from stripe_webhook import router as stripe_router
except ImportError:
stripe_router = None
else:
mcp_auth_router = None
stripe_router = None
# OAuth configuration from environment variables
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 # Setup logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configure CORS and Auth middleware # Configure CORS
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
# Configure Bearer token authentication based on ENABLE_AUTH # Create MCP app
auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true" mcp_server = create_app()
bearer_auth = None
# Only import and configure auth if enabled # Create MCP Starlette sub-application
if auth_enabled:
# Import FastMCP JWT Verifier (handles both old and new FastMCP versions)
try:
# FastMCP 2.12+ uses JWTVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
AuthProviderClass = JWTVerifier
except ImportError:
try:
# Older FastMCP versions used BearerAuthProvider
from fastmcp.server.auth import BearerAuthProvider
from fastmcp.server.auth.providers.bearer import RSAKeyPair
AuthProviderClass = BearerAuthProvider
except ImportError:
logger.error("No compatible auth provider found in FastMCP")
AuthProviderClass = None
RSAKeyPair = None
# 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")
if AuthProviderClass:
if CLERK_SECRET_KEY and CLERK_ISSUER:
# Production: Use Clerk JWKS endpoint for token validation
bearer_auth = AuthProviderClass(
jwks_uri=f"{CLERK_ISSUER}/.well-known/jwks.json",
issuer=None,
algorithm="RS256",
audience=None,
required_scopes=[]
)
elif RSAKeyPair:
# Development: Generate RSA key pair for testing
dev_key_pair = RSAKeyPair.generate()
bearer_auth = AuthProviderClass(
public_key=dev_key_pair.public_key,
issuer="https://dev.yargimcp.com",
audience="dev-mcp-server",
required_scopes=["yargi.read"]
)
else:
CLERK_SDK_AVAILABLE = False
logger.info("Authentication disabled (ENABLE_AUTH=false)")
# Create MCP app with Bearer authentication (None if auth disabled)
mcp_server = create_app(auth=bearer_auth)
# Create MCP Starlette sub-application with root path - mount will add /mcp prefix
mcp_app = mcp_server.http_app(path="/") mcp_app = mcp_server.http_app(path="/")
@@ -118,7 +38,7 @@ class UTF8JSONResponse(JSONResponse):
headers = {} headers = {}
headers["Content-Type"] = "application/json; charset=utf-8" headers["Content-Type"] = "application/json; charset=utf-8"
super().__init__(content, status_code, headers, **kwargs) super().__init__(content, status_code, headers, **kwargs)
def render(self, content) -> bytes: def render(self, content) -> bytes:
return json.dumps( return json.dumps(
content, content,
@@ -133,43 +53,22 @@ custom_middleware = [
CORSMiddleware, CORSMiddleware,
allow_origins=cors_origins, allow_origins=cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS", "DELETE"], allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"], allow_headers=["Content-Type", "X-Request-ID", "X-Session-ID"],
), ),
] ]
# Create FastAPI wrapper application # 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",
version="0.1.0", version="0.1.0",
middleware=custom_middleware, middleware=custom_middleware,
default_response_class=UTF8JSONResponse, # Use UTF-8 JSON encoder default_response_class=UTF8JSONResponse,
redirect_slashes=False # Disable to prevent 307 redirects on /mcp endpoint redirect_slashes=False,
) )
# Add auth-related routers to FastAPI (only if available)
if stripe_router:
app.include_router(stripe_router, prefix="/api/stripe")
# Custom 401 exception handler for MCP spec compliance
@app.exception_handler(401)
async def custom_401_handler(request: Request, exc: HTTPException):
"""Custom 401 handler that adds WWW-Authenticate header as required by MCP spec"""
response = await http_exception_handler(request, exc)
# Add WWW-Authenticate header pointing to protected resource metadata
# as required by RFC 9728 Section 5.1 and MCP Authorization spec
response.headers["WWW-Authenticate"] = (
'Bearer '
'error="invalid_token", '
'error_description="The access token is missing or invalid", '
f'resource="{BASE_URL}/.well-known/oauth-protected-resource"'
)
return response
# FastAPI health check endpoint - BEFORE mounting MCP app
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint for monitoring""" """Health check endpoint for monitoring"""
@@ -178,119 +77,33 @@ async def health_check():
"service": "Yargı MCP Server", "service": "Yargı MCP Server",
"version": "0.1.0", "version": "0.1.0",
"tools_count": len(mcp_server._tool_manager._tools), "tools_count": len(mcp_server._tool_manager._tools),
"auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true"
} }
# Add explicit redirect for /mcp to /mcp/ with method preservation
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"]) @app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
async def redirect_to_slash(request: Request): async def redirect_to_slash(request: Request):
"""Redirect /mcp to /mcp/ preserving HTTP method with 308""" """Redirect /mcp to /mcp/ preserving HTTP method with 308"""
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/mcp/", status_code=308) return RedirectResponse(url="/mcp/", 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"
})
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
media_type=response.headers.get("content-type")
)
except Exception as e:
return JSONResponse(
{"error": "proxy_error", "message": str(e)},
status_code=500,
headers={"Access-Control-Allow-Origin": "*"}
)
# FastAPI root endpoint
@app.get("/") @app.get("/")
async def root(): async def root():
"""Root endpoint with service information""" """Root endpoint with service information"""
return { return {
"service": "Yargı MCP Server", "service": "Yargı MCP Server",
"description": "MCP server for Turkish legal databases with OAuth authentication", "description": "MCP server for Turkish legal databases",
"endpoints": { "endpoints": {
"mcp": "/mcp", "mcp": "/mcp",
"health": "/health", "health": "/health",
"status": "/status", "status": "/status",
"stripe_webhook": "/api/stripe/webhook",
"oauth_login": "/auth/login",
"oauth_callback": "/auth/callback",
"oauth_google": "/auth/google/login",
"user_info": "/auth/user"
}, },
"transports": { "transports": {
"http": "/mcp" "http": "/mcp"
}, },
"supported_databases": [ "supported_databases": [
"Yargıtay (Court of Cassation)", "Yargıtay (Court of Cassation)",
"Danıştay (Council of State)", "Danıştay (Council of State)",
"Emsal (Precedent)", "Emsal (Precedent)",
"Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)", "Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)",
"Anayasa Mahkemesi (Constitutional Court)", "Anayasa Mahkemesi (Constitutional Court)",
@@ -299,140 +112,12 @@ async def root():
"Sayıştay (Court of Accounts)", "Sayıştay (Court of Accounts)",
"KVKK (Personal Data Protection Authority)", "KVKK (Personal Data Protection Authority)",
"BDDK (Banking Regulation and Supervision Agency)", "BDDK (Banking Regulation and Supervision Agency)",
"Bedesten API (Multiple courts)" "Bedesten API (Multiple courts)",
"Sigorta Tahkim Komisyonu (Insurance Arbitration Commission)",
], ],
"authentication": {
"enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true",
"type": "OAuth 2.0 via Clerk",
"issuer": CLERK_ISSUER,
"providers": ["google"],
"flow": "authorization_code"
}
} }
# 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"],
"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"
}
# 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 {
"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"],
"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"
}
@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 {
"resource": BASE_URL,
"authorization_servers": [
BASE_URL
],
"scopes_supported": ["read", "search"],
"bearer_methods_supported": ["header"],
"resource_documentation": f"{BASE_URL}/mcp",
"resource_policy_uri": f"{BASE_URL}/privacy"
}
# 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 {
"resource": BASE_URL,
"authorization_servers": [
BASE_URL
],
"scopes_supported": ["read", "search"],
"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 {
"mcp_server": {
"name": "Yargı MCP Server",
"version": "0.1.0",
"endpoint": f"{BASE_URL}/mcp",
"authentication": {
"type": "oauth2",
"authorization_url": f"{BASE_URL}/auth/login",
"scopes": ["read", "search"]
},
"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 {
"name": "Yargı MCP Server",
"description": "MCP server for Turkish legal databases",
"version": "0.1.0",
"protocol": "mcp",
"transport": "http",
"endpoint": "/mcp",
"authentication": {
"type": "oauth2",
"authorization_url": "/auth/login",
"token_url": "/token",
"scopes": ["read", "search"],
"provider": "clerk"
},
"capabilities": {
"tools": True,
"resources": True,
"prompts": False
},
"tools_count": len(mcp_server._tool_manager._tools),
"contact": {
"url": BASE_URL,
"email": "support@yargi-mcp.dev"
}
}
# FastAPI status endpoint
@app.get("/status") @app.get("/status")
async def status(): async def status():
"""Status endpoint with detailed information""" """Status endpoint with detailed information"""
@@ -442,159 +127,20 @@ async def status():
"name": tool.name, "name": tool.name,
"description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description "description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description
}) })
return { return {
"status": "operational", "status": "operational",
"tools": tools, "tools": tools,
"total_tools": len(tools), "total_tools": len(tools),
"transport": "streamable_http", "transport": "streamable_http",
"architecture": "FastAPI wrapper + MCP Starlette sub-app",
"auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled"
} }
# 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)"""
try:
# 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:
try:
return "oauth_user_from_token"
except Exception as e:
pass
# Fallback to cookie validation # Mount MCP app at /mcp/
clerk_session = request.cookies.get("__session")
if not clerk_session:
raise HTTPException(status_code=401, detail="No Clerk session found")
# Validate session with Clerk
session = clerk.sessions.verify_session(clerk_session)
return session.user_id
except ImportError:
return "dev_user_123"
except Exception as e:
raise HTTPException(status_code=401, detail=f"OAuth session validation failed: {str(e)}")
# MCP OAuth Callback Endpoint
@app.get("/auth/mcp-callback")
async def mcp_oauth_callback(request: Request, clerk_token: str = Query(None)):
"""Handle OAuth callback for MCP token generation"""
try:
# Validate Clerk session with JWT token support
user_id = await validate_clerk_session_for_oauth(request, clerk_token)
# Return success response
return HTMLResponse(f"""
<html>
<head>
<title>MCP Connection Successful</title>
<style>
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
.success {{ color: #28a745; }}
.token {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; word-break: break-all; }}
</style>
</head>
<body>
<h1 class="success">✅ MCP Connection Successful!</h1>
<p>Your Yargı MCP integration is now active.</p>
<div class="token">
<strong>Authentication:</strong><br>
<code>Use your Clerk JWT token directly with Bearer authentication</code>
</div>
<p>You can now close this window and return to your MCP client.</p>
<script>
// Try to close the popup if opened as such
if (window.opener) {{
window.opener.postMessage({{
type: 'MCP_AUTH_SUCCESS',
token: 'use_clerk_jwt_token'
}}, '*');
setTimeout(() => window.close(), 3000);
}}
</script>
</body>
</html>
""")
except HTTPException as e:
return HTMLResponse(f"""
<html>
<head>
<title>MCP Connection Failed</title>
<style>
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
.error {{ color: #dc3545; }}
.debug {{ background: #f8f9fa; padding: 10px; margin: 20px 0; border-radius: 5px; font-family: monospace; }}
</style>
</head>
<body>
<h1 class="error">❌ MCP Connection Failed</h1>
<p>{e.detail}</p>
<div class="debug">
<strong>Debug Info:</strong><br>
Clerk Token: {'✅ Provided' if clerk_token else '❌ Missing'}<br>
Error: {e.detail}<br>
Status: {e.status_code}
</div>
<p>Please try again or contact support.</p>
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
</body>
</html>
""", status_code=e.status_code)
except Exception as e:
return HTMLResponse(f"""
<html>
<head>
<title>MCP Connection Error</title>
<style>
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
.error {{ color: #dc3545; }}
</style>
</head>
<body>
<h1 class="error">❌ Unexpected Error</h1>
<p>An unexpected error occurred during authentication.</p>
<p>Error: {str(e)}</p>
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
</body>
</html>
""", status_code=500)
# OAuth2 Token Endpoint - Now uses Clerk JWT tokens directly
@app.post("/auth/mcp-token")
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_for_oauth(request)
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}
)
# Mount MCP app at /mcp/ with trailing slash
app.mount("/mcp/", mcp_app) app.mount("/mcp/", mcp_app)
# Set the lifespan context after mounting # Set the lifespan context after mounting
app.router.lifespan_context = mcp_app.lifespan app.router.lifespan_context = mcp_app.lifespan
# Export for uvicorn # Export for uvicorn
__all__ = ["app"] __all__ = ["app"]
-66
View File
@@ -1,66 +0,0 @@
version: '3.8'
services:
yargi-mcp:
build: .
image: yargi-mcp:latest
container_name: yargi-mcp-server
ports:
- "${PORT:-8000}:8000"
environment:
- HOST=0.0.0.0
- PORT=8000
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- API_TOKEN=${API_TOKEN:-}
- PYTHONUNBUFFERED=1
volumes:
# Mount logs directory
- ./logs:/app/logs
# Mount .env file if it exists
- ./.env:/app/.env:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- yargi-network
# Optional: Nginx reverse proxy
nginx:
image: nginx:alpine
container_name: yargi-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- yargi-mcp
networks:
- yargi-network
profiles:
- production
# Optional: Redis for caching (future enhancement)
redis:
image: redis:alpine
container_name: yargi-redis
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- yargi-network
profiles:
- with-cache
networks:
yargi-network:
driver: bridge
volumes:
redis-data:
-428
View File
@@ -1,428 +0,0 @@
# Yargı MCP Server Dağıtım Rehberi
Bu rehber, Yargı MCP Server'ın ASGI web servisi olarak çeşitli dağıtım seçeneklerini kapsar.
## İçindekiler
- [Hızlı Başlangıç](#hızlı-başlangıç)
- [Yerel Geliştirme](#yerel-geliştirme)
- [Production Dağıtımı](#production-dağıtımı)
- [Cloud Dağıtımı](#cloud-dağıtımı)
- [Docker Dağıtımı](#docker-dağıtımı)
- [Güvenlik Hususları](#güvenlik-hususları)
- [İzleme](#izleme)
## Hızlı Başlangıç
### 1. Bağımlılıkları Yükleyin
```bash
# ASGI sunucusu için uvicorn yükleyin
pip install uvicorn
# Veya tüm bağımlılıklarla birlikte yükleyin
pip install -e .
pip install uvicorn
```
### 2. Sunucuyu Çalıştırın
```bash
# Temel başlatma
python run_asgi.py
# Veya doğrudan uvicorn ile
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
```
Sunucu şu adreslerde kullanılabilir olacak:
- MCP Endpoint: `http://localhost:8000/mcp/`
- Sağlık Kontrolü: `http://localhost:8000/health`
- API Durumu: `http://localhost:8000/status`
## Yerel Geliştirme
### Otomatik Yeniden Yükleme ile Geliştirme Sunucusu
```bash
python run_asgi.py --reload --log-level debug
```
### FastAPI Entegrasyonunu Kullanma
Ek REST API endpoint'leri için:
```bash
uvicorn fastapi_app:app --reload
```
Bu şunları sağlar:
- `/docs` adresinde interaktif API dokümantasyonu
- `/api/tools` adresinde araç listesi
- `/api/databases` adresinde veritabanı bilgileri
### Ortam Değişkenleri
`.env.example` dosyasını temel alarak bir `.env` dosyası oluşturun:
```bash
cp .env.example .env
```
Temel değişkenler:
- `HOST`: Sunucu host adresi (varsayılan: 127.0.0.1)
- `PORT`: Sunucu portu (varsayılan: 8000)
- `ALLOWED_ORIGINS`: CORS kökenleri (virgülle ayrılmış)
- `LOG_LEVEL`: Log seviyesi (debug, info, warning, error)
## Production Dağıtımı
### 1. Uvicorn ile Çoklu Worker Kullanımı
```bash
python run_asgi.py --host 0.0.0.0 --port 8000 --workers 4
```
### 2. Gunicorn Kullanımı
```bash
pip install gunicorn
gunicorn asgi_app:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
```
### 3. Nginx Reverse Proxy ile
1. Nginx'i yükleyin
2. Sağlanan `nginx.conf` dosyasını kullanın:
```bash
sudo cp nginx.conf /etc/nginx/sites-available/yargi-mcp
sudo ln -s /etc/nginx/sites-available/yargi-mcp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 4. Systemd Servisi
`/etc/systemd/system/yargi-mcp.service` dosyasını oluşturun:
```ini
[Unit]
Description=Yargı MCP Server
After=network.target
[Service]
Type=exec
User=www-data
WorkingDirectory=/opt/yargi-mcp
Environment="PATH=/opt/yargi-mcp/venv/bin"
ExecStart=/opt/yargi-mcp/venv/bin/uvicorn asgi_app:app --host 0.0.0.0 --port 8000 --workers 4
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Etkinleştirin ve başlatın:
```bash
sudo systemctl enable yargi-mcp
sudo systemctl start yargi-mcp
```
## Cloud Dağıtımı
### Heroku
1. `Procfile` oluşturun:
```
web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT
```
2. Dağıtın:
```bash
heroku create uygulama-isminiz
git push heroku main
```
### Railway
1. `railway.json` ekleyin:
```json
{
"build": {
"builder": "NIXPACKS"
},
"deploy": {
"startCommand": "uvicorn asgi_app:app --host 0.0.0.0 --port $PORT"
}
}
```
2. Railway CLI veya GitHub entegrasyonu ile dağıtın
### Google Cloud Run
1. Container oluşturun:
```bash
docker build -t yargi-mcp .
docker tag yargi-mcp gcr.io/PROJE_ADINIZ/yargi-mcp
docker push gcr.io/PROJE_ADINIZ/yargi-mcp
```
2. Dağıtın:
```bash
gcloud run deploy yargi-mcp \
--image gcr.io/PROJE_ADINIZ/yargi-mcp \
--platform managed \
--region us-central1 \
--allow-unauthenticated
```
### AWS Lambda (Mangum kullanarak)
1. Mangum'u yükleyin:
```bash
pip install mangum
```
2. `lambda_handler.py` oluşturun:
```python
from mangum import Mangum
from asgi_app import app
handler = Mangum(app, lifespan="off")
```
3. AWS SAM veya Serverless Framework kullanarak dağıtın
## Docker Dağıtımı
### Tek Container
```bash
# Oluşturun
docker build -t yargi-mcp .
# Çalıştırın
docker run -p 8000:8000 --env-file .env yargi-mcp
```
### Docker Compose
```bash
# Geliştirme
docker-compose up
# Nginx ile Production
docker-compose --profile production up
# Redis önbellekleme ile
docker-compose --profile with-cache up
```
### Kubernetes
Deployment YAML oluşturun:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: yargi-mcp
spec:
replicas: 3
selector:
matchLabels:
app: yargi-mcp
template:
metadata:
labels:
app: yargi-mcp
spec:
containers:
- name: yargi-mcp
image: yargi-mcp:latest
ports:
- containerPort: 8000
env:
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "8000"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: yargi-mcp-service
spec:
selector:
app: yargi-mcp
ports:
- port: 80
targetPort: 8000
type: LoadBalancer
```
## Güvenlik Hususları
### 1. Kimlik Doğrulama
`API_TOKEN` ortam değişkenini ayarlayarak token kimlik doğrulamasını etkinleştirin:
```bash
export API_TOKEN=gizli-token-degeri
```
Ardından isteklere ekleyin:
```bash
curl -H "Authorization: Bearer gizli-token-degeri" http://localhost:8000/api/tools
```
### 2. HTTPS/SSL
Production için her zaman HTTPS kullanın:
1. SSL sertifikası edinin (Let's Encrypt vb.)
2. Nginx veya cloud sağlayıcıda yapılandırın
3. `ALLOWED_ORIGINS` değerini https:// kullanacak şekilde güncelleyin
### 3. Rate Limiting (Hız Sınırlama)
Sağlanan Nginx yapılandırması rate limiting içerir:
- API endpoint'leri: 10 istek/saniye
- MCP endpoint: 100 istek/saniye
### 4. CORS Yapılandırması
Production için belirli kaynaklara izin verin:
```bash
ALLOWED_ORIGINS=https://app.sizindomain.com,https://www.sizindomain.com
```
## İzleme
### Sağlık Kontrolleri
`/health` endpoint'ini izleyin:
```bash
curl http://localhost:8000/health
```
Yanıt:
```json
{
"status": "healthy",
"timestamp": "2024-12-26T10:00:00",
"uptime_seconds": 3600,
"tools_operational": true
}
```
### Loglama
Ortam değişkeni ile log seviyesini yapılandırın:
```bash
LOG_LEVEL=info # veya debug, warning, error
```
Loglar şuraya yazılır:
- Konsol (stdout)
- `logs/mcp_server.log` dosyası
### Metrikler (Opsiyonel)
OpenTelemetry desteği için:
```bash
pip install opentelemetry-instrumentation-fastapi
```
Ortam değişkenlerini ayarlayın:
```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_SERVICE_NAME=yargi-mcp-server
```
## Sorun Giderme
### Port Zaten Kullanımda
```bash
# 8000 portunu kullanan işlemi bulun
lsof -i :8000
# İşlemi sonlandırın
kill -9 <PID>
```
### İzin Hataları
Dosya izinlerinin doğru olduğundan emin olun:
```bash
chmod +x run_asgi.py
chown -R www-data:www-data /opt/yargi-mcp
```
### Bellek Sorunları
Büyük belge işleme için worker belleğini artırın:
```bash
# systemd servisinde
Environment="PYTHONMALLOC=malloc"
LimitNOFILE=65536
```
### Zaman Aşımı Sorunları
Zaman aşımlarını ayarlayın:
1. Uvicorn: `--timeout-keep-alive 75`
2. Nginx: `proxy_read_timeout 300s;`
3. Cloud sağlayıcılar: Platform özel zaman aşımı ayarlarını kontrol edin
## Performans Ayarlama
### 1. Worker İşlemleri
- Geliştirme: 1 worker
- Production: CPU çekirdeği başına 2-4 worker
### 2. Bağlantı Havuzlama
Sunucu varsayılan olarak httpx ile bağlantı havuzlama kullanır.
### 3. Önbellekleme (Gelecek Geliştirme)
Redis önbellekleme docker-compose ile etkinleştirilebilir:
```bash
docker-compose --profile with-cache up
```
### 4. Veritabanı Zaman Aşımları
`.env` dosyasında veritabanı başına zaman aşımlarını ayarlayın:
```bash
YARGITAY_TIMEOUT=60
DANISTAY_TIMEOUT=60
ANAYASA_TIMEOUT=90
```
## Destek
Sorunlar ve sorular için:
- GitHub Issues: https://github.com/saidsurucu/yargi-mcp/issues
- Dokümantasyon: README.md dosyasına bakın
-46
View File
@@ -1,46 +0,0 @@
# fly.toml app configuration file for yargi-mcp-noauth
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'yargi-mcp-free'
primary_region = 'fra'
[env]
ENABLE_AUTH = "false"
HOST = "0.0.0.0"
PORT = "8000"
LOG_LEVEL = "info"
[build]
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
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'
cpus = 1
[deploy]
strategy = "immediate"
[processes]
app = "python asgi_app.py"
[checks.http_health] # keep MCP /health live
type = "http"
interval = "30s"
timeout = "10s"
path = "/health"
-40
View File
@@ -1,40 +0,0 @@
# fly.toml app configuration file generated for yargi-mcp on 2025-06-29T00:23:47+03:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'yargi-mcp'
primary_region = 'fra'
[env]
ENABLE_AUTH = "true"
HOST = "0.0.0.0"
PORT = "8000"
LOG_LEVEL = "info"
[build]
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
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'
cpus = 1
[checks.http_health] # keep MCP /health live
type = "http"
interval = "30s"
timeout = "10s"
path = "/health"
-28
View File
@@ -1,28 +0,0 @@
"""
MCP Auth Toolkit - OAuth 2.1 + Authorization for Model Context Protocol Servers
Integrated with Clerk Authentication
"""
from .middleware import (
AuthContext,
FastMCPAuthWrapper,
MCPAuthMiddleware,
auth_required,
)
from .oauth import OAuthConfig, OAuthProvider
from .policy import PolicyEngine, ToolPolicy, create_default_policies
from .storage import PersistentStorage
__version__ = "0.1.0"
__all__ = [
"OAuthProvider",
"OAuthConfig",
"AuthContext",
"auth_required",
"create_default_policies",
"MCPAuthMiddleware",
"FastMCPAuthWrapper",
"PolicyEngine",
"ToolPolicy",
"PersistentStorage",
]
-73
View File
@@ -1,73 +0,0 @@
"""
Clerk OAuth configuration for MCP Auth Toolkit
"""
import os
import logging
from .oauth import OAuthConfig
logger = logging.getLogger(__name__)
def create_clerk_oauth_config() -> OAuthConfig:
"""Create OAuth configuration for Clerk integration using SDK"""
# Get Clerk configuration from environment
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
clerk_publishable_key = os.getenv("CLERK_PUBLISHABLE_KEY")
clerk_secret_key = os.getenv("CLERK_SECRET_KEY")
if not clerk_publishable_key or not clerk_secret_key:
raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required")
# For Clerk with custom domains, we use our adapter endpoints
# This allows us to handle the custom domain flow properly
base_url = os.getenv("BASE_URL", "https://yargimcp.com")
config = OAuthConfig(
client_id=clerk_publishable_key,
client_secret=clerk_secret_key,
# Use our adapter endpoints instead of Clerk's direct endpoints
authorization_endpoint=f"{base_url}/authorize",
token_endpoint=f"{base_url}/token",
# Keep Clerk's JWKS for token validation
jwks_uri=f"https://{clerk_domain}/.well-known/jwks.json",
issuer=base_url, # We're the issuer for MCP tokens
scopes=["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"]
)
logger.info(f"Created Clerk OAuth config with adapter endpoints")
logger.info(f"Clerk domain: {clerk_domain}")
logger.debug(f"Authorization endpoint: {config.authorization_endpoint}")
logger.debug(f"Token endpoint: {config.token_endpoint}")
return config
def get_jwt_secret() -> str:
"""Get JWT secret for token signing"""
jwt_secret = os.getenv("JWT_SECRET_KEY")
if not jwt_secret:
raise ValueError("JWT_SECRET_KEY environment variable is required")
return jwt_secret
def create_mcp_server_config():
"""Create complete MCP server configuration for Clerk integration"""
try:
oauth_config = create_clerk_oauth_config()
jwt_secret = get_jwt_secret()
return {
"oauth_config": oauth_config,
"jwt_secret": jwt_secret,
"base_url": os.getenv("BASE_URL", "https://yargi-mcp.fly.dev"),
"auth_enabled": os.getenv("ENABLE_AUTH", "true").lower() == "true"
}
except Exception as e:
logger.error(f"Failed to create MCP server config: {e}")
raise
-315
View File
@@ -1,315 +0,0 @@
"""
MCP server middleware for OAuth authentication and authorization
"""
import functools
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
logger = logging.getLogger(__name__)
try:
from fastmcp import FastMCP
FASTMCP_AVAILABLE = True
except ImportError:
FASTMCP_AVAILABLE = False
FastMCP = None
logger.warning("FastMCP not available, some features will be disabled")
from .oauth import OAuthProvider
from .policy import PolicyEngine
@dataclass
class AuthContext:
"""Authentication context passed to MCP tools"""
user_id: str
scopes: list[str]
claims: dict[str, Any]
token: str
class MCPAuthMiddleware:
"""Authentication middleware for MCP servers"""
def __init__(self, oauth_provider: OAuthProvider, policy_engine: PolicyEngine):
self.oauth_provider = oauth_provider
self.policy_engine = policy_engine
def authenticate_request(self, authorization_header: str) -> AuthContext | None:
"""Extract and validate auth token from request"""
if not authorization_header:
logger.debug("No authorization header provided")
return None
if not authorization_header.startswith("Bearer "):
logger.debug("Authorization header does not start with 'Bearer '")
return None
token = authorization_header[7:] # Remove 'Bearer ' prefix
token_info = self.oauth_provider.introspect_token(token)
if not token_info.get("active"):
logger.warning("Token is not active")
return None
logger.debug(f"Authenticated user: {token_info.get('sub', 'unknown')}")
return AuthContext(
user_id=token_info.get("sub", "unknown"),
scopes=token_info.get("mcp_tool_scopes", []),
claims=token_info,
token=token,
)
def authorize_tool_call(
self, tool_name: str, auth_context: AuthContext
) -> tuple[bool, str | None]:
"""Check if user can call the specified tool"""
return self.policy_engine.authorize_tool_call(
tool_name=tool_name,
user_scopes=auth_context.scopes,
user_claims=auth_context.claims,
)
def auth_required(
oauth_provider: OAuthProvider,
policy_engine: PolicyEngine,
tool_name: str | None = None,
):
"""
Decorator to require authentication for MCP tool functions
Usage:
@auth_required(oauth_provider, policy_engine, "search_yargitay")
def my_tool_function(context: AuthContext, ...):
pass
"""
def decorator(func: Callable) -> Callable:
middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
@functools.wraps(func)
async def wrapper(*args, **kwargs):
# Extract authorization header from kwargs
auth_header = kwargs.pop("authorization", None)
# Also check in args if it's a Request object
if not auth_header and args:
for arg in args:
if hasattr(arg, 'headers'):
auth_header = arg.headers.get("Authorization")
break
if not auth_header:
logger.warning(f"No authorization header for tool '{tool_name or func.__name__}'")
raise PermissionError("Authorization header required")
auth_context = middleware.authenticate_request(auth_header)
if not auth_context:
logger.warning(f"Authentication failed for tool '{tool_name or func.__name__}'")
raise PermissionError("Invalid or expired token")
actual_tool_name = tool_name or func.__name__
authorized, reason = middleware.authorize_tool_call(
actual_tool_name, auth_context
)
if not authorized:
logger.warning(f"Authorization failed for tool '{actual_tool_name}': {reason}")
raise PermissionError(f"Access denied: {reason}")
# Add auth context to function call
return await func(auth_context, *args, **kwargs)
return wrapper
return decorator
class FastMCPAuthWrapper:
"""Wrapper for FastMCP servers to add authentication"""
def __init__(
self,
mcp_server: "FastMCP",
oauth_provider: OAuthProvider,
policy_engine: PolicyEngine,
):
if not FASTMCP_AVAILABLE:
raise ImportError("FastMCP is required for FastMCPAuthWrapper")
self.mcp_server = mcp_server
self.middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
self.oauth_provider = oauth_provider
logger.info("Initializing FastMCP authentication wrapper")
self._wrap_tools()
def _wrap_tools(self):
"""Wrap all existing tools with auth middleware"""
# Try different FastMCP tool storage locations
tool_registry = None
if hasattr(self.mcp_server, '_tools'):
tool_registry = self.mcp_server._tools
elif hasattr(self.mcp_server, 'tools'):
tool_registry = self.mcp_server.tools
elif hasattr(self.mcp_server, '_tool_registry'):
tool_registry = self.mcp_server._tool_registry
elif hasattr(self.mcp_server, '_handlers') and hasattr(self.mcp_server._handlers, 'tools'):
tool_registry = self.mcp_server._handlers.tools
if not tool_registry:
logger.warning("FastMCP server tool registry not found, tools will not be automatically wrapped")
logger.debug(f"Available server attributes: {dir(self.mcp_server)}")
return
logger.debug(f"Found tool registry with {len(tool_registry)} tools")
original_tools = dict(tool_registry)
wrapped_count = 0
for tool_name, tool_func in original_tools.items():
try:
wrapped_func = self._create_auth_wrapper(tool_name, tool_func)
tool_registry[tool_name] = wrapped_func
wrapped_count += 1
logger.debug(f"Wrapped tool: {tool_name}")
except Exception as e:
logger.error(f"Failed to wrap tool {tool_name}: {e}")
logger.info(f"Successfully wrapped {wrapped_count} tools with authentication")
def _create_auth_wrapper(self, tool_name: str, original_func: Callable) -> Callable:
"""Create auth wrapper for a specific tool"""
@functools.wraps(original_func)
async def auth_wrapper(*args, **kwargs):
# Extract authorization from various sources
auth_header = None
# Check kwargs first
auth_header = kwargs.pop("authorization", None)
# Check if first argument is a Request object
if not auth_header and args:
first_arg = args[0]
if hasattr(first_arg, 'headers'):
auth_header = first_arg.headers.get("Authorization")
if not auth_header:
logger.warning(f"No authorization header for tool '{tool_name}'")
raise PermissionError("Authorization required")
auth_context = self.middleware.authenticate_request(auth_header)
if not auth_context:
logger.warning(f"Authentication failed for tool '{tool_name}'")
raise PermissionError("Invalid token")
authorized, reason = self.middleware.authorize_tool_call(
tool_name, auth_context
)
if not authorized:
logger.warning(f"Authorization failed for tool '{tool_name}': {reason}")
raise PermissionError(f"Access denied: {reason}")
# Add auth context to kwargs
kwargs["auth_context"] = auth_context
logger.debug(f"Calling tool '{tool_name}' for user {auth_context.user_id}")
return await original_func(*args, **kwargs)
return auth_wrapper
def add_oauth_endpoints(self):
"""Add OAuth endpoints to the MCP server"""
@self.mcp_server.tool(
description="Initiate OAuth 2.1 authorization flow with PKCE",
annotations={"readOnlyHint": True, "idempotentHint": False}
)
async def oauth_authorize(redirect_uri: str, scopes: Optional[str] = None):
"""OAuth authorization endpoint"""
scope_list = scopes.split(" ") if scopes else None
auth_url, pkce = self.oauth_provider.generate_authorization_url(
redirect_uri=redirect_uri, scopes=scope_list
)
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
return {
"authorization_url": auth_url,
"code_verifier": pkce.verifier, # For PKCE flow
"code_challenge": pkce.challenge,
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
}
@self.mcp_server.tool(
description="Exchange OAuth authorization code for access token",
annotations={"readOnlyHint": False, "idempotentHint": False}
)
async def oauth_token(
code: str,
state: str,
redirect_uri: str
):
"""OAuth token exchange endpoint"""
try:
result = await self.oauth_provider.exchange_code_for_token(
code=code, state=state, redirect_uri=redirect_uri
)
logger.info("Successfully exchanged authorization code for token")
return result
except Exception as e:
logger.error(f"Token exchange failed: {e}")
raise
@self.mcp_server.tool(
description="Validate and introspect OAuth access token",
annotations={"readOnlyHint": True, "idempotentHint": True}
)
async def oauth_introspect(token: str):
"""Token introspection endpoint"""
result = self.oauth_provider.introspect_token(token)
logger.debug(f"Token introspection: active={result.get('active', False)}")
return result
@self.mcp_server.tool(
description="Revoke OAuth access token",
annotations={"readOnlyHint": False, "idempotentHint": False}
)
async def oauth_revoke(token: str):
"""Token revocation endpoint"""
success = self.oauth_provider.revoke_token(token)
logger.info(f"Token revocation: success={success}")
return {"revoked": success}
@self.mcp_server.tool(
description="Get list of tools available to authenticated user",
annotations={"readOnlyHint": True, "idempotentHint": True}
)
async def oauth_user_tools(authorization: str):
"""Get user's allowed tools based on scopes"""
auth_context = self.middleware.authenticate_request(authorization)
if not auth_context:
raise PermissionError("Invalid token")
allowed_patterns = self.middleware.policy_engine.get_allowed_tools(auth_context.scopes)
return {
"user_id": auth_context.user_id,
"scopes": auth_context.scopes,
"allowed_tool_patterns": allowed_patterns,
"message": "Use these patterns to determine which tools you can access"
}
logger.info("Added OAuth endpoints: oauth_authorize, oauth_token, oauth_introspect, oauth_revoke, oauth_user_tools")
-304
View File
@@ -1,304 +0,0 @@
"""
OAuth 2.1 + PKCE implementation for MCP servers with Clerk integration
"""
import base64
import hashlib
import secrets
import time
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Optional
from urllib.parse import urlencode
import httpx
import jwt
from jwt.exceptions import PyJWTError, InvalidTokenError
from .storage import PersistentStorage
# Try to import Clerk SDK
try:
from clerk_backend_api import Clerk
CLERK_AVAILABLE = True
except ImportError:
CLERK_AVAILABLE = False
Clerk = None
logger = logging.getLogger(__name__)
@dataclass
class OAuthConfig:
"""OAuth provider configuration for Clerk"""
client_id: str
client_secret: str
authorization_endpoint: str
token_endpoint: str
jwks_uri: str | None = None
issuer: str = "mcp-auth"
scopes: list[str] = None
def __post_init__(self):
if self.scopes is None:
self.scopes = ["mcp:tools:read", "mcp:tools:write"]
class PKCEChallenge:
"""PKCE challenge/verifier pair for OAuth 2.1"""
def __init__(self):
self.verifier = (
base64.urlsafe_b64encode(secrets.token_bytes(32))
.decode("utf-8")
.rstrip("=")
)
challenge_bytes = hashlib.sha256(self.verifier.encode("utf-8")).digest()
self.challenge = (
base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=")
)
class OAuthProvider:
"""OAuth 2.1 provider with PKCE support and Clerk integration"""
def __init__(self, config: OAuthConfig, jwt_secret: str):
self.config = config
self.jwt_secret = jwt_secret
# Use persistent storage instead of memory
self.storage = PersistentStorage()
# Initialize Clerk SDK if available
self.clerk = None
if CLERK_AVAILABLE and config.client_secret:
try:
self.clerk = Clerk(bearer_auth=config.client_secret)
logger.info("Clerk SDK initialized successfully")
except Exception as e:
logger.warning(f"Failed to initialize Clerk SDK: {e}")
logger.info("OAuth provider initialized with persistent storage")
def generate_authorization_url(
self,
redirect_uri: str,
state: str | None = None,
scopes: list[str] | None = None,
) -> tuple[str, PKCEChallenge]:
"""Generate OAuth authorization URL with PKCE for Clerk"""
pkce = PKCEChallenge()
session_id = secrets.token_urlsafe(32)
if state is None:
state = secrets.token_urlsafe(16)
if scopes is None:
scopes = self.config.scopes
# Store session data with expiration
session_data = {
"pkce_verifier": pkce.verifier,
"state": state,
"redirect_uri": redirect_uri,
"scopes": scopes,
"created_at": time.time(),
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
}
self.storage.set_session(session_id, session_data)
# Build Clerk OAuth URL
# Check if this is a custom domain (sign-in endpoint)
if self.config.authorization_endpoint.endswith('/sign-in'):
# For custom domains, Clerk expects redirect_url parameter
params = {
"redirect_url": redirect_uri,
"state": f"{state}:{session_id}",
}
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
else:
# Standard OAuth flow with PKCE
params = {
"response_type": "code",
"client_id": self.config.client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"state": f"{state}:{session_id}", # Combine state with session ID
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
}
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
logger.info(f"Generated OAuth URL with session {session_id[:8]}...")
logger.debug(f"Auth URL: {auth_url}")
return auth_url, pkce
async def exchange_code_for_token(
self, code: str, state: str, redirect_uri: str
) -> dict[str, Any]:
"""Exchange authorization code for access token with Clerk"""
try:
original_state, session_id = state.split(":", 1)
except ValueError as e:
logger.error(f"Invalid state format: {state}")
raise ValueError("Invalid state format") from e
session = self.storage.get_session(session_id)
if not session:
logger.error(f"Session {session_id} not found")
raise ValueError("Invalid session")
# Check session expiration
if datetime.utcnow().timestamp() > session.get("expires_at", 0):
self.storage.delete_session(session_id)
logger.error(f"Session {session_id} expired")
raise ValueError("Session expired")
if session["state"] != original_state:
logger.error(f"State mismatch: expected {session['state']}, got {original_state}")
raise ValueError("State mismatch")
if session["redirect_uri"] != redirect_uri:
logger.error(f"Redirect URI mismatch: expected {session['redirect_uri']}, got {redirect_uri}")
raise ValueError("Redirect URI mismatch")
# Prepare token exchange request for Clerk
token_data = {
"grant_type": "authorization_code",
"client_id": self.config.client_id,
"client_secret": self.config.client_secret,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": session["pkce_verifier"],
}
logger.info(f"Exchanging code with Clerk for session {session_id[:8]}...")
async with httpx.AsyncClient() as client:
response = await client.post(
self.config.token_endpoint,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"Clerk token exchange failed: {response.status_code} - {response.text}")
raise ValueError(f"Token exchange failed: {response.text}")
token_response = response.json()
logger.info("Successfully exchanged code for Clerk token")
# Create MCP-scoped JWT token
access_token = self._create_mcp_token(
session["scopes"], token_response.get("access_token"), session_id
)
# Store token for introspection
token_id = secrets.token_urlsafe(16)
token_data = {
"access_token": access_token,
"scopes": session["scopes"],
"created_at": time.time(),
"expires_at": (datetime.utcnow() + timedelta(hours=1)).timestamp(),
"session_id": session_id,
"clerk_token": token_response.get("access_token"),
}
self.storage.set_token(token_id, token_data)
# Clean up session
self.storage.delete_session(session_id)
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": 3600,
"scope": " ".join(session["scopes"]),
}
def validate_pkce(self, code_verifier: str, code_challenge: str) -> bool:
"""Validate PKCE code challenge (RFC 7636)"""
# S256 method
verifier_hash = hashlib.sha256(code_verifier.encode()).digest()
expected_challenge = base64.urlsafe_b64encode(verifier_hash).decode().rstrip('=')
return expected_challenge == code_challenge
def _create_mcp_token(
self, scopes: list[str], upstream_token: str, session_id: str
) -> str:
"""Create MCP-scoped JWT token with Clerk token embedded"""
now = int(time.time())
payload = {
"iss": self.config.issuer,
"sub": session_id,
"aud": "mcp-server",
"iat": now,
"exp": now + 3600, # 1 hour expiration
"mcp_tool_scopes": scopes,
"upstream_token": upstream_token,
"clerk_integration": True,
}
return jwt.encode(payload, self.jwt_secret, algorithm="HS256")
def introspect_token(self, token: str) -> dict[str, Any]:
"""Introspect and validate MCP token"""
try:
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
# Check if token is expired
if payload.get("exp", 0) < time.time():
return {"active": False, "error": "token_expired"}
return {
"active": True,
"sub": payload.get("sub"),
"aud": payload.get("aud"),
"iss": payload.get("iss"),
"exp": payload.get("exp"),
"iat": payload.get("iat"),
"mcp_tool_scopes": payload.get("mcp_tool_scopes", []),
"upstream_token": payload.get("upstream_token"),
"clerk_integration": payload.get("clerk_integration", False),
}
except PyJWTError as e:
logger.warning(f"Token validation failed: {e}")
return {"active": False, "error": "invalid_token"}
def revoke_token(self, token: str) -> bool:
"""Revoke a token"""
try:
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
session_id = payload.get("sub")
# Remove all tokens associated with this session
all_tokens = self.storage.get_tokens()
tokens_to_remove = [
token_id
for token_id, token_data in all_tokens.items()
if token_data.get("session_id") == session_id
]
for token_id in tokens_to_remove:
self.storage.delete_token(token_id)
logger.info(f"Revoked {len(tokens_to_remove)} tokens for session {session_id}")
return True
except InvalidTokenError as e:
logger.warning(f"Token revocation failed: {e}")
return False
def cleanup_expired_sessions(self):
"""Clean up expired sessions and tokens"""
# This is now handled automatically by persistent storage
self.storage.cleanup_expired_sessions()
logger.debug("Cleanup completed via persistent storage")
-201
View File
@@ -1,201 +0,0 @@
"""
Authorization policy engine for MCP tools
"""
import re
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
class PolicyAction(Enum):
ALLOW = "allow"
DENY = "deny"
@dataclass
class ToolPolicy:
"""Policy rule for MCP tool access"""
tool_pattern: str # regex pattern for tool names
required_scopes: list[str]
action: PolicyAction = PolicyAction.ALLOW
conditions: dict[str, Any] | None = None
def matches_tool(self, tool_name: str) -> bool:
"""Check if the policy applies to given tool"""
return bool(re.match(self.tool_pattern, tool_name))
def evaluate_scopes(self, user_scopes: list[str]) -> bool:
"""Check if user has required scopes"""
return all(scope in user_scopes for scope in self.required_scopes)
class PolicyEngine:
"""Authorization policy engine for Turkish legal database tools"""
def __init__(self):
self.policies: list[ToolPolicy] = []
self.default_action = PolicyAction.DENY
def add_policy(self, policy: ToolPolicy):
"""Add a policy rule"""
self.policies.append(policy)
logger.debug(f"Added policy: {policy.tool_pattern} -> {policy.required_scopes}")
def add_tool_scope_policy(
self,
tool_pattern: str,
required_scopes: str | list[str],
action: PolicyAction = PolicyAction.ALLOW,
):
"""Convenience method to add tool-scope policy"""
if isinstance(required_scopes, str):
required_scopes = [required_scopes]
policy = ToolPolicy(
tool_pattern=tool_pattern, required_scopes=required_scopes, action=action
)
self.add_policy(policy)
def authorize_tool_call(
self,
tool_name: str,
user_scopes: list[str],
user_claims: dict[str, Any] | None = None,
) -> tuple[bool, str | None]:
"""
Authorize a tool call
Returns:
(authorized: bool, reason: Optional[str])
"""
logger.debug(f"Authorizing tool '{tool_name}' for user with scopes: {user_scopes}")
matching_policies = [
policy for policy in self.policies if policy.matches_tool(tool_name)
]
if not matching_policies:
if self.default_action == PolicyAction.ALLOW:
logger.debug(f"No policies found for '{tool_name}', allowing by default")
return True, None
else:
logger.warning(f"No policies found for '{tool_name}', denying by default")
return False, f"No policy found for tool '{tool_name}', default deny"
# Check for explicit deny policies first
for policy in matching_policies:
if policy.action == PolicyAction.DENY:
if policy.evaluate_scopes(user_scopes):
logger.warning(f"Explicit deny policy matched for '{tool_name}'")
return False, f"Explicit deny policy for tool '{tool_name}'"
# Check allow policies
allow_policies = [
p for p in matching_policies if p.action == PolicyAction.ALLOW
]
if not allow_policies:
logger.warning(f"No allow policies found for '{tool_name}'")
return False, f"No allow policies found for tool '{tool_name}'"
for policy in allow_policies:
if policy.evaluate_scopes(user_scopes):
if self._evaluate_conditions(policy.conditions, user_claims):
logger.debug(f"Authorization granted for '{tool_name}'")
return True, None
logger.warning(f"Insufficient scopes for '{tool_name}'. Required: {[p.required_scopes for p in allow_policies]}, User has: {user_scopes}")
return False, f"Insufficient scopes for tool '{tool_name}'"
def _evaluate_conditions(
self,
conditions: dict[str, Any] | None,
user_claims: dict[str, Any] | None,
) -> bool:
"""Evaluate additional policy conditions"""
if not conditions:
return True
if not user_claims:
logger.debug("No user claims provided, conditions evaluation failed")
return False
for key, expected_value in conditions.items():
user_value = user_claims.get(key)
if isinstance(expected_value, list):
if user_value not in expected_value:
logger.debug(f"Condition failed: {key} = {user_value} not in {expected_value}")
return False
elif user_value != expected_value:
logger.debug(f"Condition failed: {key} = {user_value} != {expected_value}")
return False
return True
def get_allowed_tools(self, user_scopes: list[str]) -> list[str]:
"""Get list of tool patterns user is allowed to call"""
allowed_tools = []
for policy in self.policies:
if policy.action == PolicyAction.ALLOW and policy.evaluate_scopes(
user_scopes
):
allowed_tools.append(policy.tool_pattern)
return allowed_tools
def create_turkish_legal_policies() -> PolicyEngine:
"""Create policy set for Turkish legal database MCP server"""
engine = PolicyEngine()
# Administrative tools (full access)
engine.add_tool_scope_policy(".*", ["mcp:tools:admin"])
# Search tools - require read access
engine.add_tool_scope_policy("search.*", ["mcp:tools:read"])
# Fetch/get document tools - require read access
engine.add_tool_scope_policy("get_.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("fetch.*", ["mcp:tools:read"])
# Specific Turkish legal database tools
engine.add_tool_scope_policy("search_yargitay.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_danistay.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_anayasa.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_rekabet.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_kik.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_emsal.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_uyusmazlik.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_sayistay.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_.*_bedesten", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_yerel_hukuk.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_istinaf_hukuk.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("search_kyb.*", ["mcp:tools:read"])
# Document retrieval tools
engine.add_tool_scope_policy("get_.*_document.*", ["mcp:tools:read"])
engine.add_tool_scope_policy("get_.*_markdown", ["mcp:tools:read"])
# Write operations (if any future tools need them)
engine.add_tool_scope_policy("create_.*", ["mcp:tools:write"])
engine.add_tool_scope_policy("update_.*", ["mcp:tools:write"])
engine.add_tool_scope_policy("delete_.*", ["mcp:tools:write"])
logger.info("Created Turkish legal database policy engine")
return engine
def create_default_policies() -> PolicyEngine:
"""Create a default policy set for MCP servers (backwards compatibility)"""
return create_turkish_legal_policies()
-112
View File
@@ -1,112 +0,0 @@
"""
Persistent storage for OAuth sessions and tokens
"""
import json
import os
import tempfile
import logging
from datetime import datetime
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class PersistentStorage:
"""File-based persistent storage for OAuth data"""
def __init__(self, storage_dir: str = None):
if storage_dir is None:
# Use system temp directory or environment variable
storage_dir = os.environ.get('TEMP', tempfile.gettempdir())
self.storage_dir = os.path.join(storage_dir, 'mcp_oauth_storage')
os.makedirs(self.storage_dir, exist_ok=True)
self.sessions_file = os.path.join(self.storage_dir, 'oauth_sessions.json')
self.tokens_file = os.path.join(self.storage_dir, 'oauth_tokens.json')
logger.info(f"Persistent OAuth storage initialized at: {self.storage_dir}")
def _load_json(self, filepath: str) -> Dict:
"""Load JSON data from file"""
try:
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading {filepath}: {e}")
return {}
def _save_json(self, filepath: str, data: Dict):
"""Save JSON data to file"""
try:
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, default=str)
except Exception as e:
logger.error(f"Error saving {filepath}: {e}")
def get_sessions(self) -> Dict[str, Dict[str, Any]]:
"""Get all OAuth sessions"""
data = self._load_json(self.sessions_file)
# Clean expired sessions
now = datetime.utcnow().timestamp()
valid_sessions = {k: v for k, v in data.items()
if v.get('expires_at', 0) > now}
if len(valid_sessions) != len(data):
self._save_json(self.sessions_file, valid_sessions)
return valid_sessions
def set_session(self, session_id: str, data: Dict[str, Any]):
"""Set OAuth session data"""
sessions = self.get_sessions()
sessions[session_id] = data
self._save_json(self.sessions_file, sessions)
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get specific OAuth session data"""
sessions = self.get_sessions()
return sessions.get(session_id)
def delete_session(self, session_id: str):
"""Delete OAuth session"""
sessions = self.get_sessions()
if session_id in sessions:
del sessions[session_id]
self._save_json(self.sessions_file, sessions)
def get_tokens(self) -> Dict[str, Dict[str, Any]]:
"""Get all OAuth tokens"""
data = self._load_json(self.tokens_file)
# Clean expired tokens
now = datetime.utcnow().timestamp()
valid_tokens = {k: v for k, v in data.items()
if v.get('expires_at', 0) > now}
if len(valid_tokens) != len(data):
self._save_json(self.tokens_file, valid_tokens)
return valid_tokens
def set_token(self, token_id: str, token_data: Dict[str, Any]):
"""Set OAuth token data"""
tokens = self.get_tokens()
tokens[token_id] = token_data
self._save_json(self.tokens_file, tokens)
def get_token(self, token_id: str) -> Optional[Dict[str, Any]]:
"""Get specific OAuth token data"""
tokens = self.get_tokens()
return tokens.get(token_id)
def delete_token(self, token_id: str):
"""Delete OAuth token"""
tokens = self.get_tokens()
if token_id in tokens:
del tokens[token_id]
self._save_json(self.tokens_file, tokens)
def cleanup_expired_sessions(self):
"""Clean up expired sessions and tokens"""
# This is handled automatically in get_sessions() and get_tokens()
sessions = self.get_sessions()
tokens = self.get_tokens()
logger.debug(f"Cleanup: {len(sessions)} active sessions, {len(tokens)} active tokens")
-193
View File
@@ -1,193 +0,0 @@
"""
Factory for creating FastMCP app with MCP Auth Toolkit integration
"""
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
try:
from fastmcp import FastMCP
FASTMCP_AVAILABLE = True
except ImportError:
FASTMCP_AVAILABLE = False
FastMCP = None
from mcp_auth import (
OAuthProvider,
PolicyEngine,
FastMCPAuthWrapper,
create_default_policies
)
from mcp_auth.clerk_config import create_mcp_server_config
def create_auth_enabled_app(app_name: str = "Yargı MCP Server") -> FastMCP:
"""Create FastMCP app with authentication enabled"""
if not FASTMCP_AVAILABLE:
raise ImportError("FastMCP is required for authenticated MCP server")
logger.info("Creating FastMCP app with MCP Auth Toolkit integration")
# Create base FastMCP app
app = FastMCP(app_name)
# Check if authentication is enabled
auth_enabled = os.getenv("ENABLE_AUTH", "true").lower() == "true"
if not auth_enabled:
logger.info("Authentication disabled, returning basic FastMCP app")
return app
try:
# Get configuration
logger.info("Getting MCP server configuration...")
config = create_mcp_server_config()
logger.info("Configuration loaded successfully")
# Create OAuth provider with Clerk config
logger.info("Creating OAuth provider...")
oauth_provider = OAuthProvider(
config=config["oauth_config"],
jwt_secret=config["jwt_secret"]
)
logger.info("OAuth provider created successfully")
# Create policy engine for Turkish legal database
policy_engine = create_default_policies()
# Store auth components for later wrapping (after tools are defined)
app._oauth_provider = oauth_provider
app._policy_engine = policy_engine
app._auth_config = config
# Add OAuth endpoints immediately
@app.tool(
description="Initiate OAuth 2.1 authorization flow with PKCE",
annotations={"readOnlyHint": True, "idempotentHint": False}
)
async def oauth_authorize(redirect_uri: str, scopes: str = None):
"""OAuth authorization endpoint"""
scope_list = scopes.split(" ") if scopes else ["mcp:tools:read", "mcp:tools:write"]
auth_url, pkce = oauth_provider.generate_authorization_url(
redirect_uri=redirect_uri, scopes=scope_list
)
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
return {
"authorization_url": auth_url,
"code_verifier": pkce.verifier,
"code_challenge": pkce.challenge,
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
}
@app.tool(
description="Exchange OAuth authorization code for access token",
annotations={"readOnlyHint": False, "idempotentHint": False}
)
async def oauth_token(code: str, state: str, redirect_uri: str):
"""OAuth token exchange endpoint"""
try:
result = await oauth_provider.exchange_code_for_token(
code=code, state=state, redirect_uri=redirect_uri
)
logger.info("Successfully exchanged authorization code for token")
return result
except Exception as e:
logger.error(f"Token exchange failed: {e}")
raise
@app.tool(
description="Validate and introspect OAuth access token",
annotations={"readOnlyHint": True, "idempotentHint": True}
)
async def oauth_introspect(token: str):
"""Token introspection endpoint"""
result = oauth_provider.introspect_token(token)
logger.debug(f"Token introspection: active={result.get('active', False)}")
return result
@app.tool(
description="Revoke OAuth access token",
annotations={"readOnlyHint": False, "idempotentHint": False}
)
async def oauth_revoke(token: str):
"""Token revocation endpoint"""
success = oauth_provider.revoke_token(token)
logger.info(f"Token revocation: success={success}")
return {"revoked": success}
logger.info("Successfully created authenticated FastMCP app")
except Exception as e:
logger.error(f"Failed to create authenticated app: {e}")
logger.info("Falling back to non-authenticated FastMCP app")
# Return basic app if auth setup fails
return app
return app
def create_app() -> FastMCP:
"""Create FastMCP app (backwards compatible with mcp_factory.py)"""
return create_auth_enabled_app()
def get_auth_wrapper(app: FastMCP) -> Optional[FastMCPAuthWrapper]:
"""Get auth wrapper from app if available"""
return getattr(app, '_auth_wrapper', None)
def get_oauth_provider(app: FastMCP) -> Optional[OAuthProvider]:
"""Get OAuth provider from app if available"""
return getattr(app, '_oauth_provider', None)
def get_policy_engine(app: FastMCP) -> Optional[PolicyEngine]:
"""Get policy engine from app if available"""
return getattr(app, '_policy_engine', None)
def is_auth_enabled(app: FastMCP) -> bool:
"""Check if authentication is enabled for the app"""
return hasattr(app, '_oauth_provider') or hasattr(app, '_auth_wrapper')
def enable_tool_authentication(app: FastMCP):
"""Enable authentication on all existing tools (call after tools are defined)"""
if not is_auth_enabled(app):
logger.debug("Authentication not enabled, skipping tool authentication")
return
oauth_provider = get_oauth_provider(app)
policy_engine = get_policy_engine(app)
if not oauth_provider or not policy_engine:
logger.warning("OAuth provider or policy engine not available")
return
try:
# Create auth wrapper and wrap tools
auth_wrapper = FastMCPAuthWrapper(
mcp_server=app,
oauth_provider=oauth_provider,
policy_engine=policy_engine
)
# Store wrapper for reference
app._auth_wrapper = auth_wrapper
logger.info("Tool authentication enabled successfully")
except Exception as e:
logger.error(f"Failed to enable tool authentication: {e}")
def cleanup_auth_sessions(app: FastMCP):
"""Clean up expired auth sessions and tokens"""
oauth_provider = get_oauth_provider(app)
if oauth_provider:
oauth_provider.cleanup_expired_sessions()
logger.debug("Cleaned up expired OAuth sessions")
-383
View File
@@ -1,383 +0,0 @@
"""
HTTP adapter for MCP Auth Toolkit OAuth endpoints
Exposes MCP OAuth tools as HTTP endpoints for Claude.ai integration
"""
import os
import logging
import secrets
import time
from typing import Optional
from urllib.parse import urlencode, quote
from datetime import datetime, timedelta
from fastapi import APIRouter, Request, Query, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
# Try to import Clerk SDK
try:
from clerk_backend_api import Clerk
CLERK_AVAILABLE = True
except ImportError as e:
CLERK_AVAILABLE = False
Clerk = None
logger = logging.getLogger(__name__)
router = APIRouter()
# OAuth configuration
BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
@router.get("/.well-known/oauth-authorization-server")
async def get_oauth_metadata():
"""OAuth 2.0 Authorization Server Metadata (RFC 8414)"""
return JSONResponse({
"issuer": BASE_URL,
"authorization_endpoint": f"{BASE_URL}/authorize",
"token_endpoint": f"{BASE_URL}/token",
"registration_endpoint": f"{BASE_URL}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"],
"service_documentation": f"{BASE_URL}/mcp/"
})
@router.get("/.well-known/oauth-protected-resource")
async def get_protected_resource_metadata():
"""OAuth Protected Resource Metadata (RFC 9728)"""
return JSONResponse({
"resource": BASE_URL,
"authorization_servers": [BASE_URL],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp:tools:read", "mcp:tools:write"],
"resource_documentation": f"{BASE_URL}/docs"
})
@router.get("/authorize")
async def authorize_endpoint(
response_type: str = Query(...),
client_id: str = Query(...),
redirect_uri: str = Query(...),
code_challenge: str = Query(...),
code_challenge_method: str = Query("S256"),
state: Optional[str] = Query(None),
scope: Optional[str] = Query(None)
):
"""OAuth 2.1 Authorization Endpoint - Uses Clerk SDK for custom domains"""
logger.info(f"OAuth authorize request - client_id: {client_id}, redirect_uri: {redirect_uri}")
if not CLERK_AVAILABLE:
logger.error("Clerk SDK not available")
raise HTTPException(status_code=500, detail="Clerk SDK not available")
# Store OAuth session for later validation
try:
from mcp_server_main import app as mcp_app
from mcp_auth_factory import get_oauth_provider
oauth_provider = get_oauth_provider(mcp_app)
if not oauth_provider:
raise HTTPException(status_code=500, detail="OAuth provider not configured")
# Generate session and store PKCE
session_id = secrets.token_urlsafe(32)
if state is None:
state = secrets.token_urlsafe(16)
# Create PKCE challenge
from mcp_auth.oauth import PKCEChallenge
pkce = PKCEChallenge()
# Store session data
session_data = {
"pkce_verifier": pkce.verifier,
"pkce_challenge": code_challenge, # Store the client's challenge
"state": state,
"redirect_uri": redirect_uri,
"client_id": client_id,
"scopes": scope.split(" ") if scope else ["mcp:tools:read", "mcp:tools:write"],
"created_at": time.time(),
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
}
oauth_provider.storage.set_session(session_id, session_data)
# For Clerk with custom domains, we need to use their hosted sign-in page
# We'll pass our callback URL and session info in the state
callback_url = f"{BASE_URL}/auth/callback"
# Encode session info in state for retrieval after Clerk auth
combined_state = f"{state}:{session_id}"
# Use Clerk's sign-in URL with proper parameters
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
sign_in_params = {
"redirect_url": f"{callback_url}?state={quote(combined_state)}",
}
sign_in_url = f"https://{clerk_domain}/sign-in?{urlencode(sign_in_params)}"
logger.info(f"Redirecting to Clerk sign-in: {sign_in_url}")
return RedirectResponse(url=sign_in_url)
except Exception as e:
logger.exception(f"Authorization failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/auth/callback")
async def oauth_callback(
request: Request,
state: Optional[str] = Query(None),
clerk_token: Optional[str] = Query(None)
):
"""Handle OAuth callback from Clerk - supports both JWT token and cookie auth"""
logger.info(f"OAuth callback received - state: {state}")
logger.info(f"Query params: {dict(request.query_params)}")
logger.info(f"Cookies: {dict(request.cookies)}")
logger.info(f"Clerk JWT token provided: {bool(clerk_token)}")
# Support both JWT token (for cross-domain) and cookie auth (for subdomain)
try:
if not state:
logger.error("No state parameter provided")
return JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "Missing state parameter"}
)
# Parse state to get original state and session ID
try:
if ":" in state:
original_state, session_id = state.rsplit(":", 1)
else:
original_state = state
session_id = state # Fallback
except ValueError:
logger.error(f"Invalid state format: {state}")
return JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "Invalid state format"}
)
# Get OAuth provider
from mcp_server_main import app as mcp_app
from mcp_auth_factory import get_oauth_provider
oauth_provider = get_oauth_provider(mcp_app)
if not oauth_provider:
raise HTTPException(status_code=500, detail="OAuth provider not configured")
# Get stored session
oauth_session = oauth_provider.storage.get_session(session_id)
if not oauth_session:
logger.error(f"OAuth session not found for ID: {session_id}")
return JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "OAuth session expired or not found"}
)
# Check if we have a JWT token (for cross-domain auth)
user_authenticated = False
auth_method = "none"
if clerk_token:
logger.info("Attempting JWT token validation")
try:
# Validate JWT token with Clerk
from clerk_backend_api import Clerk
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
# 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") or decoded_token.get("session_id")
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
else:
user_id = None
if user_id:
logger.info(f"JWT token validation successful - user_id: {user_id}")
user_authenticated = True
auth_method = "jwt_token"
# Store user info in session for token exchange
oauth_session["user_id"] = user_id
oauth_session["auth_method"] = "jwt_token"
else:
logger.error("JWT token validation failed - no user_id in claims")
except Exception as e:
logger.error(f"JWT token validation failed: {str(e)}")
# Fall through to cookie validation
# If no JWT token or validation failed, check cookies
if not user_authenticated:
logger.info("Checking for Clerk session cookies")
# Check for Clerk session cookies (for subdomain auth)
clerk_session_cookie = request.cookies.get("__session")
if clerk_session_cookie:
logger.info("Found Clerk session cookie, assuming authenticated")
user_authenticated = True
auth_method = "cookie"
oauth_session["auth_method"] = "cookie"
else:
logger.info("No Clerk session cookie found")
# For custom domains, we'll also trust that Clerk redirected here
if not user_authenticated:
logger.info("Trusting Clerk redirect for custom domain flow")
user_authenticated = True
auth_method = "trusted_redirect"
oauth_session["auth_method"] = "trusted_redirect"
logger.info(f"User authenticated: {user_authenticated}, method: {auth_method}")
# Generate simple authorization code for custom domain flow
auth_code = f"clerk_custom_{session_id}_{int(time.time())}"
# Store the code mapping for token exchange
code_data = {
"session_id": session_id,
"clerk_authenticated": user_authenticated,
"auth_method": auth_method,
"custom_domain_flow": True,
"created_at": time.time(),
"expires_at": (datetime.utcnow() + timedelta(minutes=5)).timestamp(),
}
if "user_id" in oauth_session:
code_data["user_id"] = oauth_session["user_id"]
oauth_provider.storage.set_session(f"code_{auth_code}", code_data)
# Build redirect URL back to Claude
redirect_params = {
"code": auth_code,
"state": original_state
}
redirect_url = f"{oauth_session['redirect_uri']}?{urlencode(redirect_params)}"
logger.info(f"Redirecting back to Claude: {redirect_url}")
return RedirectResponse(url=redirect_url)
except Exception as e:
logger.exception(f"Callback processing failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "server_error", "error_description": str(e)}
)
@router.post("/register")
async def register_client(request: Request):
"""Dynamic Client Registration (RFC 7591)"""
data = await request.json()
logger.info(f"Client registration request: {data}")
# Simple dynamic registration - accept any client
client_id = f"mcp-client-{os.urandom(8).hex()}"
return JSONResponse({
"client_id": client_id,
"client_secret": None, # Public client
"redirect_uris": data.get("redirect_uris", []),
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_name": data.get("client_name", "MCP Client"),
"token_endpoint_auth_method": "none",
"client_id_issued_at": int(datetime.now().timestamp())
})
@router.post("/token")
async def token_endpoint(request: Request):
"""OAuth 2.1 Token Endpoint"""
# Parse form data
form_data = await request.form()
grant_type = form_data.get("grant_type")
code = form_data.get("code")
redirect_uri = form_data.get("redirect_uri")
client_id = form_data.get("client_id")
code_verifier = form_data.get("code_verifier")
logger.info(f"Token exchange - grant_type: {grant_type}, code: {code[:20] if code else 'None'}...")
if grant_type != "authorization_code":
return JSONResponse(
status_code=400,
content={"error": "unsupported_grant_type"}
)
try:
# OAuth token exchange - validate code and return Clerk JWT
# This supports proper OAuth flow while using Clerk JWT tokens
if not code or not redirect_uri:
logger.error("Missing required parameters: code or redirect_uri")
return JSONResponse(
status_code=400,
content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
)
# Validate OAuth code with Clerk
if CLERK_AVAILABLE:
try:
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
# In a real implementation, you'd validate the code with Clerk
# For now, we'll assume the code is valid if it looks like a Clerk code
if len(code) > 10: # Basic validation
# Create a mock session with the code
# In practice, this would be validated with Clerk's OAuth flow
# Return Clerk JWT token format
# This should be the actual Clerk JWT token from the OAuth flow
return JSONResponse({
"access_token": f"mock_clerk_jwt_{code}",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "yargi.read yargi.search"
})
else:
logger.error(f"Invalid code format: {code}")
return JSONResponse(
status_code=400,
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
)
except Exception as e:
logger.error(f"Clerk validation failed: {e}")
return JSONResponse(
status_code=400,
content={"error": "invalid_grant", "error_description": "Authorization code validation failed"}
)
else:
logger.warning("Clerk SDK not available, using mock response")
return JSONResponse({
"access_token": "mock_jwt_token_for_development",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "yargi.read yargi.search"
})
except Exception as e:
logger.exception(f"Token exchange failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "server_error", "error_description": str(e)}
)
+3 -26
View File
@@ -31,7 +31,6 @@ try:
except ImportError: except ImportError:
TIKTOKEN_AVAILABLE = False TIKTOKEN_AVAILABLE = False
tiktoken = None tiktoken = None
from fastmcp.server.dependencies import get_access_token, AccessToken
from fastmcp import Context from fastmcp import Context
# Use standard exception for tool errors # Use standard exception for tool errors
@@ -241,14 +240,10 @@ 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(auth=None): def create_app():
"""Create FastMCP app with standard capabilities and optional auth.""" """Create FastMCP app with standard capabilities."""
global app global app
if auth: logger.info("MCP server created with standard capabilities...")
app.auth = auth
logger.info("MCP server created with Bearer authentication enabled")
else:
logger.info("MCP server created with standard capabilities...")
# Add token counting middleware only if tiktoken is available # Add token counting middleware only if tiktoken is available
if TIKTOKEN_AVAILABLE: if TIKTOKEN_AVAILABLE:
@@ -1125,24 +1120,6 @@ 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 - DISABLED: Already handled by Bearer auth provider
# 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
# Convert date formats if provided # Convert date formats if provided
-94
View File
@@ -1,94 +0,0 @@
events {
worker_connections 1024;
}
http {
upstream yargi_mcp {
server yargi-mcp:8000;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=100r/s;
server {
listen 80;
server_name localhost;
# Redirect HTTP to HTTPS in production
# return 301 https://$server_name$request_uri;
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# API endpoints
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://yargi_mcp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# MCP endpoint (higher rate limit)
location /mcp-server/mcp/ {
limit_req zone=mcp_limit burst=50 nodelay;
proxy_pass http://yargi_mcp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Longer timeouts for MCP operations
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Health check (no rate limit)
location /health {
proxy_pass http://yargi_mcp;
proxy_set_header Host $host;
}
# Root and other paths
location / {
limit_req zone=api_limit burst=10 nodelay;
proxy_pass http://yargi_mcp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# SSL configuration (uncomment for production)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
#
# # Include all location blocks from above
# }
}
+2 -9
View File
@@ -46,22 +46,15 @@ production = [
"gunicorn>=22.0.0", "gunicorn>=22.0.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
] ]
saas = [
"clerk-backend-api>=3.0.0",
"stripe>=9.1.0",
"upstash-redis>=1.1.0",
"tiktoken>=0.5.0",
"PyJWT>=2.8.0",
]
[project.scripts] [project.scripts]
yargi-mcp = "mcp_server_main:main" yargi-mcp = "mcp_server_main:main"
[tool.setuptools] [tool.setuptools]
py-modules = ["mcp_server_main", "mcp_auth_factory", "mcp_auth_http_adapter", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "stripe_webhook"] py-modules = ["mcp_server_main", "asgi_app"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["*_mcp_module", "mcp_auth", "semantic_search"] include = ["*_mcp_module", "semantic_search"]
[build-system] [build-system]
requires = ["setuptools>=65.0", "wheel"] requires = ["setuptools>=65.0", "wheel"]
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env python3
"""
Standalone ASGI server runner for Yargı MCP
This script provides a simple way to run the Yargı MCP server
as a web service using uvicorn.
Usage:
python run_asgi.py
python run_asgi.py --host 0.0.0.0 --port 8080
python run_asgi.py --reload # For development
"""
import os
import sys
import argparse
import logging
from pathlib import Path
# Add project root to Python path
sys.path.insert(0, str(Path(__file__).parent))
try:
import uvicorn
except ImportError:
print("Error: uvicorn is not installed.")
print("Please install it with: pip install uvicorn")
sys.exit(1)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def main():
parser = argparse.ArgumentParser(
description="Run Yargı MCP server as an ASGI web service"
)
parser.add_argument(
"--host",
type=str,
default=os.getenv("HOST", "127.0.0.1"),
help="Host to bind to (default: 127.0.0.1)"
)
parser.add_argument(
"--port",
type=int,
default=int(os.getenv("PORT", "8000")),
help="Port to bind to (default: 8000)"
)
parser.add_argument(
"--reload",
action="store_true",
help="Enable auto-reload for development"
)
parser.add_argument(
"--transport",
choices=["http", "sse"],
default="http",
help="Transport type (default: http)"
)
parser.add_argument(
"--log-level",
choices=["debug", "info", "warning", "error"],
default=os.getenv("LOG_LEVEL", "info").lower(),
help="Log level (default: info)"
)
parser.add_argument(
"--workers",
type=int,
default=1,
help="Number of worker processes (default: 1)"
)
args = parser.parse_args()
# Select app based on transport
app_name = "asgi_app:app" if args.transport == "http" else "asgi_app:sse_app"
# Configure uvicorn
config = {
"app": app_name,
"host": args.host,
"port": args.port,
"log_level": args.log_level,
"reload": args.reload,
"access_log": True,
}
# Add workers only if not in reload mode
if not args.reload and args.workers > 1:
config["workers"] = args.workers
# Print startup information
print(f"Starting Yargı MCP server...")
print(f"Host: {args.host}")
print(f"Port: {args.port}")
print(f"Transport: {args.transport}")
print(f"Log level: {args.log_level}")
if args.reload:
print("Auto-reload: enabled")
else:
print(f"Workers: {args.workers}")
print(f"\nServer will be available at: http://{args.host}:{args.port}")
print(f"MCP endpoint: http://{args.host}:{args.port}/mcp/")
print(f"Health check: http://{args.host}:{args.port}/health")
print(f"API status: http://{args.host}:{args.port}/status")
print("\nPress CTRL+C to stop the server\n")
# Run uvicorn
try:
uvicorn.run(**config)
except KeyboardInterrupt:
print("\nShutting down server...")
sys.exit(0)
if __name__ == "__main__":
main()
-159
View File
@@ -1,159 +0,0 @@
"""
Starlette integration example for Yargı MCP Server
This module demonstrates how to integrate the Yargı MCP server
with a Starlette application, including authentication middleware
and custom routing.
Usage:
uvicorn starlette_app:app --host 0.0.0.0 --port 8000
"""
import os
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.authentication import (
AuthenticationBackend, AuthCredentials, SimpleUser, AuthenticationError
)
# Import the main MCP app
from mcp_server_main import app as mcp_server
# Simple token authentication backend
class TokenAuthBackend(AuthenticationBackend):
async def authenticate(self, request):
auth_header = request.headers.get("Authorization")
expected_token = os.getenv("API_TOKEN")
# Skip auth for health check and public endpoints
if request.url.path in ["/health", "/", "/login"]:
return None
if not expected_token:
# No token configured, allow all
return AuthCredentials(["authenticated"]), SimpleUser("anonymous")
if not auth_header:
raise AuthenticationError("Authorization header required")
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
raise AuthenticationError("Invalid authentication scheme")
if token != expected_token:
raise AuthenticationError("Invalid token")
return AuthCredentials(["authenticated"]), SimpleUser("user")
except ValueError:
raise AuthenticationError("Invalid authorization header format")
# Homepage
async def homepage(request: Request):
return JSONResponse({
"service": "Yargı MCP Server",
"version": "0.1.0",
"endpoints": {
"mcp": "/mcp-server/mcp/",
"api": "/api/",
"health": "/health"
}
})
# API info endpoint
async def api_info(request: Request):
if not request.user.is_authenticated:
return JSONResponse({"error": "Authentication required"}, status_code=401)
return JSONResponse({
"authenticated_as": request.user.display_name,
"available_tools": len(mcp_server._tool_manager._tools),
"databases": [
"Yargıtay", "Danıştay", "Emsal", "Uyuşmazlık",
"Anayasa", "KIK", "Rekabet", "Bedesten"
]
})
# Health check
async def health_check(request: Request):
return JSONResponse({
"status": "healthy",
"service": "Yargı MCP Server"
})
# Login example (returns token for demo)
async def login(request: Request):
token = os.getenv("API_TOKEN", "demo-token")
return JSONResponse({
"message": "Use this token in Authorization header",
"example": f"Authorization: Bearer {token}",
"note": "Set API_TOKEN environment variable to change token"
})
# Create MCP ASGI app
mcp_app = mcp_server.http_app(path='/mcp')
# Configure middleware
middleware = [
Middleware(
CORSMiddleware,
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
),
Middleware(AuthenticationMiddleware, backend=TokenAuthBackend()),
]
# Create routes
routes = [
Route("/", homepage),
Route("/health", health_check),
Route("/login", login),
Route("/api/info", api_info),
Mount("/mcp-server", app=mcp_app),
]
# Create Starlette app
app = Starlette(
routes=routes,
middleware=middleware,
lifespan=mcp_app.lifespan
)
# Nested mount example
def create_nested_app():
"""Example of nested mounting for complex routing structures"""
# Create inner app with MCP
inner_app = Starlette(
routes=[Mount("/services", app=mcp_app)],
middleware=middleware
)
# Create outer app
outer_app = Starlette(
routes=[
Route("/", homepage),
Mount("/v1", app=inner_app),
],
lifespan=mcp_app.lifespan
)
# MCP would be available at /v1/services/mcp/
return outer_app
# Export both apps
nested_app = create_nested_app()
if __name__ == "__main__":
import uvicorn
print("Starting Starlette app with authentication...")
print("Set API_TOKEN environment variable to enable authentication")
print("Example: API_TOKEN=secret-token python starlette_app.py")
uvicorn.run(app, host="0.0.0.0", port=8000)
-25
View File
@@ -1,25 +0,0 @@
import os, stripe
from clerk_backend_api import Clerk # Clerk backend SDK
from fastapi import APIRouter, Request, HTTPException
router = APIRouter()
stripe.api_key = os.getenv("STRIPE_SECRET")
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
@router.post("/stripe/webhook")
async def stripe_hook(req: Request):
payload, sig = await req.body(), req.headers["stripe-signature"]
try:
event = stripe.Webhook.construct_event( # Stripe-recommended verify
payload, sig, os.getenv("STRIPE_WEBHOOK_SECRET"))
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "Bad sig")
if event["type"] == "customer.subscription.updated":
item = event["data"]["object"]["items"]["data"][0]
plan = item["price"]["nickname"] # "Pro", "Enterprise"…
userID = event["data"]["object"]["metadata"]["clerk_user_id"]
clerk.users.update_user_metadata( # merge into unsafe_metadata
userID, unsafe_metadata={"plan": plan})
return {"ok": True}