From a66a3f2053039d613b2709e59c6c8e7fd4bc215e Mon Sep 17 00:00:00 2001 From: saidsurucu Date: Fri, 3 Apr 2026 02:08:56 +0300 Subject: [PATCH] Remove auth and Fly.io deployment --- .../anayasa_mahkemesi_architecture.md | 278 ++++++++++ .serena/project.yml | 57 +- Dockerfile | 34 -- Procfile | 1 - asgi_app.py | 498 +----------------- docker-compose.yml | 66 --- docs/DEPLOYMENT.md | 428 --------------- fly-no-auth.toml | 46 -- fly.toml | 40 -- mcp_auth/__init__.py | 28 - mcp_auth/clerk_config.py | 73 --- mcp_auth/middleware.py | 315 ----------- mcp_auth/oauth.py | 304 ----------- mcp_auth/policy.py | 201 ------- mcp_auth/storage.py | 112 ---- mcp_auth_factory.py | 193 ------- mcp_auth_http_adapter.py | 383 -------------- mcp_server_main.py | 29 +- nginx.conf | 94 ---- pyproject.toml | 11 +- run_asgi.py | 119 ----- starlette_app.py | 159 ------ stripe_webhook.py | 25 - 23 files changed, 361 insertions(+), 3133 deletions(-) create mode 100644 .serena/memories/anayasa_mahkemesi_architecture.md delete mode 100644 Dockerfile delete mode 100644 Procfile delete mode 100644 docker-compose.yml delete mode 100644 docs/DEPLOYMENT.md delete mode 100644 fly-no-auth.toml delete mode 100644 fly.toml delete mode 100644 mcp_auth/__init__.py delete mode 100644 mcp_auth/clerk_config.py delete mode 100644 mcp_auth/middleware.py delete mode 100644 mcp_auth/oauth.py delete mode 100644 mcp_auth/policy.py delete mode 100644 mcp_auth/storage.py delete mode 100644 mcp_auth_factory.py delete mode 100644 mcp_auth_http_adapter.py delete mode 100644 nginx.conf delete mode 100644 run_asgi.py delete mode 100644 starlette_app.py delete mode 100644 stripe_webhook.py diff --git a/.serena/memories/anayasa_mahkemesi_architecture.md b/.serena/memories/anayasa_mahkemesi_architecture.md new file mode 100644 index 0000000..ccc0cda --- /dev/null +++ b/.serena/memories/anayasa_mahkemesi_architecture.md @@ -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

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: +

Esas No.: [number]

+

Karar No.: [number]

+

Karar tarihi: [date]

+

Resmî Gazete tarih ve sayısı: [info]

+``` + +### 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 diff --git a/.serena/project.yml b/.serena/project.yml index db1e992..f340017 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -79,6 +79,61 @@ excluded_tools: [] # 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). initial_prompt: "" - +# the name by which the project can be referenced within Serena 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: [] + +# 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 read‑only. +# 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: {} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 489ece5..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/Procfile b/Procfile deleted file mode 100644 index 2b6b125..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT \ No newline at end of file diff --git a/asgi_app.py b/asgi_app.py index 92b0c34..219608d 100755 --- a/asgi_app.py +++ b/asgi_app.py @@ -2,112 +2,32 @@ ASGI application for Yargı MCP Server This module provides ASGI/HTTP access to the Yargı MCP server, -allowing it to be deployed as a web service with FastAPI wrapper -for OAuth integration and proper middleware support. +allowing it to be deployed as a web service with FastAPI wrapper. Usage: uvicorn asgi_app:app --host 0.0.0.0 --port 8000 """ import os -import time -import logging import json -from datetime import datetime, timedelta -from fastapi import FastAPI, Request, HTTPException, Query -from fastapi.responses import JSONResponse, HTMLResponse, Response -from fastapi.exception_handlers import http_exception_handler +import logging +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from starlette.middleware import Middleware 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 -# 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 logger = logging.getLogger(__name__) -# Configure CORS and Auth middleware +# Configure CORS cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") -# Configure Bearer token authentication based on ENABLE_AUTH -auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true" -bearer_auth = None +# Create MCP app +mcp_server = create_app() -# Only import and configure auth if enabled -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 +# Create MCP Starlette sub-application mcp_app = mcp_server.http_app(path="/") @@ -118,7 +38,7 @@ class UTF8JSONResponse(JSONResponse): headers = {} headers["Content-Type"] = "application/json; charset=utf-8" super().__init__(content, status_code, headers, **kwargs) - + def render(self, content) -> bytes: return json.dumps( content, @@ -133,43 +53,22 @@ custom_middleware = [ CORSMiddleware, allow_origins=cors_origins, allow_credentials=True, - allow_methods=["GET", "POST", "OPTIONS", "DELETE"], - allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"], + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type", "X-Request-ID", "X-Session-ID"], ), ] # Create FastAPI wrapper application app = FastAPI( 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", middleware=custom_middleware, - default_response_class=UTF8JSONResponse, # Use UTF-8 JSON encoder - redirect_slashes=False # Disable to prevent 307 redirects on /mcp endpoint + default_response_class=UTF8JSONResponse, + 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") async def health_check(): """Health check endpoint for monitoring""" @@ -178,119 +77,33 @@ async def health_check(): "service": "Yargı MCP Server", "version": "0.1.0", "tools_count": len(mcp_server._tool_manager._tools), - "auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true" } -# Add explicit redirect for /mcp to /mcp/ with method preservation + @app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"]) async def redirect_to_slash(request: Request): """Redirect /mcp to /mcp/ preserving HTTP method with 308""" from fastapi.responses import RedirectResponse return RedirectResponse(url="/mcp/", status_code=308) -# 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("/") async def root(): """Root endpoint with service information""" return { "service": "Yargı MCP Server", - "description": "MCP server for Turkish legal databases with OAuth authentication", + "description": "MCP server for Turkish legal databases", "endpoints": { "mcp": "/mcp", "health": "/health", "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": { "http": "/mcp" }, "supported_databases": [ "Yargıtay (Court of Cassation)", - "Danıştay (Council of State)", + "Danıştay (Council of State)", "Emsal (Precedent)", "Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)", "Anayasa Mahkemesi (Constitutional Court)", @@ -299,140 +112,12 @@ async def root(): "Sayıştay (Court of Accounts)", "KVKK (Personal Data Protection Authority)", "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") async def status(): """Status endpoint with detailed information""" @@ -442,159 +127,20 @@ async def status(): "name": tool.name, "description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description }) - + return { "status": "operational", "tools": tools, "total_tools": len(tools), "transport": "streamable_http", - "architecture": "FastAPI wrapper + MCP Starlette sub-app", - "auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled" } -# 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 - 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""" - - - MCP Connection Successful - - - -

✅ MCP Connection Successful!

-

Your Yargı MCP integration is now active.

-
- Authentication:
- Use your Clerk JWT token directly with Bearer authentication -
-

You can now close this window and return to your MCP client.

- - - - """) - - except HTTPException as e: - return HTMLResponse(f""" - - - MCP Connection Failed - - - -

❌ MCP Connection Failed

-

{e.detail}

-
- Debug Info:
- Clerk Token: {'✅ Provided' if clerk_token else '❌ Missing'}
- Error: {e.detail}
- Status: {e.status_code} -
-

Please try again or contact support.

- Return to Sign In - - - """, status_code=e.status_code) - except Exception as e: - return HTMLResponse(f""" - - - MCP Connection Error - - - -

❌ Unexpected Error

-

An unexpected error occurred during authentication.

-

Error: {str(e)}

- Return to Sign In - - - """, 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 +# Mount MCP app at /mcp/ app.mount("/mcp/", mcp_app) # Set the lifespan context after mounting app.router.lifespan_context = mcp_app.lifespan # Export for uvicorn -__all__ = ["app"] \ No newline at end of file +__all__ = ["app"] diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index d36eee7..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: \ No newline at end of file diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md deleted file mode 100644 index db5f361..0000000 --- a/docs/DEPLOYMENT.md +++ /dev/null @@ -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 -``` - -### İ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 \ No newline at end of file diff --git a/fly-no-auth.toml b/fly-no-auth.toml deleted file mode 100644 index 5205913..0000000 --- a/fly-no-auth.toml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/fly.toml b/fly.toml deleted file mode 100644 index fa1cc3f..0000000 --- a/fly.toml +++ /dev/null @@ -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" diff --git a/mcp_auth/__init__.py b/mcp_auth/__init__.py deleted file mode 100644 index 0ed22c9..0000000 --- a/mcp_auth/__init__.py +++ /dev/null @@ -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", -] \ No newline at end of file diff --git a/mcp_auth/clerk_config.py b/mcp_auth/clerk_config.py deleted file mode 100644 index 2c3e205..0000000 --- a/mcp_auth/clerk_config.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/mcp_auth/middleware.py b/mcp_auth/middleware.py deleted file mode 100644 index 0dc6301..0000000 --- a/mcp_auth/middleware.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/mcp_auth/oauth.py b/mcp_auth/oauth.py deleted file mode 100644 index efc3adc..0000000 --- a/mcp_auth/oauth.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/mcp_auth/policy.py b/mcp_auth/policy.py deleted file mode 100644 index e947af3..0000000 --- a/mcp_auth/policy.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/mcp_auth/storage.py b/mcp_auth/storage.py deleted file mode 100644 index ea1ad8f..0000000 --- a/mcp_auth/storage.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/mcp_auth_factory.py b/mcp_auth_factory.py deleted file mode 100644 index a5760f6..0000000 --- a/mcp_auth_factory.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/mcp_auth_http_adapter.py b/mcp_auth_http_adapter.py deleted file mode 100644 index 442b540..0000000 --- a/mcp_auth_http_adapter.py +++ /dev/null @@ -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)} - ) \ No newline at end of file diff --git a/mcp_server_main.py b/mcp_server_main.py index bb1c583..cd67a50 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -31,7 +31,6 @@ try: except ImportError: TIKTOKEN_AVAILABLE = False tiktoken = None -from fastmcp.server.dependencies import get_access_token, AccessToken from fastmcp import Context # Use standard exception for tool errors @@ -241,14 +240,10 @@ class TokenCountingMiddleware(Middleware): # Create FastMCP app directly without authentication wrapper from fastmcp import FastMCP -def create_app(auth=None): - """Create FastMCP app with standard capabilities and optional auth.""" +def create_app(): + """Create FastMCP app with standard capabilities.""" global app - if auth: - app.auth = auth - logger.info("MCP server created with Bearer authentication enabled") - else: - logger.info("MCP server created with standard capabilities...") + logger.info("MCP server created with standard capabilities...") # Add token counting middleware only if tiktoken is available if TIKTOKEN_AVAILABLE: @@ -1125,24 +1120,6 @@ For best results, use exact phrases with quotes for legal terms."""), ) -> dict: """Search Turkish legal databases via unified Bedesten API.""" - # Get Bearer token information for access control and logging - try: - access_token: AccessToken = get_access_token() - user_id = access_token.client_id - user_scopes = access_token.scopes - - # Check for required scopes - 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 # Convert date formats if provided diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 5cb39b2..0000000 --- a/nginx.conf +++ /dev/null @@ -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 - # } -} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 694b64d..b1a8bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,22 +46,15 @@ production = [ "gunicorn>=22.0.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] yargi-mcp = "mcp_server_main:main" [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] -include = ["*_mcp_module", "mcp_auth", "semantic_search"] +include = ["*_mcp_module", "semantic_search"] [build-system] requires = ["setuptools>=65.0", "wheel"] diff --git a/run_asgi.py b/run_asgi.py deleted file mode 100644 index 30ef9f3..0000000 --- a/run_asgi.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/starlette_app.py b/starlette_app.py deleted file mode 100644 index a9e711f..0000000 --- a/starlette_app.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/stripe_webhook.py b/stripe_webhook.py deleted file mode 100644 index 3e5dfa9..0000000 --- a/stripe_webhook.py +++ /dev/null @@ -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} -