diff --git a/asgi_app.py b/asgi_app.py index ecbb113..f1f91bc 100755 --- a/asgi_app.py +++ b/asgi_app.py @@ -58,21 +58,19 @@ except ImportError: auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true" bearer_auth = None -if auth_enabled and CLERK_SECRET_KEY and CLERK_ISSUER: +if CLERK_SECRET_KEY and CLERK_ISSUER: # Production: Use Clerk JWKS endpoint for token validation - # JWT token shows issuer as "https://clerk.yargimcp.com", so use that for JWKS - jwt_issuer = "https://clerk.yargimcp.com" bearer_auth = BearerAuthProvider( - jwks_uri=f"{jwt_issuer}/.well-known/jwks.json", - issuer=jwt_issuer, # Enable issuer validation with correct issuer + jwks_uri=f"{CLERK_ISSUER}/.well-known/jwks.json", + issuer=None, # Disable issuer validation - Clerk uses different issuer format algorithm="RS256", - audience=None, # Disable audience validation - Clerk tokens vary - required_scopes=[] # Disable scope validation - rely on token presence + audience=None, # Disable audience validation - Clerk uses different audience format + required_scopes=[] # Disable scope validation - Clerk JWT has ['read', 'search'] ) - logger.info(f"Bearer auth configured with Clerk JWKS: {jwt_issuer}/.well-known/jwks.json (issuer validation enabled)") -elif auth_enabled: - # Development: Generate RSA key pair for testing when auth is enabled but no Clerk - logger.warning("Authentication enabled but no Clerk credentials - using development RSA key pair") + logger.info(f"Bearer auth configured with Clerk JWKS: {CLERK_ISSUER}/.well-known/jwks.json") +else: + # Development: Generate RSA key pair for testing + logger.warning("No Clerk credentials found - using development RSA key pair") dev_key_pair = RSAKeyPair.generate() bearer_auth = BearerAuthProvider( public_key=dev_key_pair.public_key, @@ -89,13 +87,10 @@ elif auth_enabled: scopes=["yargi.read", "yargi.search"], expires_in_seconds=3600 * 24 # 24 hours for development ) - logger.debug("Development Bearer token generated (masked for security)") # Don't log actual token -else: - # Authentication disabled - allow unauthenticated access - logger.info("Authentication disabled - MCP server will allow unauthenticated access") + logger.info(f"Development Bearer token: {dev_token}") # Create MCP app with Bearer authentication -mcp_server = create_app(auth=bearer_auth) +mcp_server = create_app(auth=bearer_auth if auth_enabled else None) # Create MCP Starlette sub-application with root path - mount will add /mcp prefix mcp_app = mcp_server.http_app(path="/") @@ -126,41 +121,13 @@ class UTF8JSONResponse(JSONResponse): separators=(",", ":"), ).encode("utf-8") -# CORS middleware configuration - Allow Claude AI and Clerk domains -cors_allowed_origins = ["*"] - custom_middleware = [ Middleware( CORSMiddleware, - allow_origins=cors_allowed_origins, - allow_credentials=True, # Enable credentials for cross-origin requests - allow_methods=["GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS", "PATCH"], - allow_headers=[ - "Content-Type", - "Authorization", - "X-Request-ID", - "X-Session-ID", - "MCP-Protocol-Version", - "Mcp-Session-Id", - "x-api-key", # Added from your config - "Last-Event-ID", # Added from your config for SSE support - "Accept", - "Origin", - "User-Agent", - "DNT", - "Cache-Control", - "X-Mx-ReqToken", - "Keep-Alive", - "X-Requested-With", - "If-Modified-Since" - ], - expose_headers=[ - "Content-Type", # Added from your config - "Authorization", - "x-api-key", # Added from your config - "Mcp-Session-Id" - ], - max_age=86400, # Added from your config (24 hours) + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS", "DELETE"], + allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"], ), ] @@ -637,7 +604,7 @@ async def mcp_token_endpoint(request: Request): content={"error": "invalid_request", "error_description": e.detail} ) -# Mount MCP app at /mcp/ with trailing slash (v0.1.6 approach) +# Mount MCP app at /mcp/ with trailing slash app.mount("/mcp/", mcp_app) # Set the lifespan context after mounting diff --git a/mcp_auth_http_simple.py b/mcp_auth_http_simple.py index fd6d2d8..2b6bbfd 100644 --- a/mcp_auth_http_simple.py +++ b/mcp_auth_http_simple.py @@ -72,24 +72,14 @@ async def get_oauth_metadata(): return JSONResponse({ "issuer": BASE_URL, "authorization_endpoint": f"{BASE_URL}/auth/login", - "authorization_endpoint_simple": f"{BASE_URL}/auth/login-simple", # Simple request endpoint "token_endpoint": f"{BASE_URL}/token", - "token_endpoint_simple": f"{BASE_URL}/token-simple", # Simple request endpoint "registration_endpoint": f"{BASE_URL}/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], "scopes_supported": ["read", "search", "openid", "profile", "email"], - "service_documentation": f"{BASE_URL}/mcp/", - "preflight_free_endpoints": { - "authorization": f"{BASE_URL}/auth/login-simple", - "token": f"{BASE_URL}/token-simple" - }, - "clerk_optimization": { - "simple_requests": True, - "cors_preflight_bypass": True, - "performance_optimized": True + "service_documentation": f"{BASE_URL}/mcp/" } }) diff --git a/mcp_server_main.py b/mcp_server_main.py index 3dd3a58..ccb3c77 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -243,10 +243,8 @@ def create_app(auth=None): global app if auth: app.auth = auth - app.name = "Yargı MCP Server" logger.info("MCP server created with Bearer authentication enabled") else: - app.name = "Yargı MCP Server" logger.info("MCP server created with standard capabilities...") token_counter = TokenCountingMiddleware() diff --git a/saidsurucu-yargi-mcp-f5fa007/.dockerignore b/saidsurucu-yargi-mcp-f5fa007/.dockerignore new file mode 100644 index 0000000..e9f20bb --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/.dockerignore @@ -0,0 +1,186 @@ +# flyctl launch added from .gitignore +# Byte-compiled / optimized / DLL files +**/__pycache__ +**/*.py[cod] +**/*$py.class + +# C extensions +**/*.so + +# Distribution / packaging +**/.Python +**/build +**/develop-eggs +**/dist +**/downloads +**/eggs +**/.eggs +**/lib +**/lib64 +**/parts +**/sdist +**/var +**/wheels +**/share/python-wheels +**/*.egg-info +**/.installed.cfg +**/*.egg +**/MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +**/*.manifest +**/*.spec + +# Installer logs +**/pip-log.txt +**/pip-delete-this-directory.txt + +# Unit test / coverage reports +**/htmlcov +**/.tox +**/.nox +**/.coverage +**/.coverage.* +**/.cache +**/nosetests.xml +**/coverage.xml +**/*.cover +**/*.py,cover +**/.hypothesis +**/.pytest_cache +**/cover + +# Translations +**/*.mo +**/*.pot + +# Django stuff: +**/*.log +**/local_settings.py +**/db.sqlite3 +**/db.sqlite3-journal + +# Flask stuff: +**/instance +**/.webassets-cache + +# Scrapy stuff: +**/.scrapy + +# Sphinx documentation +**/docs/_build + +# PyBuilder +**/.pybuilder +**/target + +# Jupyter Notebook +**/.ipynb_checkpoints + +# IPython +**/profile_default +**/ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +**/.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +**/__pypackages__ + +# Celery stuff +**/celerybeat-schedule +**/celerybeat.pid + +# SageMath parsed files +**/*.sage.py + +# Environments +**/.env +**/.venv +**/env +**/venv +**/ENV +**/env.bak +**/venv.bak + +# Spyder project settings +**/.spyderproject +**/.spyproject + +# Rope project settings +**/.ropeproject + +# mkdocs documentation +site + +# mypy +**/.mypy_cache +**/.dmypy.json +**/dmypy.json + +# Pyre type checker +**/.pyre + +# pytype static type analyzer +**/.pytype + +# Cython debug symbols +**/cython_debug + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +**/.DS_Store +**/hello.py + +**/*.html +**/fast-mcp-docs.md + +# Debug and test files +**/debug_* +**/test_* +**/CLAUDE.md + +# ASGI/Deployment files +**/ssl +**/*.pem +**/*.key +**/*.crt + +# Docker volumes +**/redis-data + +# Production logs +**/logs/*.log.* +**/Dockerfile +**/Dockerfile +fly.toml diff --git a/saidsurucu-yargi-mcp-f5fa007/.env.example b/saidsurucu-yargi-mcp-f5fa007/.env.example new file mode 100644 index 0000000..270a0fb --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/.env.example @@ -0,0 +1,103 @@ +# OAuth Configuration for Clerk + Google +# Copy this file to .env and fill in your actual values + +# ============================================================================= +# AUTHENTICATION SETTINGS +# ============================================================================= + +# Enable/disable authentication (set to "true" to enable OAuth) +ENABLE_AUTH=false + +# ============================================================================= +# CLERK CONFIGURATION +# ============================================================================= + +# Clerk API keys (get from https://dashboard.clerk.com/) +CLERK_SECRET_KEY=sk_test_your_secret_key_here +CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here + +# OAuth Redirect URLs +CLERK_OAUTH_REDIRECT_URL=http://localhost:8000/auth/callback +CLERK_FRONTEND_URL=http://localhost:3000 + +# Clerk domain issuer (usually auto-configured) +CLERK_ISSUER=https://your-clerk-domain.clerk.accounts.dev +CLERK_DOMAIN=your-clerk-domain + +# ============================================================================= +# GOOGLE OAUTH SETTINGS +# ============================================================================= +# Note: Google OAuth is configured through Clerk dashboard +# You need to: +# 1. Go to Clerk Dashboard > Social Connections +# 2. Enable Google provider +# 3. Add your Google OAuth client ID and secret +# 4. Configure redirect URIs in Google Console + +# ============================================================================= +# STRIPE CONFIGURATION (for payments/subscriptions) +# ============================================================================= + +STRIPE_SECRET=sk_test_your_stripe_secret_key_here +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here + +# ============================================================================= +# SERVER CONFIGURATION +# ============================================================================= + +# CORS origins (comma-separated list) +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,https://yourdomain.com + +# Server settings +HOST=0.0.0.0 +PORT=8000 +LOG_LEVEL=info + +# Base URL for the application (used for OAuth callbacks and API URLs) +BASE_URL=http://localhost:8000 + +# JWT Secret for MCP token generation +JWT_SECRET_KEY=your_jwt_secret_key_here + +# ============================================================================= +# MCP SERVER SETTINGS +# ============================================================================= + +# Additional MCP server configuration can go here +# For example, rate limiting, feature flags, etc. + +# Example: Rate limiting +# MAX_REQUESTS_PER_MINUTE=60 +# BURST_CAPACITY=20 + +# ============================================================================= +# USAGE INSTRUCTIONS +# ============================================================================= + +# 1. Copy this file to .env: +# cp .env.example .env + +# 2. Get Clerk credentials: +# - Sign up at https://clerk.com/ +# - Create a new application +# - Go to API Keys tab +# - Copy Secret Key and Publishable Key + +# 3. Configure Google OAuth in Clerk: +# - In Clerk Dashboard, go to Social Connections +# - Enable Google provider +# - Get Google OAuth credentials from Google Console +# - Add redirect URI: http://localhost:8000/auth/callback + +# 4. Update OAuth URLs: +# - Set CLERK_OAUTH_REDIRECT_URL to your callback URL +# - Set CLERK_FRONTEND_URL to your frontend application URL + +# 5. Enable authentication: +# - Set ENABLE_AUTH=true + +# 6. Test the OAuth flow: +# - Start server: uvicorn asgi_app:app --reload +# - Visit: http://localhost:8000/auth/login +# - Complete OAuth flow with Google +# - Check: http://localhost:8000/auth/user \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/.gitattributes b/saidsurucu-yargi-mcp-f5fa007/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/saidsurucu-yargi-mcp-f5fa007/.github/workflows/publish.yml b/saidsurucu-yargi-mcp-f5fa007/.github/workflows/publish.yml new file mode 100644 index 0000000..b1924b3 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: # Manual trigger for testing + +jobs: + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/yargi-mcp + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: python -m build + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/.gitignore b/saidsurucu-yargi-mcp-f5fa007/.gitignore new file mode 100644 index 0000000..7f3ac3d --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/.gitignore @@ -0,0 +1,215 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +.DS_Store +hello.py + +*.html +fast-mcp-docs.md + +# Debug and test files +debug_* +test_* +CLAUDE.md + +# ASGI/Deployment files +ssl/ +*.pem +*.key +*.crt + +# Docker volumes +redis-data/ + +# Production logs +logs/*.log.* + +# Remove these lines - we need deployment files in git: +# Dockerfile - NEEDED for SaaS deployment +# fly.toml - NEEDED for Fly.io deployment +# .github/workflows/fly-deploy.yml - NEEDED for GitHub Actions + +GEMINI.md +fly.toml +scripts/deploy-flyio.sh +docs/DEPLOYMENT_FLYIO.md +setup_jwt_template.py +mcp_server_main.py.backup +mcp_overhead_content.json +ANTHROPIC_TEST_README.md +extract_mcp_overhead.py +mcp_overhead_content.txt +mcp_overhead_summary.txt +run_http_server.py +run_local_test.py + +# MCP overhead analysis files +mcp_overhead_*.json +mcp_overhead_*.txt +mcp_test_results_*.json +mcp_quick_test_*.json + +# General text files (temporary notes, etc) +*.txt +analyze_playwright_mcp.py +measure_mcp_directly.py +playwright_mcp_overhead.json +simple_test.py +analyze_anayasa_html.py diff --git a/saidsurucu-yargi-mcp-f5fa007/5ire-settings.png b/saidsurucu-yargi-mcp-f5fa007/5ire-settings.png new file mode 100644 index 0000000..0fa460b Binary files /dev/null and b/saidsurucu-yargi-mcp-f5fa007/5ire-settings.png differ diff --git a/saidsurucu-yargi-mcp-f5fa007/Dockerfile b/saidsurucu-yargi-mcp-f5fa007/Dockerfile new file mode 100644 index 0000000..111f909 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/Dockerfile @@ -0,0 +1,29 @@ +# -------- BASE IMAGE (includes Chromium & deps) ---------------------------- +FROM mcr.microsoft.com/playwright/python:v1.53.0-noble + +# -------- 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 .[asgi,saas] + +# 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/saidsurucu-yargi-mcp-f5fa007/LICENSE b/saidsurucu-yargi-mcp-f5fa007/LICENSE new file mode 100644 index 0000000..cdf2fd2 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 saidsurucu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/saidsurucu-yargi-mcp-f5fa007/Procfile b/saidsurucu-yargi-mcp-f5fa007/Procfile new file mode 100644 index 0000000..2b6b125 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/Procfile @@ -0,0 +1 @@ +web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/README.md b/saidsurucu-yargi-mcp-f5fa007/README.md new file mode 100644 index 0000000..570305d --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/README.md @@ -0,0 +1,272 @@ +# Yargı MCP: Türk Hukuk Kaynakları için MCP Sunucusu + +[![Star History Chart](https://api.star-history.com/svg?repos=saidsurucu/yargi-mcp&type=Date)](https://www.star-history.com/#saidsurucu/yargi-mcp&Date) + +Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kararlar, Uyuşmazlık Mahkemesi, Anayasa Mahkemesi - Norm Denetimi ile Bireysel Başvuru Kararları, Kamu İhale Kurulu Kararları, Rekabet Kurumu Kararları, Sayıştay Kararları, KVKK Kararları ve BDDK Kararları) erişimi kolaylaştıran bir [FastMCP](https://gofastmcp.com/) sunucusu oluşturur. Bu sayede, bu kaynaklardan veri arama ve belge getirme işlemleri, Model Context Protocol (MCP) destekleyen LLM (Büyük Dil Modeli) uygulamaları (örneğin Claude Desktop veya [5ire](https://5ire.app)) ve diğer istemciler tarafından araç (tool) olarak kullanılabilir hale gelir. + +![örnek](./ornek.png) + +🎯 **Temel Özellikler** + +🚀 **YÜKSEK PERFORMANS OPTİMİZASYONU:** Bu MCP sunucusu **%61.8 token azaltma** ile optimize edilmiştir (8,692 token tasarrufu). Claude AI ile daha hızlı yanıt süreleri ve daha verimli etkileşim sağlar. + +* Çeşitli Türk hukuk veritabanlarına programatik erişim için standart bir MCP arayüzü. +* **Kapsamlı Mahkeme Daire/Kurul Filtreleme:** 79 farklı daire/kurul filtreleme seçeneği +* **Dual/Triple API Desteği:** Her mahkeme için birden fazla API kaynağı ile maksimum kapsama +* **Kapsamlı Tarih Filtreleme:** Tüm Bedesten API araçlarında ISO 8601 formatında tarih aralığı filtreleme +* **Kesin Cümle Arama:** Tüm Bedesten API araçlarında çift tırnak ile tam cümle arama desteği +* Aşağıdaki kurumların kararlarını arama ve getirme yeteneği: + * **Yargıtay:** Detaylı kriterlerle karar arama ve karar metinlerini Markdown formatında getirme. **Dual API** (Ana + Bedesten) + **52 Daire/Kurul Filtreleme** + **Tarih & Kesin Cümle Arama** (Hukuk/Ceza Daireleri, Genel Kurullar) + * **Danıştay:** Anahtar kelime bazlı ve detaylı kriterlerle karar arama; karar metinlerini Markdown formatında getirme. **Triple API** (Keyword + Detailed + Bedesten) + **27 Daire/Kurul Filtreleme** + **Tarih & Kesin Cümle Arama** (İdari Daireler, Vergi/İdare Kurulları, Askeri Yüksek İdare Mahkemesi) + * **Yerel Hukuk Mahkemeleri:** Bedesten API ile yerel hukuk mahkemesi kararlarına erişim + **Tarih & Kesin Cümle Arama** + * **İstinaf Hukuk Mahkemeleri:** Bedesten API ile istinaf mahkemesi kararlarına erişim + **Tarih & Kesin Cümle Arama** + * **Kanun Yararına Bozma (KYB):** Bedesten API ile olağanüstü kanun yoluna erişim + **Tarih & Kesin Cümle Arama** + * **Emsal (UYAP):** Detaylı kriterlerle emsal karar arama ve karar metinlerini Markdown formatında getirme. + * **Uyuşmazlık Mahkemesi:** Form tabanlı kriterlerle karar arama ve karar metinlerini (URL ile erişilen) Markdown formatında getirme. + * **Anayasa Mahkemesi (Norm Denetimi):** Kapsamlı kriterlerle norm denetimi kararlarını arama; uzun karar metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme. + * **Anayasa Mahkemesi (Bireysel Başvuru):** Kapsamlı kriterlerle bireysel başvuru "Karar Arama Raporu" oluşturma ve listedeki kararların metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme. + * **KİK (Kamu İhale Kurulu):** Çeşitli kriterlerle Kurul kararlarını arama; uzun karar metinlerini (varsayılan 5.000 karakterlik) sayfalanmış Markdown formatında getirme. + * **Rekabet Kurumu:** Çeşitli kriterlerle Kurul kararlarını arama; karar metinlerini Markdown formatında getirme. + * **Sayıştay:** 3 karar türü ile kapsamlı denetim kararlarına erişim + **8 Daire Filtreleme** + **Tarih Aralığı & İçerik Arama** (Genel Kurul yorumlayıcı kararları, Temyiz Kurulu itiraz kararları, Daire ilk derece denetim kararları) + * **KVKK (Kişisel Verilerin Korunması Kurulu):** Brave Search API ile veri koruma kararlarını arama; uzun karar metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme + **Türkçe Arama** + **Site Hedeflemeli Arama** (kvkk.gov.tr kararları) + * **BDDK (Bankacılık Düzenleme ve Denetleme Kurumu):** Bankacılık düzenleme kararlarını arama; karar metinlerini Markdown formatında getirme + **Optimized Search** + **"Karar Sayısı" Targeting** + **Spesifik URL Filtreleme** (bddk.org.tr/Mevzuat/DokumanGetir) + +* Karar metinlerinin daha kolay işlenebilmesi için Markdown formatına çevrilmesi. +* Claude Desktop uygulaması ile `fastmcp install` komutu kullanılarak kolay entegrasyon. +* Yargı MCP artık [5ire](https://5ire.app) gibi Claude Desktop haricindeki MCP istemcilerini de destekliyor! +--- +
+🚀 Claude Haricindeki Modellerle Kullanmak İçin Çok Kolay Kurulum (Örnek: 5ire için) + +Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istemcileriyle kullanmak isteyenler içindir. + +* **Python Kurulumu:** Sisteminizde Python 3.11 veya üzeri kurulu olmalıdır. Kurulum sırasında "**Add Python to PATH**" (Python'ı PATH'e ekle) seçeneğini işaretlemeyi unutmayın. [Buradan](https://www.python.org/downloads/) indirebilirsiniz. +* **Git Kurulumu (Windows):** Bilgisayarınıza [git](https://git-scm.com/downloads/win) yazılımını indirip kurun. "Git for Windows/x64 Setup" seçeneğini indirmelisiniz. +* **`uv` Kurulumu:** + * **Windows Kullanıcıları (PowerShell):** Bir CMD ekranı açın ve bu kodu çalıştırın: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` + * **Mac/Linux Kullanıcıları (Terminal):** Bir Terminal ekranı açın ve bu kodu çalıştırın: `curl -LsSf https://astral.sh/uv/install.sh | sh` +* **Microsoft Visual C++ Redistributable (Windows):** Bazı Python paketlerinin doğru çalışması için gereklidir. [Buradan](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) indirip kurun. +* İşletim sisteminize uygun [5ire](https://5ire.app) MCP istemcisini indirip kurun. +* 5ire'ı açın. **Workspace -> Providers** menüsünden kullanmak istediğiniz LLM servisinin API anahtarını girin. +* **Tools** menüsüne girin. **+Local** veya **New** yazan butona basın. + * **Tool Key:** `yargimcp` + * **Name:** `Yargı MCP` + * **Command:** + ``` + uvx yargi-mcp + ``` + * **Save** butonuna basarak kaydedin. +![5ire ayarları](./5ire-settings.png) +* Şimdi **Tools** altında **Yargı MCP**'yi görüyor olmalısınız. Üstüne geldiğinizde sağda çıkan butona tıklayıp etkinleştirin (yeşil ışık yanmalı). +* Artık Yargı MCP ile konuşabilirsiniz. + +
+ +--- +
+⚙️ Claude Desktop Manuel Kurulumu + +1. **Ön Gereksinimler:** Python, `uv`, (Windows için) Microsoft Visual C++ Redistributable'ın sisteminizde kurulu olduğundan emin olun. Detaylı bilgi için yukarıdaki "5ire için Kurulum" bölümündeki ilgili adımlara bakabilirsiniz. +2. Claude Desktop **Settings -> Developer -> Edit Config**. +3. Açılan `claude_desktop_config.json` dosyasına `mcpServers` altına ekleyin: + + ```json + { + "mcpServers": { + // ... (varsa diğer sunucularınız) ... + "Yargı MCP": { + "command": "uvx", + "args": [ + "yargi-mcp" + ] + } + } + } + ``` +4. Claude Desktop'ı kapatıp yeniden başlatın. + +
+ +--- +
+🌟 Gemini CLI ile Kullanım + +Yargı MCP'yi Gemini CLI ile kullanmak için: + +1. **Ön Gereksinimler:** Python, `uv`, (Windows için) Microsoft Visual C++ Redistributable'ın sisteminizde kurulu olduğundan emin olun. Detaylı bilgi için yukarıdaki "5ire için Kurulum" bölümündeki ilgili adımlara bakabilirsiniz. + +2. **Gemini CLI ayarlarını yapılandırın:** + + Gemini CLI'ın ayar dosyasını düzenleyin: + - **macOS/Linux:** `~/.gemini/settings.json` + - **Windows:** `%USERPROFILE%\.gemini\settings.json` + + Aşağıdaki `mcpServers` bloğunu ekleyin: + ```json + { + "theme": "Default", + "selectedAuthType": "###", + "mcpServers": { + "yargi_mcp": { + "command": "uvx", + "args": [ + "yargi-mcp" + ] + } + } + } + ``` + + **Yapılandırma açıklamaları:** + - `"yargi_mcp"`: Sunucunuz için yerel bir isim + - `"command"`: `uvx` komutu (uv'nin paket çalıştırma aracı) + - `"args"`: GitHub'dan doğrudan Yargı MCP'yi çalıştırmak için gerekli argümanlar + +3. **Kullanım:** + - Gemini CLI'ı başlatın + - Yargı MCP araçları otomatik olarak kullanılabilir olacaktır + - Örnek komutlar: + - "Yargıtay'ın mülkiyet hakkı ile ilgili son kararlarını ara" + - "Danıştay'ın imar planı iptaline ilişkin kararlarını bul" + - "Anayasa Mahkemesi'nin ifade özgürlüğü kararlarını getir" + +
+ +
+🛠️ Kullanılabilir Araçlar (MCP Tools) + +Bu FastMCP sunucusu **19 optimize edilmiş MCP aracı** sunar (token verimliliği için optimize edilmiş): + +### **Yargıtay Araçları (Birleşik Bedesten API - Token Optimized)** +*Not: Yargıtay araçları token verimliliği için birleşik Bedesten API'ye entegre edilmiştir* + +### **Danıştay Araçları (Birleşik Bedesten API - Token Optimized)** +*Not: Danıştay araçları token verimliliği için birleşik Bedesten API'ye entegre edilmiştir* + +### **Birleşik Bedesten API Araçları (5 Mahkeme) - 🚀 TOKEN OPTİMİZE** +1. `search_bedesten_unified(phrase, court_types, birimAdi, kararTarihiStart, kararTarihiEnd, ...)`: **5 mahkeme türünü** birleşik arama (Yargıtay, Danıştay, Yerel Hukuk, İstinaf Hukuk, KYB) + **79 daire filtreleme** + **Tarih & Kesin Cümle Arama** +2. `get_bedesten_document_markdown(documentId: str)`: Bedesten API'den herhangi bir belgeyi Markdown formatında getirir (HTML/PDF → Markdown) + +### **Emsal Karar Araçları (UYAP)** +3. `search_emsal_detailed_decisions(keyword, ...)`: Emsal (UYAP) kararlarını detaylı kriterlerle arar. +4. `get_emsal_document_markdown(id: str)`: Belirli bir Emsal kararının metnini Markdown formatında getirir. + +### **Uyuşmazlık Mahkemesi Araçları** +5. `search_uyusmazlik_decisions(icerik, ...)`: Uyuşmazlık Mahkemesi kararlarını çeşitli form kriterleriyle arar. +6. `get_uyusmazlik_document_markdown_from_url(document_url)`: Bir Uyuşmazlık kararını tam URL'sinden alıp Markdown formatında getirir. + +### **Anayasa Mahkemesi Araçları (Birleşik API) - 🚀 TOKEN OPTİMİZE** +7. `search_anayasa_unified(decision_type, keywords_all, ...)`: AYM kararlarını birleşik arama (Norm Denetimi + Bireysel Başvuru) - **4 araç → 2 araç optimizasyonu** +8. `get_anayasa_document_unified(document_url, page_number)`: AYM kararlarını birleşik belge getirme - **sayfalanmış Markdown** içeriği + +### **KİK (Kamu İhale Kurulu) Araçları** +9. `search_kik_decisions(karar_tipi, ...)`: KİK (Kamu İhale Kurulu) kararlarını arar. +10. `get_kik_document_markdown(karar_id, page_number)`: Belirli bir KİK kararını, Base64 ile encode edilmiş `karar_id`'sini kullanarak alır ve **sayfalanmış Markdown** içeriğini getirir. +### **Rekabet Kurumu Araçları** +    * `search_rekabet_kurumu_decisions(KararTuru: Literal[...], ...) -> RekabetSearchResult`: Rekabet Kurumu kararlarını arar. `KararTuru` için kullanıcı dostu isimler kullanılır (örn: "Birleşme ve Devralma"). +    * `get_rekabet_kurumu_document(karar_id: str, page_number: Optional[int] = 1) -> RekabetDocument`: Belirli bir Rekabet Kurumu kararını `karar_id` ile alır. Kararın PDF formatındaki orijinalinden istenen sayfayı ayıklar ve Markdown formatında döndürür. + + +--- + +* **Sayıştay Araçları (3 Karar Türü + 8 Daire Filtreleme):** + * `search_sayistay_genel_kurul(karar_no, karar_tarih_baslangic, karar_tamami, ...)`: Sayıştay Genel Kurul (yorumlayıcı) kararlarını arar. **Tarih aralığı** (2006-2024) + **İçerik arama** (400 karakter) + * `search_sayistay_temyiz_kurulu(ilam_dairesi, kamu_idaresi_turu, temyiz_karar, ...)`: Temyiz Kurulu (itiraz) kararlarını arar. **8 Daire filtreleme** + **Kurum türü** + **Konu sınıflandırması** + * `search_sayistay_daire(yargilama_dairesi, web_karar_metni, hesap_yili, ...)`: Daire (ilk derece denetim) kararlarını arar. **8 Daire filtreleme** + **Hesap yılı** + **İçerik arama** + * `get_sayistay_genel_kurul_document_markdown(decision_id: str)`: Genel Kurul kararının tam metnini Markdown formatında getirir + * `get_sayistay_temyiz_kurulu_document_markdown(decision_id: str)`: Temyiz Kurulu kararının tam metnini Markdown formatında getirir + * `get_sayistay_daire_document_markdown(decision_id: str)`: Daire kararının tam metnini Markdown formatında getirir + +* **KVKK Araçları (Brave Search API + Türkçe Arama):** + * `search_kvkk_decisions(keywords, page, pageSize, ...)`: KVKK (Kişisel Verilerin Korunması Kurulu) kararlarını Brave Search API ile arar. **Türkçe arama** + **Site hedeflemeli** (`site:kvkk.gov.tr "karar özeti"`) + **Sayfalama desteği** + * `get_kvkk_document_markdown(decision_url: str, page_number: Optional[int] = 1)`: KVKK kararının tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa) + +### BDDK Araçları + * `search_bddk_decisions(keywords, page)`: BDDK (Bankacılık Düzenleme ve Denetleme Kurumu) kararlarını arar. **"Karar Sayısı" targeting** + **Spesifik URL filtreleme** (`bddk.org.tr/Mevzuat/DokumanGetir`) + **Optimized search** + * `get_bddk_document_markdown(document_id: str, page_number: Optional[int] = 1)`: BDDK kararının tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa) + +
+ +--- + +
+📊 Kapsamlı İstatistikler & Optimizasyon Başarıları + +🚀 **TOKEN OPTİMİZASYON BAŞARISI:** +- **%61.8 Token Azaltma:** 14,061 → 5,369 tokens (8,692 token tasarrufu) +- **Hedef Aşım:** 10,000 token hedefini 4,631 token aştık +- **Daha Hızlı Yanıt:** Claude AI ile optimize edilmiş etkileşim +- **Korunan İşlevsellik:** %100 özellik desteği devam ediyor + +**GENEL İSTATİSTİKLER:** +- **Toplam Mahkeme/Kurum:** 13 farklı hukuki kurum (KVKK dahil) +- **Toplam MCP Tool:** 19 optimize edilmiş arama ve belge getirme aracı +- **Daire/Kurul Filtreleme:** 87 farklı seçenek (52 Yargıtay + 27 Danıştay + 8 Sayıştay) +- **Tarih Filtreleme:** Birleşik Bedesten API aracında ISO 8601 formatında tam tarih aralığı desteği +- **Kesin Cümle Arama:** Birleşik Bedesten API aracında çift tırnak ile tam cümle arama (`"\"mülkiyet kararı\""` formatı) +- **Birleşik API:** 10 ayrı Bedesten aracı → 2 birleşik araç (search_bedesten_unified + get_bedesten_document_markdown) +- **API Kaynağı:** Dual/Triple API desteği ile maksimum kapsama +- **Tam Türk Adalet Sistemi:** Yerel mahkemelerden en yüksek mahkemelere kadar + +**🏛️ Desteklenen Mahkeme Hiyerarşisi:** +``` +Yerel Mahkemeler → İstinaf → Yargıtay/Danıştay → Anayasa Mahkemesi + ↓ ↓ ↓ ↓ +Bedesten API Bedesten API Dual/Triple API Norm+Bireysel API ++ Tarih + Kesin + Tarih + Kesin + Daire + Tarih + Gelişmiş + Cümle Arama Cümle Arama + Kesin Cümle Arama +``` + +**⚖️ Kapsamlı Filtreleme Özellikleri:** +- **Daire Filtreleme:** 79 seçenek (52 Yargıtay + 27 Danıştay) + - **Yargıtay:** 52 seçenek (1-23 Hukuk, 1-23 Ceza, Genel Kurullar, Başkanlar Kurulu) + - **Danıştay:** 27 seçenek (1-17 Daireler, İdare/Vergi Kurulları, Askeri Mahkemeler) +- **Tarih Filtreleme:** 5 Bedesten API aracında ISO 8601 formatı (YYYY-MM-DDTHH:MM:SS.000Z) + - Tek tarih, tarih aralığı, tek taraflı filtreleme desteği + - Yargıtay, Danıştay, Yerel Hukuk, İstinaf Hukuk, KYB kararları +- **Kesin Cümle Arama:** 5 Bedesten API aracında çift tırnak formatı + - Normal arama: `"mülkiyet kararı"` (kelimeler ayrı ayrı) + - Kesin arama: `"\"mülkiyet kararı\""` (tam cümle olarak) + - Daha kesin sonuçlar için hukuki terimler ve kavramlar + +**🔧 OPTİMİZASYON DETAYLARI:** +- **Anayasa Mahkemesi:** 4 araç → 2 birleşik araç (search_anayasa_unified + get_anayasa_document_unified) +- **Yargıtay & Danıştay:** Ana API araçları birleşik Bedesten API'ye entegre edildi +- **Sayıştay:** 6 araç → 2 birleşik araç (search_sayistay_unified + get_sayistay_document_unified) +- **Parameter Optimizasyonu:** pageSize parametreleri optimize edildi +- **Açıklama Optimizasyonu:** Uzun açıklamalar kısaltıldı (örn: KIK karar_metni) + +
+ +--- + +
+🌐 Web Service / ASGI Deployment + +Yargı MCP artık web servisi olarak da çalıştırılabilir! ASGI desteği sayesinde: + +- **Web API olarak erişim**: HTTP endpoint'leri üzerinden MCP araçlarına erişim +- **Cloud deployment**: Heroku, Railway, Google Cloud Run, AWS Lambda desteği +- **Docker desteği**: Production-ready Docker container +- **FastAPI entegrasyonu**: REST API ve interaktif dokümantasyon + +**Hızlı başlangıç:** +```bash +# ASGI dependencies yükle +pip install yargi-mcp[asgi] + +# Web servisi olarak başlat +python run_asgi.py +# veya +uvicorn asgi_app:app --host 0.0.0.0 --port 8000 +``` + +Detaylı deployment rehberi için: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) + +
+ +--- + +📜 **Lisans** + +Bu proje MIT Lisansı altında lisanslanmıştır. Detaylar için `LICENSE` dosyasına bakınız. diff --git a/saidsurucu-yargi-mcp-f5fa007/__main__.py b/saidsurucu-yargi-mcp-f5fa007/__main__.py new file mode 100644 index 0000000..12ab0a0 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/__main__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +"""Entry point for yargi-mcp package.""" + +from mcp_server_main import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/bireysel_client.py b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/bireysel_client.py new file mode 100644 index 0000000..3d01787 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/bireysel_client.py @@ -0,0 +1,355 @@ +# anayasa_mcp_module/bireysel_client.py +# This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr + +import httpx +from bs4 import BeautifulSoup, Tag +from typing import Dict, Any, List, Optional, Tuple +import logging +import html +import re +import io +from urllib.parse import urlencode, urljoin, quote +from markitdown import MarkItDown +import math # For math.ceil for pagination + +from .models import ( + AnayasaBireyselReportSearchRequest, + AnayasaBireyselReportDecisionDetail, + AnayasaBireyselReportDecisionSummary, + AnayasaBireyselReportSearchResult, + AnayasaBireyselBasvuruDocumentMarkdown, # Model for Bireysel Başvuru document +) + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + +class AnayasaBireyselBasvuruApiClient: + BASE_URL = "https://kararlarbilgibankasi.anayasa.gov.tr" + SEARCH_PATH = "/Ara" + DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page + + def __init__(self, request_timeout: float = 60.0): + self.http_client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=request_timeout, + verify=True, + follow_redirects=True + ) + + def _build_query_params_for_bireysel_report(self, params: AnayasaBireyselReportSearchRequest) -> List[Tuple[str, str]]: + query_params: List[Tuple[str, str]] = [] + query_params.append(("KararBulteni", "1")) # Specific to this report type + + if params.keywords: + for kw in params.keywords: + query_params.append(("KelimeAra[]", kw)) + + if params.page_to_fetch and params.page_to_fetch > 1: + query_params.append(("page", str(params.page_to_fetch))) + + return query_params + + async def search_bireysel_basvuru_report( + self, + params: AnayasaBireyselReportSearchRequest + ) -> AnayasaBireyselReportSearchResult: + final_query_params = self._build_query_params_for_bireysel_report(params) + request_url = self.SEARCH_PATH + + logger.info(f"AnayasaBireyselBasvuruApiClient: Performing Bireysel Başvuru Report search. Path: {request_url}, Params: {final_query_params}") + + try: + response = await self.http_client.get(request_url, params=final_query_params) + response.raise_for_status() + html_content = response.text + except httpx.RequestError as e: + logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP request error during Bireysel Başvuru Report search: {e}") + raise + except Exception as e: + logger.error(f"AnayasaBireyselBasvuruApiClient: Error processing Bireysel Başvuru Report search request: {e}") + raise + + soup = BeautifulSoup(html_content, 'html.parser') + + total_records = None + bulunan_karar_div = soup.find("div", class_="bulunankararsayisi") + if bulunan_karar_div: + match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True)) + if match_records: + total_records = int(match_records.group(1)) + + processed_decisions: List[AnayasaBireyselReportDecisionSummary] = [] + + report_content_area = soup.find("div", class_="HaberBulteni") + if not report_content_area: + logger.warning("HaberBulteni div not found, attempting to parse decision divs from the whole page.") + report_content_area = soup + + decision_divs = report_content_area.find_all("div", class_="KararBulteniBirKarar") + if not decision_divs: + logger.warning("No KararBulteniBirKarar divs found.") + + + for decision_div in decision_divs: + title_tag = decision_div.find("h4") + title_text = title_tag.get_text(strip=True) if title_tag and title_tag.strong else (title_tag.get_text(strip=True) if title_tag else "") + + + alti_cizili_div = decision_div.find("div", class_="AltiCizili") + ref_no, dec_type, body, app_date, dec_date, url_path = "", "", "", "", "", "" + if alti_cizili_div: + link_tag = alti_cizili_div.find("a", href=True) + if link_tag: + ref_no = link_tag.get_text(strip=True) + url_path = link_tag['href'] + + parts_text = alti_cizili_div.get_text(separator="|", strip=True) + parts = [part.strip() for part in parts_text.split("|")] + + # Clean ref_no from the first part if it was extracted from link + if ref_no and parts and parts[0].strip().startswith(ref_no): + parts[0] = parts[0].replace(ref_no, "").strip() + if not parts[0]: parts.pop(0) # Remove empty string if ref_no was the only content + + # Assign parts based on typical order, adjusting for missing ref_no at start + current_idx = 0 + if not ref_no and len(parts) > current_idx and re.match(r"\d+/\d+", parts[current_idx]): # Check if first part is ref_no + ref_no = parts[current_idx] + current_idx += 1 + + dec_type = parts[current_idx] if len(parts) > current_idx else "" + current_idx += 1 + body = parts[current_idx] if len(parts) > current_idx else "" + current_idx += 1 + + app_date_raw = parts[current_idx] if len(parts) > current_idx else "" + current_idx += 1 + dec_date_raw = parts[current_idx] if len(parts) > current_idx else "" + + if app_date_raw and "Başvuru Tarihi :" in app_date_raw: + app_date = app_date_raw.replace("Başvuru Tarihi :", "").strip() + elif app_date_raw: # If label is missing but format matches + app_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', app_date_raw) + if app_date_match: app_date = app_date_match.group(1) + + + if dec_date_raw and "Karar Tarihi :" in dec_date_raw: + dec_date = dec_date_raw.replace("Karar Tarihi :", "").strip() + elif dec_date_raw: # If label is missing but format matches + dec_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', dec_date_raw) + if dec_date_match: dec_date = dec_date_match.group(1) + + + subject_div = decision_div.find(lambda tag: tag.name == 'div' and not tag.has_attr('class') and tag.get_text(strip=True).startswith("BAŞVURU KONUSU :")) + subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else "" + + details_list: List[AnayasaBireyselReportDecisionDetail] = [] + karar_detaylari_div = decision_div.find_next_sibling("div", id="KararDetaylari") # Corrected: was KararDetaylari + if karar_detaylari_div: + table = karar_detaylari_div.find("table", class_="table") + if table and table.find("tbody"): + for row in table.find("tbody").find_all("tr"): + cells = row.find_all("td") + if len(cells) == 4: # Hak, Müdahale İddiası, Sonuç, Giderim + details_list.append(AnayasaBireyselReportDecisionDetail( + hak=cells[0].get_text(strip=True) or "", + mudahale_iddiasi=cells[1].get_text(strip=True) or "", + sonuc=cells[2].get_text(strip=True) or "", + giderim=cells[3].get_text(strip=True) or "", + )) + + full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else "" + + processed_decisions.append(AnayasaBireyselReportDecisionSummary( + title=title_text, + decision_reference_no=ref_no, + decision_page_url=full_decision_page_url, + decision_type_summary=dec_type, + decision_making_body=body, + application_date_summary=app_date, + decision_date_summary=dec_date, + application_subject_summary=subject_text, + details=details_list + )) + + return AnayasaBireyselReportSearchResult( + decisions=processed_decisions, + total_records_found=total_records, + retrieved_page_number=params.page_to_fetch + ) + + def _convert_html_to_markdown_bireysel(self, full_decision_html_content: str) -> Optional[str]: + if not full_decision_html_content: + return None + + processed_html = html.unescape(full_decision_html_content) + soup = BeautifulSoup(processed_html, "html.parser") + html_input_for_markdown = "" + + karar_tab_content = soup.find("div", id="Karar") + if karar_tab_content: + karar_html_span = karar_tab_content.find("span", class_="kararHtml") + if karar_html_span: + word_section = karar_html_span.find("div", class_="WordSection1") + if word_section: + for s in word_section.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'): + s.decompose() + html_input_for_markdown = str(word_section) + else: + logger.warning("AnayasaBireyselBasvuruApiClient: WordSection1 not found in span.kararHtml. Using span.kararHtml content.") + for s in karar_html_span.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'): + s.decompose() + html_input_for_markdown = str(karar_html_span) + else: + logger.warning("AnayasaBireyselBasvuruApiClient: span.kararHtml not found in div#Karar. Using div#Karar content.") + for s in karar_tab_content.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'): + s.decompose() + html_input_for_markdown = str(karar_tab_content) + else: + logger.warning("AnayasaBireyselBasvuruApiClient: div#Karar (KARAR tab) not found. Trying WordSection1 fallback.") + word_section_fallback = soup.find("div", class_="WordSection1") + if word_section_fallback: + for s in word_section_fallback.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'): + s.decompose() + html_input_for_markdown = str(word_section_fallback) + else: + body_tag = soup.find("body") + if body_tag: + for s in body_tag.select('script, style, .item.col-xs-12.col-sm-12, center:has(b), .banner, .footer, .yazdirmaalani, .filtreler, .menu, .altmenu, .geri, .arabuton, .temizlebutonu, form#KararGetir, .TabBaslik, #KararDetaylari, .share-button-container'): + s.decompose() + html_input_for_markdown = str(body_tag) + else: + html_input_for_markdown = processed_html + + markdown_text = None + try: + # Ensure the content is wrapped in basic HTML structure if it's not already + if not html_input_for_markdown.strip().lower().startswith(("{html_input_for_markdown}" + else: + html_content = html_input_for_markdown + + # Convert HTML string to bytes and create BytesIO stream + html_bytes = html_content.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown() + conversion_result = md_converter.convert(html_stream) + markdown_text = conversion_result.text_content + except Exception as e: + logger.error(f"AnayasaBireyselBasvuruApiClient: MarkItDown conversion error: {e}") + return markdown_text + + async def get_decision_document_as_markdown( + self, + document_url_path: str, # e.g. /BB/2021/20295 + page_number: int = 1 + ) -> AnayasaBireyselBasvuruDocumentMarkdown: + full_url = urljoin(self.BASE_URL, document_url_path) + logger.info(f"AnayasaBireyselBasvuruApiClient: Fetching Bireysel Başvuru document for Markdown (page {page_number}) from URL: {full_url}") + + basvuru_no_from_page = None + karar_tarihi_from_page = None + basvuru_tarihi_from_page = None + karari_veren_birim_from_page = None + karar_turu_from_page = None + resmi_gazete_info_from_page = None + + try: + response = await self.http_client.get(full_url) + response.raise_for_status() + html_content_from_api = response.text + + if not isinstance(html_content_from_api, str) or not html_content_from_api.strip(): + logger.warning(f"AnayasaBireyselBasvuruApiClient: Received empty HTML from {full_url}.") + return AnayasaBireyselBasvuruDocumentMarkdown( + source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False + ) + + soup = BeautifulSoup(html_content_from_api, 'html.parser') + + meta_desc_tag = soup.find("meta", attrs={"name": "description"}) + if meta_desc_tag and meta_desc_tag.get("content"): + content = meta_desc_tag["content"] + bn_match = re.search(r"B\.\s*No:\s*([\d\/]+)", content) + if bn_match: basvuru_no_from_page = bn_match.group(1).strip() + + date_match = re.search(r"(\d{1,2}\/\d{1,2}\/\d{4}),\s*§", content) + if date_match: karar_tarihi_from_page = date_match.group(1).strip() + + karar_detaylari_tab = soup.find("div", id="KararDetaylari") + if karar_detaylari_tab: + table = karar_detaylari_tab.find("table", class_="table") + if table: + rows = table.find_all("tr") + for row in rows: + cells = row.find_all("td") + if len(cells) == 2: + key = cells[0].get_text(strip=True) + value = cells[1].get_text(strip=True) + if "Kararı Veren Birim" in key: karari_veren_birim_from_page = value + elif "Karar Türü (Başvuru Sonucu)" in key: karar_turu_from_page = value + elif "Başvuru No" in key and not basvuru_no_from_page: basvuru_no_from_page = value + elif "Başvuru Tarihi" in key: basvuru_tarihi_from_page = value + elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value + elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value + + full_markdown_content = self._convert_html_to_markdown_bireysel(html_content_from_api) + + if not full_markdown_content: + return AnayasaBireyselBasvuruDocumentMarkdown( + source_url=full_url, + basvuru_no_from_page=basvuru_no_from_page, + karar_tarihi_from_page=karar_tarihi_from_page, + basvuru_tarihi_from_page=basvuru_tarihi_from_page, + karari_veren_birim_from_page=karari_veren_birim_from_page, + karar_turu_from_page=karar_turu_from_page, + resmi_gazete_info_from_page=resmi_gazete_info_from_page, + markdown_chunk=None, + current_page=page_number, + total_pages=0, + is_paginated=False + ) + + content_length = len(full_markdown_content) + total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) + if total_pages == 0: total_pages = 1 + + current_page_clamped = max(1, min(page_number, total_pages)) + start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE + end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE + markdown_chunk = full_markdown_content[start_index:end_index] + + return AnayasaBireyselBasvuruDocumentMarkdown( + source_url=full_url, + basvuru_no_from_page=basvuru_no_from_page, + karar_tarihi_from_page=karar_tarihi_from_page, + basvuru_tarihi_from_page=basvuru_tarihi_from_page, + karari_veren_birim_from_page=karari_veren_birim_from_page, + karar_turu_from_page=karar_turu_from_page, + resmi_gazete_info_from_page=resmi_gazete_info_from_page, + markdown_chunk=markdown_chunk, + current_page=current_page_clamped, + total_pages=total_pages, + is_paginated=(total_pages > 1) + ) + + except httpx.RequestError as e: + logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP error fetching Bireysel Başvuru document from {full_url}: {e}") + raise + except Exception as e: + logger.error(f"AnayasaBireyselBasvuruApiClient: General error processing Bireysel Başvuru document from {full_url}: {e}") + raise + + async def close_client_session(self): + if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed: + await self.http_client.aclose() + logger.info("AnayasaBireyselBasvuruApiClient: HTTP client session closed.") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/client.py new file mode 100644 index 0000000..9e15a48 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/client.py @@ -0,0 +1,356 @@ +# anayasa_mcp_module/client.py +# This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr + +import httpx +from bs4 import BeautifulSoup +from typing import Dict, Any, List, Optional, Tuple +import logging +import html +import re +import io +from urllib.parse import urlencode, urljoin, quote +from markitdown import MarkItDown +import math # For math.ceil for pagination + +from .models import ( + AnayasaNormDenetimiSearchRequest, + AnayasaDecisionSummary, + AnayasaReviewedNormInfo, + AnayasaSearchResult, + AnayasaDocumentMarkdown, # Model for Norm Denetimi document +) + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +class AnayasaMahkemesiApiClient: + BASE_URL = "https://normkararlarbilgibankasi.anayasa.gov.tr" + SEARCH_PATH_SEGMENT = "Ara" + DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page + + def __init__(self, request_timeout: float = 60.0): + self.http_client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=request_timeout, + verify=True, + follow_redirects=True + ) + + def _build_search_query_params_for_aym(self, params: AnayasaNormDenetimiSearchRequest) -> List[Tuple[str, str]]: + query_params: List[Tuple[str, str]] = [] + if params.keywords_all: + for kw in params.keywords_all: query_params.append(("KelimeAra[]", kw)) + if params.keywords_any: + for kw in params.keywords_any: query_params.append(("HerhangiBirKelimeAra[]", kw)) + if params.keywords_exclude: + for kw in params.keywords_exclude: query_params.append(("BulunmayanKelimeAra[]", kw)) + if params.period and params.period and params.period != "ALL": query_params.append(("Donemler_id", params.period)) + if params.case_number_esas: query_params.append(("EsasNo", params.case_number_esas)) + if params.decision_number_karar: query_params.append(("KararNo", params.decision_number_karar)) + if params.first_review_date_start: query_params.append(("IlkIncelemeTarihiIlk", params.first_review_date_start)) + if params.first_review_date_end: query_params.append(("IlkIncelemeTarihiSon", params.first_review_date_end)) + if params.decision_date_start: query_params.append(("KararTarihiIlk", params.decision_date_start)) + if params.decision_date_end: query_params.append(("KararTarihiSon", params.decision_date_end)) + if params.application_type and params.application_type and params.application_type != "ALL": query_params.append(("BasvuruTurler_id", params.application_type)) + if params.applicant_general_name: query_params.append(("BasvuranGeneller_id", params.applicant_general_name)) + if params.applicant_specific_name: query_params.append(("BasvuranOzeller_id", params.applicant_specific_name)) + if params.attending_members_names: + for name in params.attending_members_names: query_params.append(("Uyeler_id[]", name)) + if params.rapporteur_name: query_params.append(("Raportorler_id", params.rapporteur_name)) + if params.norm_type and params.norm_type and params.norm_type != "ALL": query_params.append(("NormunTurler_id", params.norm_type)) + if params.norm_id_or_name: query_params.append(("NormunNumarasiAdlar_id", params.norm_id_or_name)) + if params.norm_article: query_params.append(("NormunMaddeNumarasi", params.norm_article)) + if params.review_outcomes: + for outcome_val in params.review_outcomes: + if outcome_val and outcome_val != "ALL": query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_val)) + if params.reason_for_final_outcome and params.reason_for_final_outcome and params.reason_for_final_outcome != "ALL": + query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome)) + if params.basis_constitution_article_numbers: + for article_no in params.basis_constitution_article_numbers: query_params.append(("DayanakHukmu[]", article_no)) + if params.official_gazette_date_start: query_params.append(("ResmiGazeteTarihiIlk", params.official_gazette_date_start)) + if params.official_gazette_date_end: query_params.append(("ResmiGazeteTarihiSon", params.official_gazette_date_end)) + if params.official_gazette_number_start: query_params.append(("ResmiGazeteSayisiIlk", params.official_gazette_number_start)) + if params.official_gazette_number_end: query_params.append(("ResmiGazeteSayisiSon", params.official_gazette_number_end)) + if params.has_press_release and params.has_press_release and params.has_press_release != "ALL": query_params.append(("BasinDuyurusu", params.has_press_release)) + if params.has_dissenting_opinion and params.has_dissenting_opinion and params.has_dissenting_opinion != "ALL": query_params.append(("KarsiOy", params.has_dissenting_opinion)) + if params.has_different_reasoning and params.has_different_reasoning and params.has_different_reasoning != "ALL": query_params.append(("FarkliGerekce", params.has_different_reasoning)) + + # Add pagination and sorting parameters as query params instead of URL path + if params.results_per_page and params.results_per_page != 10: + query_params.append(("SatirSayisi", str(params.results_per_page))) + + if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi": + query_params.append(("Siralama", params.sort_by_criteria)) + + if params.page_to_fetch and params.page_to_fetch > 1: + query_params.append(("page", str(params.page_to_fetch))) + return query_params + + async def search_norm_denetimi_decisions( + self, + params: AnayasaNormDenetimiSearchRequest + ) -> AnayasaSearchResult: + # Use simple /Ara endpoint - the complex path structure seems to cause 404s + request_path = f"/{self.SEARCH_PATH_SEGMENT}" + + final_query_params = self._build_search_query_params_for_aym(params) + logger.info(f"AnayasaMahkemesiApiClient: Performing Norm Denetimi search. Path: {request_path}, Params: {final_query_params}") + + try: + response = await self.http_client.get(request_path, params=final_query_params) + response.raise_for_status() + html_content = response.text + except httpx.RequestError as e: + logger.error(f"AnayasaMahkemesiApiClient: HTTP request error during Norm Denetimi search: {e}") + raise + except Exception as e: + logger.error(f"AnayasaMahkemesiApiClient: Error processing Norm Denetimi search request: {e}") + raise + + soup = BeautifulSoup(html_content, 'html.parser') + + total_records = None + bulunan_karar_div = soup.find("div", class_="bulunankararsayisi") + if not bulunan_karar_div: # Fallback for mobile view + bulunan_karar_div = soup.find("div", class_="bulunankararsayisiMobil") + + if bulunan_karar_div: + match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True)) + if match_records: + total_records = int(match_records.group(1)) + + processed_decisions: List[AnayasaDecisionSummary] = [] + decision_divs = soup.find_all("div", class_="birkarar") + + for decision_div in decision_divs: + link_tag = decision_div.find("a", href=True) + doc_url_path = link_tag['href'] if link_tag else None + decision_page_url_str = urljoin(self.BASE_URL, doc_url_path) if doc_url_path else None + + title_div = decision_div.find("div", class_="bkararbaslik") + ek_no_text_raw = title_div.get_text(strip=True, separator=" ").replace('\xa0', ' ') if title_div else "" + ek_no_match = re.search(r"(E\.\s*\d+/\d+\s*,\s*K\.\s*\d+/\d+)", ek_no_text_raw) + ek_no_text = ek_no_match.group(1) if ek_no_match else ek_no_text_raw.split("Sayılı Karar")[0].strip() + + keyword_count_div = title_div.find("div", class_="BulunanKelimeSayisi") if title_div else None + keyword_count_text = keyword_count_div.get_text(strip=True).replace("Bulunan Kelime Sayısı", "").strip() if keyword_count_div else None + keyword_count = int(keyword_count_text) if keyword_count_text and keyword_count_text.isdigit() else None + + info_div = decision_div.find("div", class_="kararbilgileri") + info_parts = [part.strip() for part in info_div.get_text(separator="|").split("|")] if info_div else [] + + app_type_summary = info_parts[0] if len(info_parts) > 0 else None + applicant_summary = info_parts[1] if len(info_parts) > 1 else None + outcome_summary = info_parts[2] if len(info_parts) > 2 else None + dec_date_raw = info_parts[3] if len(info_parts) > 3 else None + decision_date_summary = dec_date_raw.replace("Karar Tarihi:", "").strip() if dec_date_raw else None + + reviewed_norms_list: List[AnayasaReviewedNormInfo] = [] + details_table_container = decision_div.find_next_sibling("div", class_=re.compile(r"col-sm-12")) # The details table is in a sibling div + if details_table_container: + details_table = details_table_container.find("table", class_="table") + if details_table and details_table.find("tbody"): + for row in details_table.find("tbody").find_all("tr"): + cells = row.find_all("td") + if len(cells) == 6: + reviewed_norms_list.append(AnayasaReviewedNormInfo( + norm_name_or_number=cells[0].get_text(strip=True) or None, + article_number=cells[1].get_text(strip=True) or None, + review_type_and_outcome=cells[2].get_text(strip=True) or None, + outcome_reason=cells[3].get_text(strip=True) or None, + basis_constitution_articles_cited=[a.strip() for a in cells[4].get_text(strip=True).split(',') if a.strip()] if cells[4].get_text(strip=True) else [], + postponement_period=cells[5].get_text(strip=True) or None + )) + + processed_decisions.append(AnayasaDecisionSummary( + decision_reference_no=ek_no_text, + decision_page_url=decision_page_url_str, + keywords_found_count=keyword_count, + application_type_summary=app_type_summary, + applicant_summary=applicant_summary, + decision_outcome_summary=outcome_summary, + decision_date_summary=decision_date_summary, + reviewed_norms=reviewed_norms_list + )) + + return AnayasaSearchResult( + decisions=processed_decisions, + total_records_found=total_records, + retrieved_page_number=params.page_to_fetch + ) + + def _convert_html_to_markdown_norm_denetimi(self, full_decision_html_content: str) -> Optional[str]: + """Converts direct HTML content from an Anayasa Mahkemesi Norm Denetimi decision page to Markdown.""" + if not full_decision_html_content: + return None + + processed_html = html.unescape(full_decision_html_content) + soup = BeautifulSoup(processed_html, "html.parser") + html_input_for_markdown = "" + + karar_tab_content = soup.find("div", id="Karar") # "KARAR" tab content + if karar_tab_content: + karar_metni_div = karar_tab_content.find("div", class_="KararMetni") + if karar_metni_div: + # Remove scripts and styles + for script_tag in karar_metni_div.find_all("script"): script_tag.decompose() + for style_tag in karar_metni_div.find_all("style"): style_tag.decompose() + # Remove "Künye Kopyala" button and other non-content divs + for item_div in karar_metni_div.find_all("div", class_="item col-sm-12"): item_div.decompose() + for modal_div in karar_metni_div.find_all("div", class_="modal fade"): modal_div.decompose() # If any modals + + word_section = karar_metni_div.find("div", class_="WordSection1") + html_input_for_markdown = str(word_section) if word_section else str(karar_metni_div) + else: + html_input_for_markdown = str(karar_tab_content) + else: + # Fallback if specific structure is not found + word_section_fallback = soup.find("div", class_="WordSection1") + if word_section_fallback: + html_input_for_markdown = str(word_section_fallback) + else: + # Last resort: use the whole body or the raw HTML + body_tag = soup.find("body") + html_input_for_markdown = str(body_tag) if body_tag else processed_html + + markdown_text = None + try: + # Ensure the content is wrapped in basic HTML structure if it's not already + if not html_input_for_markdown.strip().lower().startswith(("{html_input_for_markdown}" + else: + html_content = html_input_for_markdown + + # Convert HTML string to bytes and create BytesIO stream + html_bytes = html_content.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown() + conversion_result = md_converter.convert(html_stream) + markdown_text = conversion_result.text_content + except Exception as e: + logger.error(f"AnayasaMahkemesiApiClient: MarkItDown conversion error: {e}") + return markdown_text + + async def get_decision_document_as_markdown( + self, + document_url: str, + page_number: int = 1 + ) -> AnayasaDocumentMarkdown: + """ + Retrieves a specific Anayasa Mahkemesi (Norm Denetimi) decision, + converts its content to Markdown, and returns the requested page/chunk. + """ + full_url = urljoin(self.BASE_URL, document_url) if not document_url.startswith("http") else document_url + logger.info(f"AnayasaMahkemesiApiClient: Fetching Norm Denetimi document for Markdown (page {page_number}) from URL: {full_url}") + + decision_ek_no_from_page = None + decision_date_from_page = None + official_gazette_from_page = None + + try: + # Use a new client instance for document fetching if headers/timeout needs to be different, + # or reuse self.http_client if settings are compatible. For now, self.http_client. + get_response = await self.http_client.get(full_url, headers={"Accept": "text/html"}) + get_response.raise_for_status() + html_content_from_api = get_response.text + + if not isinstance(html_content_from_api, str) or not html_content_from_api.strip(): + logger.warning(f"AnayasaMahkemesiApiClient: Received empty or non-string HTML from URL {full_url}.") + return AnayasaDocumentMarkdown( + source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False + ) + + # Extract metadata from the page content (E.K. No, Date, RG) + soup = BeautifulSoup(html_content_from_api, "html.parser") + karar_metni_div = soup.find("div", class_="KararMetni") # Usually within div#Karar + if not karar_metni_div: # Fallback if not in KararMetni + karar_metni_div = soup.find("div", class_="WordSection1") + + # Initialize with empty string defaults + decision_ek_no_from_page = "" + decision_date_from_page = "" + official_gazette_from_page = "" + + if karar_metni_div: + # Attempt to find E.K. No (Esas No, Karar No) + # Norm Denetimi pages often have this in bold

tags directly or in the WordSection1 + # Look for patterns like "Esas No.: YYYY/NN" and "Karar No.: YYYY/NN" + + esas_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Esas No.:" in tag.find("b").get_text()) + karar_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar No.:" in tag.find("b").get_text()) + karar_tarihi_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar tarihi:" in tag.find("b").get_text()) # Less common on Norm pages + resmi_gazete_tag = karar_metni_div.find(lambda tag: tag.name == "p" and ("Resmî Gazete tarih ve sayısı:" in tag.get_text() or "Resmi Gazete tarih/sayı:" in tag.get_text())) + + + if esas_no_tag and esas_no_tag.find("b") and karar_no_tag and karar_no_tag.find("b"): + esas_str = esas_no_tag.find("b").get_text(strip=True).replace('Esas No.:', '').strip() + karar_str = karar_no_tag.find("b").get_text(strip=True).replace('Karar No.:', '').strip() + decision_ek_no_from_page = f"E.{esas_str}, K.{karar_str}" + + if karar_tarihi_tag and karar_tarihi_tag.find("b"): + decision_date_from_page = karar_tarihi_tag.find("b").get_text(strip=True).replace("Karar tarihi:", "").strip() + elif karar_metni_div: # Fallback for Karar Tarihi if not in specific tag + date_match = re.search(r"Karar Tarihi\s*:\s*([\d\.]+)", karar_metni_div.get_text()) # Norm pages often use DD.MM.YYYY + if date_match: decision_date_from_page = date_match.group(1).strip() + + + if resmi_gazete_tag: + # Try to get the bold part first if it exists + bold_rg_tag = resmi_gazete_tag.find("b") + rg_text_content = bold_rg_tag.get_text(strip=True) if bold_rg_tag else resmi_gazete_tag.get_text(strip=True) + official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip() + + + full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api) + + if not full_markdown_content: + return AnayasaDocumentMarkdown( + source_url=full_url, + decision_reference_no_from_page=decision_ek_no_from_page, + decision_date_from_page=decision_date_from_page, + official_gazette_info_from_page=official_gazette_from_page, + markdown_chunk=None, + current_page=page_number, + total_pages=0, + is_paginated=False + ) + + content_length = len(full_markdown_content) + total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) + if total_pages == 0: total_pages = 1 + + current_page_clamped = max(1, min(page_number, total_pages)) + start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE + end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE + markdown_chunk = full_markdown_content[start_index:end_index] + + return AnayasaDocumentMarkdown( + source_url=full_url, + decision_reference_no_from_page=decision_ek_no_from_page, + decision_date_from_page=decision_date_from_page, + official_gazette_info_from_page=official_gazette_from_page, + markdown_chunk=markdown_chunk, + current_page=current_page_clamped, + total_pages=total_pages, + is_paginated=(total_pages > 1) + ) + + except httpx.RequestError as e: + logger.error(f"AnayasaMahkemesiApiClient: HTTP error fetching Norm Denetimi document from {full_url}: {e}") + raise + except Exception as e: + logger.error(f"AnayasaMahkemesiApiClient: General error processing Norm Denetimi document from {full_url}: {e}") + raise + + async def close_client_session(self): + if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed: + await self.http_client.aclose() + logger.info("AnayasaMahkemesiApiClient (Norm Denetimi): HTTP client session closed.") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/models.py new file mode 100644 index 0000000..f5e74e1 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/models.py @@ -0,0 +1,230 @@ +# anayasa_mcp_module/models.py + +from pydantic import BaseModel, Field, HttpUrl +from typing import List, Optional, Dict, Any, Literal +from enum import Enum + +# --- Enums (AnayasaDonemEnum, etc. - same as before) --- +class AnayasaDonemEnum(str, Enum): + TUMU = "ALL" + DONEM_1961 = "1" + DONEM_1982 = "2" + + +class AnayasaVarYokEnum(str, Enum): + TUMU = "ALL" + YOK = "0" + VAR = "1" + + +class AnayasaIncelemeSonucuEnum(str, Enum): + TUMU = "ALL" + ESAS_ACILMAMIS_SAYILMA = "1" + ESAS_IPTAL = "2" + ESAS_KARAR_YER_OLMADIGI = "3" + ESAS_RET = "4" + ILK_ACILMAMIS_SAYILMA = "5" + ILK_ISIN_GERI_CEVRILMESI = "6" + ILK_KARAR_YER_OLMADIGI = "7" + ILK_RET = "8" + KANUN_6216_M43_4_IPTAL = "12" + +class AnayasaSonucGerekcesiEnum(str, Enum): + TUMU = "ALL" + ANAYASAYA_AYKIRI_DEGIL = "29" + ANAYASAYA_ESAS_YONUNDEN_AYKIRILIK = "1" + ANAYASAYA_ESAS_YONUNDEN_UYGUNLUK = "2" + ANAYASAYA_SEKIL_ESAS_UYGUNLUK = "30" + ANAYASAYA_SEKIL_YONUNDEN_AYKIRILIK = "3" + ANAYASAYA_SEKIL_YONUNDEN_UYGUNLUK = "4" + AYKIRILIK_ANAYASAYA_ESAS_YONUNDEN_DUPLICATE = "27" + BASVURU_KARARI = "5" + DENETIM_DISI = "6" + DIGER_GEREKCE_1 = "7" + DIGER_GEREKCE_2 = "8" + EKSIKLIGIN_GIDERILMEMESI = "9" + GEREKCE = "10" + GOREV = "11" + GOREV_YETKI = "12" + GOREVLI_MAHKEME = "13" + GORULMEKTE_OLAN_DAVA = "14" + MAHKEME = "15" + NORMDA_DEGISIKLIK_YAPILMASI = "16" + NORMUN_YURURLUKTEN_KALDIRILMASI = "17" + ON_YIL_YASAGI = "18" + SURE = "19" + USULE_UYMAMA = "20" + UYGULANACAK_NORM = "21" + UYGULANAMAZ_HALE_GELME = "22" + YETKI = "23" + YETKI_SURE = "24" + YOK_HUKMUNDE_OLMAMA = "25" + YOKLUK = "26" +# --- End Enums --- + +class AnayasaNormDenetimiSearchRequest(BaseModel): + """Model for Anayasa Mahkemesi (Norm Denetimi) search request for the MCP tool.""" + keywords_all: Optional[List[str]] = Field(default_factory=list, description="Keywords for AND logic (KelimeAra[]).") + keywords_any: Optional[List[str]] = Field(default_factory=list, description="Keywords for OR logic (HerhangiBirKelimeAra[]).") + keywords_exclude: Optional[List[str]] = Field(default_factory=list, description="Keywords to exclude (BulunmayanKelimeAra[]).") + period: Optional[Literal["ALL", "1", "2"]] = Field(default="ALL", description="Constitutional period (Donemler_id).") + case_number_esas: str = Field("", description="Case registry number (EsasNo), e.g., '2023/123'.") + decision_number_karar: str = Field("", description="Decision number (KararNo), e.g., '2023/456'.") + first_review_date_start: str = Field("", description="First review start date (IlkIncelemeTarihiIlk), format DD/MM/YYYY.") + first_review_date_end: str = Field("", description="First review end date (IlkIncelemeTarihiSon), format DD/MM/YYYY.") + decision_date_start: str = Field("", description="Decision start date (KararTarihiIlk), format DD/MM/YYYY.") + decision_date_end: str = Field("", description="Decision end date (KararTarihiSon), format DD/MM/YYYY.") + application_type: Optional[Literal["ALL", "1", "2", "3"]] = Field(default="ALL", description="Type of application (BasvuruTurler_id).") + applicant_general_name: str = Field("", description="General applicant name (BasvuranGeneller_id).") + applicant_specific_name: str = Field("", description="Specific applicant name (BasvuranOzeller_id).") + official_gazette_date_start: str = Field("", description="Official Gazette start date (ResmiGazeteTarihiIlk), format DD/MM/YYYY.") + official_gazette_date_end: str = Field("", description="Official Gazette end date (ResmiGazeteTarihiSon), format DD/MM/YYYY.") + official_gazette_number_start: str = Field("", description="Official Gazette starting number (ResmiGazeteSayisiIlk).") + official_gazette_number_end: str = Field("", description="Official Gazette ending number (ResmiGazeteSayisiSon).") + has_press_release: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Press release available (BasinDuyurusu).") + has_dissenting_opinion: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Dissenting opinion exists (KarsiOy).") + has_different_reasoning: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Different reasoning exists (FarkliGerekce).") + attending_members_names: Optional[List[str]] = Field(default_factory=list, description="List of attending members' exact names (Uyeler_id[]).") + rapporteur_name: str = Field("", description="Rapporteur's exact name (Raportorler_id).") + norm_type: Optional[Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"]] = Field(default="ALL", description="Type of the reviewed norm (NormunTurler_id).") + norm_id_or_name: str = Field("", description="Number or name of the norm (NormunNumarasiAdlar_id).") + norm_article: str = Field("", description="Article number of the norm (NormunMaddeNumarasi).") + review_outcomes: Optional[List[Literal["1", "2", "3", "4", "5", "6", "7", "8", "12"]]] = Field(default_factory=list, description="List of review types and outcomes (IncelemeTuruKararSonuclar_id[]).") + reason_for_final_outcome: Optional[Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "29", "30"]] = Field(default="ALL", description="Main reason for the decision outcome (KararSonucununGerekcesi).") + basis_constitution_article_numbers: Optional[List[str]] = Field(default_factory=list, description="List of supporting Constitution article numbers (DayanakHukmu[]).") + results_per_page: int = Field(10, ge=1, le=10, description="Results per page.") + page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for results list.") + sort_by_criteria: str = Field("KararTarihi", description="Sort criteria. Options: 'KararTarihi', 'YayinTarihi', 'Toplam' (keyword count).") + +class AnayasaReviewedNormInfo(BaseModel): + """Details of a norm reviewed within an AYM decision summary.""" + norm_name_or_number: str = Field("", description="Norm name or number") + article_number: str = Field("", description="Article number") + review_type_and_outcome: str = Field("", description="Review type and outcome") + outcome_reason: str = Field("", description="Outcome reason") + basis_constitution_articles_cited: List[str] = Field(default_factory=list) + postponement_period: str = Field("", description="Postponement period") + +class AnayasaDecisionSummary(BaseModel): + """Model for a single Anayasa Mahkemesi (Norm Denetimi) decision summary from search results.""" + decision_reference_no: str = Field("", description="Decision reference number") + decision_page_url: str = Field("", description="Decision page URL") + keywords_found_count: Optional[int] = Field(0, description="Keywords found count") + application_type_summary: str = Field("", description="Application type summary") + applicant_summary: str = Field("", description="Applicant summary") + decision_outcome_summary: str = Field("", description="Decision outcome summary") + decision_date_summary: str = Field("", description="Decision date summary") + reviewed_norms: List[AnayasaReviewedNormInfo] = Field(default_factory=list) + +class AnayasaSearchResult(BaseModel): + """Model for the overall search result for Anayasa Mahkemesi Norm Denetimi decisions.""" + decisions: List[AnayasaDecisionSummary] + total_records_found: int = Field(0, description="Total records found") + retrieved_page_number: int = Field(1, description="Retrieved page number") + +class AnayasaDocumentMarkdown(BaseModel): + """ + Model for an Anayasa Mahkemesi (Norm Denetimi) decision document, containing a chunk of Markdown content + and pagination information. + """ + source_url: HttpUrl + decision_reference_no_from_page: str = Field("", description="E.K. No parsed from the document page.") + decision_date_from_page: str = Field("", description="Decision date parsed from the document page.") + official_gazette_info_from_page: str = Field("", description="Official Gazette info parsed from the document page.") + markdown_chunk: str = Field("", description="A 5,000 character chunk of the Markdown content.") # Corrected chunk size + current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).") + total_pages: int = Field(description="Total number of pages for the full markdown content.") + is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.") + + +# --- Models for Anayasa Mahkemesi - Bireysel Başvuru Karar Raporu --- + +class AnayasaBireyselReportSearchRequest(BaseModel): + """Model for Anayasa Mahkemesi (Bireysel Başvuru) 'Karar Arama Raporu' search request.""" + keywords: Optional[List[str]] = Field(default_factory=list, description="Keywords for AND logic (KelimeAra[]).") + page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for the report (page). Default is 1.") + +class AnayasaBireyselReportDecisionDetail(BaseModel): + """Details of a specific right/claim within a Bireysel Başvuru decision summary in a report.""" + hak: str = Field("", description="İhlal edildiği iddia edilen hak (örneğin, Mülkiyet hakkı).") + mudahale_iddiasi: str = Field("", description="İhlale neden olan müdahale iddiası.") + sonuc: str = Field("", description="İnceleme sonucu (örneğin, İhlal, Düşme).") + giderim: str = Field("", description="Kararlaştırılan giderim (örneğin, Yeniden yargılama).") + +class AnayasaBireyselReportDecisionSummary(BaseModel): + """Model for a single Anayasa Mahkemesi (Bireysel Başvuru) decision summary from a 'Karar Arama Raporu'.""" + title: str = Field("", description="Başvurunun başlığı (e.g., 'HASAN DURMUŞ Başvurusuna İlişkin Karar').") + decision_reference_no: str = Field("", description="Başvuru Numarası (e.g., '2019/19126').") + decision_page_url: str = Field("", description="URL to the full decision page.") + decision_type_summary: str = Field("", description="Karar Türü (Başvuru Sonucu) (e.g., 'Esas (İhlal)').") + decision_making_body: str = Field("", description="Kararı Veren Birim (e.g., 'Genel Kurul', 'Birinci Bölüm').") + application_date_summary: str = Field("", description="Başvuru Tarihi (DD/MM/YYYY).") + decision_date_summary: str = Field("", description="Karar Tarihi (DD/MM/YYYY).") + application_subject_summary: str = Field("", description="Başvuru konusunun özeti.") + details: List[AnayasaBireyselReportDecisionDetail] = Field(default_factory=list, description="İncelenen haklar ve sonuçlarına ilişkin detaylar.") + +class AnayasaBireyselReportSearchResult(BaseModel): + """Model for the overall search result for Anayasa Mahkemesi 'Karar Arama Raporu'.""" + decisions: List[AnayasaBireyselReportDecisionSummary] + total_records_found: int = Field(0, description="Raporda bulunan toplam karar sayısı.") + retrieved_page_number: int = Field(description="Alınan rapor sayfa numarası.") + + +class AnayasaBireyselBasvuruDocumentMarkdown(BaseModel): + """ + Model for an Anayasa Mahkemesi (Bireysel Başvuru) decision document, containing a chunk of Markdown content + and pagination information. Fetched from /BB/YYYY/NNNN paths. + """ + source_url: HttpUrl + basvuru_no_from_page: Optional[str] = Field(None, description="Başvuru Numarası (B.No) parsed from the document page.") + karar_tarihi_from_page: Optional[str] = Field(None, description="Decision date parsed from the document page.") + basvuru_tarihi_from_page: Optional[str] = Field(None, description="Application date parsed from the document page.") + karari_veren_birim_from_page: Optional[str] = Field(None, description="Deciding body (Bölüm/Genel Kurul) parsed from the document page.") + karar_turu_from_page: Optional[str] = Field(None, description="Decision type (Başvuru Sonucu) parsed from the document page.") + resmi_gazete_info_from_page: Optional[str] = Field(None, description="Official Gazette info parsed from the document page, if available.") + markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content.") + current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).") + total_pages: int = Field(description="Total number of pages for the full markdown content.") + is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.") + +# --- End Models for Bireysel Başvuru --- + +# --- Unified Models --- +class AnayasaUnifiedSearchRequest(BaseModel): + """Unified search request for both Norm Denetimi and Bireysel Başvuru.""" + decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Decision type: norm_denetimi or bireysel_basvuru") + + # Common parameters + keywords: List[str] = Field(default_factory=list, description="Keywords to search for") + page_to_fetch: int = Field(1, ge=1, le=100, description="Page number to fetch (1-100)") + results_per_page: int = Field(10, ge=1, le=100, description="Results per page (1-100)") + + # Norm Denetimi specific parameters (ignored for bireysel_basvuru) + keywords_all: List[str] = Field(default_factory=list, description="All keywords must be present (norm_denetimi only)") + keywords_any: List[str] = Field(default_factory=list, description="Any of these keywords (norm_denetimi only)") + decision_type_norm: Literal["ALL", "1", "2", "3"] = Field("ALL", description="Decision type for norm denetimi") + application_date_start: str = Field("", description="Application start date (norm_denetimi only)") + application_date_end: str = Field("", description="Application end date (norm_denetimi only)") + + # Bireysel Başvuru specific parameters (ignored for norm_denetimi) + decision_start_date: str = Field("", description="Decision start date (bireysel_basvuru only)") + decision_end_date: str = Field("", description="Decision end date (bireysel_basvuru only)") + norm_type: Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"] = Field("ALL", description="Norm type (bireysel_basvuru only)") + subject_category: str = Field("", description="Subject category (bireysel_basvuru only)") + +class AnayasaUnifiedSearchResult(BaseModel): + """Unified search result containing decisions from either system.""" + decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Type of decisions returned") + decisions: List[Dict[str, Any]] = Field(default_factory=list, description="Decision list (structure varies by type)") + total_records_found: int = Field(0, description="Total number of records found") + retrieved_page_number: int = Field(1, description="Page number that was retrieved") + +class AnayasaUnifiedDocumentMarkdown(BaseModel): + """Unified document model for both Norm Denetimi and Bireysel Başvuru.""" + decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Type of document") + source_url: HttpUrl = Field(..., description="Source URL of the document") + document_data: Dict[str, Any] = Field(default_factory=dict, description="Document content and metadata") + markdown_chunk: Optional[str] = Field(None, description="Markdown content chunk") + current_page: int = Field(1, description="Current page number") + total_pages: int = Field(1, description="Total number of pages") + is_paginated: bool = Field(False, description="Whether document is paginated") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/unified_client.py b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/unified_client.py new file mode 100644 index 0000000..1319b85 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/unified_client.py @@ -0,0 +1,122 @@ +# anayasa_mcp_module/unified_client.py +# Unified client for both Norm Denetimi and Bireysel Başvuru + +import logging +from typing import Optional +from urllib.parse import urlparse + +from .models import ( + AnayasaUnifiedSearchRequest, + AnayasaUnifiedSearchResult, + AnayasaUnifiedDocumentMarkdown, + # Removed AnayasaDecisionTypeEnum - now using string literals + AnayasaNormDenetimiSearchRequest, + AnayasaBireyselReportSearchRequest +) +from .client import AnayasaMahkemesiApiClient +from .bireysel_client import AnayasaBireyselBasvuruApiClient + +logger = logging.getLogger(__name__) + +class AnayasaUnifiedClient: + """Unified client that handles both Norm Denetimi and Bireysel Başvuru searches.""" + + def __init__(self, request_timeout: float = 60.0): + self.norm_client = AnayasaMahkemesiApiClient(request_timeout) + self.bireysel_client = AnayasaBireyselBasvuruApiClient(request_timeout) + + async def search_unified(self, params: AnayasaUnifiedSearchRequest) -> AnayasaUnifiedSearchResult: + """Unified search that routes to appropriate client based on decision_type.""" + + if params.decision_type == "norm_denetimi": + # Convert to norm denetimi request + norm_params = AnayasaNormDenetimiSearchRequest( + keywords_all=params.keywords_all or params.keywords, + keywords_any=params.keywords_any, + application_type=params.decision_type_norm, + page_to_fetch=params.page_to_fetch, + results_per_page=params.results_per_page + ) + + result = await self.norm_client.search_norm_denetimi_decisions(norm_params) + + # Convert to unified format + decisions_list = [decision.model_dump() for decision in result.decisions] + + return AnayasaUnifiedSearchResult( + decision_type="norm_denetimi", + decisions=decisions_list, + total_records_found=result.total_records_found, + retrieved_page_number=result.retrieved_page_number + ) + + elif params.decision_type == "bireysel_basvuru": + # Convert to bireysel başvuru request + bireysel_params = AnayasaBireyselReportSearchRequest( + keywords=params.keywords, + decision_start_date=params.decision_start_date, + decision_end_date=params.decision_end_date, + norm_type=params.norm_type, + subject_category=params.subject_category, + page_to_fetch=params.page_to_fetch, + results_per_page=params.results_per_page + ) + + result = await self.bireysel_client.search_bireysel_basvuru_report(bireysel_params) + + # Convert to unified format + decisions_list = [decision.model_dump() for decision in result.decisions] + + return AnayasaUnifiedSearchResult( + decision_type="bireysel_basvuru", + decisions=decisions_list, + total_records_found=result.total_records_found, + retrieved_page_number=result.retrieved_page_number + ) + + else: + raise ValueError(f"Unsupported decision type: {params.decision_type}") + + async def get_document_unified(self, document_url: str, page_number: int = 1) -> AnayasaUnifiedDocumentMarkdown: + """Unified document retrieval that auto-detects the appropriate client.""" + + # Auto-detect decision type based on URL + parsed_url = urlparse(document_url) + + if "normkararlarbilgibankasi" in parsed_url.netloc or "/ND/" in document_url: + # Norm Denetimi document + result = await self.norm_client.get_decision_document_as_markdown(document_url, page_number) + + return AnayasaUnifiedDocumentMarkdown( + decision_type="norm_denetimi", + source_url=result.source_url, + document_data=result.model_dump(), + markdown_chunk=result.markdown_chunk, + current_page=result.current_page, + total_pages=result.total_pages, + is_paginated=result.is_paginated + ) + + elif "kararlarbilgibankasi" in parsed_url.netloc or "/BB/" in document_url: + # Bireysel Başvuru document + result = await self.bireysel_client.get_decision_document_as_markdown(document_url, page_number) + + return AnayasaUnifiedDocumentMarkdown( + decision_type="bireysel_basvuru", + source_url=result.source_url, + document_data=result.model_dump(), + markdown_chunk=result.markdown_chunk, + current_page=result.current_page, + total_pages=result.total_pages, + is_paginated=result.is_paginated + ) + + else: + raise ValueError(f"Cannot determine document type from URL: {document_url}") + + async def close_client_session(self): + """Close both client sessions.""" + if hasattr(self.norm_client, 'close_client_session'): + await self.norm_client.close_client_session() + if hasattr(self.bireysel_client, 'close_client_session'): + await self.bireysel_client.close_client_session() \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/asgi_app.py b/saidsurucu-yargi-mcp-f5fa007/asgi_app.py new file mode 100644 index 0000000..ba5a1f4 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/asgi_app.py @@ -0,0 +1,566 @@ +""" +ASGI application for Yargı MCP Server + +This module provides ASGI/HTTP access to the Yargı MCP server, +allowing it to be deployed as a web service with FastAPI wrapper +for Stripe webhook integration. + +Usage: + uvicorn asgi_app:app --host 0.0.0.0 --port 8000 +""" + +import os +import time +import logging +from datetime import datetime, timedelta +from fastapi import FastAPI, Request, HTTPException, Query +from fastapi.responses import JSONResponse, HTMLResponse +from fastapi.exception_handlers import http_exception_handler +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import Response +from starlette.requests import Request as StarletteRequest + +# Import the MCP app creator function +from mcp_server_main import create_app + +# Import Stripe webhook router +from stripe_webhook import router as stripe_router + +# Import simplified MCP Auth HTTP adapter +from mcp_auth_http_simple import router as mcp_auth_router + +# OAuth configuration from environment variables +CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com") +BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com") + +# Setup logging +logger = logging.getLogger(__name__) + +# Configure CORS and Auth middleware +cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") + +# Import FastMCP Bearer Auth Provider +from fastmcp.server.auth import BearerAuthProvider +from fastmcp.server.auth.providers.bearer import RSAKeyPair + +# Clerk JWT configuration for Bearer token validation +CLERK_SECRET_KEY = os.getenv("CLERK_SECRET_KEY") +CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://accounts.yargimcp.com") +CLERK_PUBLISHABLE_KEY = os.getenv("CLERK_PUBLISHABLE_KEY") + +# Configure Bearer token authentication +bearer_auth = None +if CLERK_SECRET_KEY and CLERK_ISSUER: + # Production: Use Clerk JWKS endpoint for token validation + bearer_auth = BearerAuthProvider( + jwks_uri=f"{CLERK_ISSUER}/.well-known/jwks.json", + issuer=CLERK_ISSUER, + algorithm="RS256", + audience=None, # Disable audience validation - Clerk uses different audience format + required_scopes=[] # Disable scope validation - Clerk JWT has ['read', 'search'] + ) + logger.info(f"Bearer auth configured with Clerk JWKS: {CLERK_ISSUER}/.well-known/jwks.json") +else: + # Development: Generate RSA key pair for testing + logger.warning("No Clerk credentials found - using development RSA key pair") + dev_key_pair = RSAKeyPair.generate() + bearer_auth = BearerAuthProvider( + public_key=dev_key_pair.public_key, + issuer="https://dev.yargimcp.com", + audience="dev-mcp-server", + required_scopes=["yargi.read"] + ) + + # Generate a test token for development + dev_token = dev_key_pair.create_token( + subject="dev-user", + issuer="https://dev.yargimcp.com", + audience="dev-mcp-server", + scopes=["yargi.read", "yargi.search"], + expires_in_seconds=3600 * 24 # 24 hours for development + ) + logger.info(f"Development Bearer token: {dev_token}") + +custom_middleware = [ + Middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS", "DELETE"], + allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"], + ), +] + +# Create MCP app with Bearer authentication +mcp_server = create_app(auth=bearer_auth) + +# Add Starlette middleware to FastAPI (not MCP) +# MCP already has Bearer auth, no need for additional middleware on MCP level + +# Create MCP Starlette sub-application with root path - mount will add /mcp prefix +mcp_app = mcp_server.http_app(path="/") + +# Configure JSON encoder for proper Turkish character support +import json +from fastapi.responses import JSONResponse + +class UTF8JSONResponse(JSONResponse): + def __init__(self, content=None, status_code=200, headers=None, **kwargs): + if headers is None: + 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, + ensure_ascii=False, + allow_nan=False, + indent=None, + separators=(",", ":"), + ).encode("utf-8") + +# Create FastAPI wrapper application +app = FastAPI( + title="Yargı MCP Server", + description="MCP server for Turkish legal databases with OAuth authentication", + version="0.1.0", + middleware=custom_middleware, + default_response_class=UTF8JSONResponse # Use UTF-8 JSON encoder +) + +# Add Stripe webhook router to FastAPI +app.include_router(stripe_router, prefix="/api") + +# Add MCP Auth HTTP adapter to FastAPI (handles OAuth endpoints) +app.include_router(mcp_auth_router) + +# 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""" + return JSONResponse({ + "status": "healthy", + "service": "Yargı MCP Server", + "version": "0.1.0", + "tools_count": len(mcp_server._tool_manager._tools), + "auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true" + }) + +# Add explicit redirect for /mcp to /mcp/ with method preservation +@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"]) +async def redirect_to_slash(request: Request): + """Redirect /mcp to /mcp/ preserving HTTP method with 308""" + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/mcp/", status_code=308) + +# Mount MCP app at /mcp/ with trailing slash +app.mount("/mcp/", mcp_app) + +# Set the lifespan context after mounting +app.router.lifespan_context = mcp_app.lifespan + + +# SSE transport deprecated - removed + +# FastAPI root endpoint +@app.get("/") +async def root(): + """Root endpoint with service information""" + return JSONResponse({ + "service": "Yargı MCP Server", + "description": "MCP server for Turkish legal databases with OAuth authentication", + "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)", + "Emsal (Precedent)", + "Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)", + "Anayasa Mahkemesi (Constitutional Court)", + "Kamu İhale Kurulu (Public Procurement Authority)", + "Rekabet Kurumu (Competition Authority)", + "Sayıştay (Court of Accounts)", + "Bedesten API (Multiple courts)" + ], + "authentication": { + "enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true", + "type": "OAuth 2.0 via Clerk", + "issuer": os.getenv("CLERK_ISSUER", "https://clerk.accounts.dev"), + "providers": ["google"], + "flow": "authorization_code" + } + }) + +# OAuth 2.0 Authorization Server Metadata proxy (for MCP clients that can't reach Clerk directly) +# MCP Auth Toolkit expects this to be under /mcp/.well-known/oauth-authorization-server +@app.get("/mcp/.well-known/oauth-authorization-server") +async def oauth_authorization_server(): + """OAuth 2.0 Authorization Server Metadata proxy to Clerk - MCP Auth Toolkit standard location""" + return JSONResponse({ + "issuer": BASE_URL, + "authorization_endpoint": "https://yargimcp.com/mcp-callback", + "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 +@app.get("/.well-known/oauth-authorization-server/mcp") +async def oauth_authorization_server_mcp_suffix(): + """OAuth 2.0 Authorization Server Metadata - Claude AI MCP specific format""" + return JSONResponse({ + "issuer": BASE_URL, + "authorization_endpoint": "https://yargimcp.com/mcp-callback", + "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 JSONResponse({ + "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" + }) + +# Keep root level for compatibility with some MCP clients +@app.get("/.well-known/oauth-authorization-server") +async def oauth_authorization_server_root(): + """OAuth 2.0 Authorization Server Metadata proxy to Clerk - root level for compatibility""" + return JSONResponse({ + "issuer": BASE_URL, + "authorization_endpoint": "https://yargimcp.com/mcp-callback", + "token_endpoint": f"{BASE_URL}/token", + "jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "none"], + "scopes_supported": ["read", "search", "openid", "profile", "email"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"], + "code_challenge_methods_supported": ["S256"], + "service_documentation": f"{BASE_URL}/mcp", + "registration_endpoint": f"{BASE_URL}/register", + "resource_documentation": f"{BASE_URL}/mcp" + }) + +# Note: GET /mcp is handled by the mounted MCP app itself +# This prevents 405 Method Not Allowed errors on POST requests + +# OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required +@app.get("/.well-known/oauth-protected-resource") +async def oauth_protected_resource(): + """OAuth 2.0 Protected Resource Metadata as required by MCP spec""" + return JSONResponse({ + "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 JSONResponse({ + "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 JSONResponse({ + "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": "/auth/callback", + "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""" + tools = [] + for tool in mcp_server._tool_manager._tools.values(): + tools.append({ + "name": tool.name, + "description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description + }) + + return JSONResponse({ + "status": "operational", + "tools": tools, + "total_tools": len(tools), + "transport": "streamable_http", + "architecture": "FastAPI wrapper + MCP Starlette sub-app", + "auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled" + }) + +# Note: JWT token validation is now handled entirely by Clerk +# All authentication flows use Clerk JWT tokens directly + +async def validate_clerk_session(request: Request, clerk_token: str = None) -> str: + """Validate Clerk session from cookies or JWT token and return user_id""" + logger.info(f"Validating Clerk session - token provided: {bool(clerk_token)}") + + try: + # Try to import Clerk SDK + from clerk_backend_api import Clerk + clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY")) + + # Try JWT token first (from URL parameter) + if clerk_token: + logger.info("Validating Clerk JWT token from URL parameter") + try: + # Extract session_id from JWT token and verify with Clerk + import jwt + decoded_token = jwt.decode(clerk_token, options={"verify_signature": False}) + session_id = decoded_token.get("sid") # Use standard JWT 'sid' claim + + if session_id: + # Verify with Clerk using session_id + session = clerk.sessions.verify(session_id=session_id, token=clerk_token) + user_id = session.user_id if session else None + + if user_id: + logger.info(f"JWT token validation successful - user_id: {user_id}") + return user_id + else: + logger.error("JWT token validation failed - no user_id in session") + else: + logger.error("No session_id found in JWT token") + except Exception as e: + logger.error(f"JWT token validation failed: {str(e)}") + # Fall through to cookie validation + + # Fallback to cookie validation + logger.info("Attempting cookie-based session validation") + clerk_session = request.cookies.get("__session") + if not clerk_session: + logger.error("No Clerk session cookie found") + raise HTTPException(status_code=401, detail="No Clerk session found") + + # Validate session with Clerk + session = clerk.sessions.verify_session(clerk_session) + logger.info(f"Cookie session validation successful - user_id: {session.user_id}") + return session.user_id + + except ImportError: + # Fallback for development without Clerk SDK + logger.warning("Clerk SDK not available - using development fallback") + return "dev_user_123" + except Exception as e: + logger.error(f"Session validation failed: {str(e)}") + raise HTTPException(status_code=401, detail=f"Session validation failed: {str(e)}") + +# 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""" + logger.info(f"MCP OAuth callback - clerk_token provided: {bool(clerk_token)}") + + try: + # Validate Clerk session with JWT token support + user_id = await validate_clerk_session(request, clerk_token) + logger.info(f"User authenticated successfully - user_id: {user_id}") + + # Use the Clerk JWT token directly (no need to generate custom token) + logger.info("User authenticated successfully via Clerk") + + # 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: + logger.error(f"MCP OAuth callback failed: {e.detail}") + 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: + logger.error(f"Unexpected error in MCP OAuth callback: {str(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(request) + + return JSONResponse({ + "message": "Use your Clerk JWT token directly with Bearer authentication", + "token_type": "Bearer", + "scope": "yargi.read", + "user_id": user_id, + "instructions": "Include 'Authorization: Bearer YOUR_CLERK_JWT_TOKEN' in your requests" + }) + except HTTPException as e: + return JSONResponse( + status_code=e.status_code, + content={"error": "invalid_request", "error_description": e.detail} + ) + +# Note: Only HTTP transport supported - SSE transport deprecated + +# Export for uvicorn +__all__ = ["app"] \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/__init__.py new file mode 100644 index 0000000..7a2195d --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/__init__.py @@ -0,0 +1,17 @@ +# bddk_mcp_module/__init__.py + +from .client import BddkApiClient +from .models import ( + BddkSearchRequest, + BddkDecisionSummary, + BddkSearchResult, + BddkDocumentMarkdown +) + +__all__ = [ + "BddkApiClient", + "BddkSearchRequest", + "BddkDecisionSummary", + "BddkSearchResult", + "BddkDocumentMarkdown" +] \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/client.py new file mode 100644 index 0000000..158e082 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/client.py @@ -0,0 +1,247 @@ +# bddk_mcp_module/client.py + +import httpx +from typing import List, Optional, Dict, Any +import logging +import os +import re +import io +import math +from urllib.parse import urlparse +from markitdown import MarkItDown + +from .models import ( + BddkSearchRequest, + BddkDecisionSummary, + BddkSearchResult, + BddkDocumentMarkdown +) + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + +class BddkApiClient: + """ + API client for searching and retrieving BDDK (Banking Regulation Authority) decisions + using Tavily Search API for discovery and direct HTTP requests for content retrieval. + """ + + TAVILY_API_URL = "https://api.tavily.com/search" + BDDK_BASE_URL = "https://www.bddk.org.tr" + DOCUMENT_URL_TEMPLATE = "https://www.bddk.org.tr/Mevzuat/DokumanGetir/{document_id}" + DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page + + def __init__(self, request_timeout: float = 60.0): + """Initialize the BDDK API client.""" + self.tavily_api_key = os.getenv("TAVILY_API_KEY") + if not self.tavily_api_key: + # Fallback to development token + self.tavily_api_key = "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" + logger.info("Using fallback Tavily API token (development token)") + else: + logger.info("Using Tavily API key from environment variable") + + self.http_client = httpx.AsyncClient( + headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + }, + timeout=httpx.Timeout(request_timeout) + ) + self.markitdown = MarkItDown() + + async def close_client_session(self): + """Close the HTTP client session.""" + await self.http_client.aclose() + logger.info("BddkApiClient: HTTP client session closed.") + + def _extract_document_id(self, url: str) -> Optional[str]: + """Extract document ID from BDDK URL.""" + # Primary pattern: https://www.bddk.org.tr/Mevzuat/DokumanGetir/310 + match = re.search(r'/DokumanGetir/(\d+)', url) + if match: + return match.group(1) + + # Alternative patterns for different BDDK URL formats + # Pattern: /Liste/55 -> use as document ID + match = re.search(r'/Liste/(\d+)', url) + if match: + return match.group(1) + + # Pattern: /EkGetir/13?ekId=381 -> use ekId as document ID + match = re.search(r'ekId=(\d+)', url) + if match: + return match.group(1) + + return None + + async def search_decisions( + self, + request: BddkSearchRequest + ) -> BddkSearchResult: + """ + Search for BDDK decisions using Tavily API. + + Args: + request: Search request parameters + + Returns: + BddkSearchResult with matching decisions + """ + try: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.tavily_api_key}" + } + + # Tavily API request - enhanced for BDDK decision documents + query = f"{request.keywords} \"Karar Sayısı\"" + payload = { + "query": query, + "country": "turkey", + "include_domains": ["https://www.bddk.org.tr/Mevzuat/DokumanGetir"], + "max_results": request.pageSize, + "search_depth": "advanced" + } + + # Calculate offset for pagination + if request.page > 1: + # Tavily doesn't have direct pagination, so we'll need to handle this + # For now, we'll just return empty for pages > 1 + logger.warning(f"Tavily API doesn't support pagination. Page {request.page} requested.") + + response = await self.http_client.post( + self.TAVILY_API_URL, + json=payload, + headers=headers + ) + response.raise_for_status() + + data = response.json() + + # Log raw Tavily response for debugging + logger.info(f"Tavily returned {len(data.get('results', []))} results") + + # Convert Tavily results to our format + decisions = [] + for result in data.get("results", []): + # Extract document ID from URL + url = result.get("url", "") + logger.debug(f"Processing URL: {url}") + doc_id = self._extract_document_id(url) + if doc_id: + decision = BddkDecisionSummary( + title=result.get("title", "").replace("[PDF] ", "").strip(), + document_id=doc_id, + content=result.get("content", "")[:500] # Limit content length + ) + decisions.append(decision) + logger.debug(f"Added decision: {decision.title} (ID: {doc_id})") + else: + logger.warning(f"Could not extract document ID from URL: {url}") + + return BddkSearchResult( + decisions=decisions, + total_results=len(data.get("results", [])), + page=request.page, + pageSize=request.pageSize + ) + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error searching BDDK decisions: {e}") + if e.response.status_code == 401: + raise Exception("Tavily API authentication failed. Check API key.") + raise Exception(f"Failed to search BDDK decisions: {str(e)}") + except Exception as e: + logger.error(f"Error searching BDDK decisions: {e}") + raise Exception(f"Failed to search BDDK decisions: {str(e)}") + + async def get_document_markdown( + self, + document_id: str, + page_number: int = 1 + ) -> BddkDocumentMarkdown: + """ + Retrieve a BDDK document and convert it to Markdown format. + + Args: + document_id: BDDK document ID (e.g., '310') + page_number: Page number for paginated content (1-indexed) + + Returns: + BddkDocumentMarkdown with paginated content + """ + try: + # Try different URL patterns for BDDK documents + potential_urls = [ + f"https://www.bddk.org.tr/Mevzuat/DokumanGetir/{document_id}", + f"https://www.bddk.org.tr/Mevzuat/Liste/{document_id}", + f"https://www.bddk.org.tr/KurumHakkinda/EkGetir/13?ekId={document_id}", + f"https://www.bddk.org.tr/KurumHakkinda/EkGetir/5?ekId={document_id}" + ] + + document_url = None + response = None + + # Try each URL pattern until one works + for url in potential_urls: + try: + logger.info(f"Trying BDDK document URL: {url}") + response = await self.http_client.get( + url, + follow_redirects=True + ) + response.raise_for_status() + document_url = url + break + except httpx.HTTPStatusError: + continue + + if not response or not document_url: + raise Exception(f"Could not find document with ID {document_id}") + + logger.info(f"Successfully fetched BDDK document from: {document_url}") + + # Determine content type + content_type = response.headers.get("content-type", "").lower() + + # Convert to Markdown based on content type + if "pdf" in content_type: + # Handle PDF documents + pdf_stream = io.BytesIO(response.content) + result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf") + markdown_content = result.text_content + else: + # Handle HTML documents + html_stream = io.BytesIO(response.content) + result = self.markitdown.convert_stream(html_stream, file_extension=".html") + markdown_content = result.text_content + + # Clean up the markdown content + markdown_content = markdown_content.strip() + + # Calculate pagination + total_length = len(markdown_content) + total_pages = math.ceil(total_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) + + # Extract the requested page + start_idx = (page_number - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE + end_idx = start_idx + self.DOCUMENT_MARKDOWN_CHUNK_SIZE + page_content = markdown_content[start_idx:end_idx] + + return BddkDocumentMarkdown( + document_id=document_id, + markdown_content=page_content, + page_number=page_number, + total_pages=total_pages + ) + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error fetching BDDK document {document_id}: {e}") + raise Exception(f"Failed to fetch BDDK document: {str(e)}") + except Exception as e: + logger.error(f"Error processing BDDK document {document_id}: {e}") + raise Exception(f"Failed to process BDDK document: {str(e)}") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/models.py new file mode 100644 index 0000000..c945f26 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bddk_mcp_module/models.py @@ -0,0 +1,43 @@ +# bddk_mcp_module/models.py + +from pydantic import BaseModel, Field +from typing import List, Optional + +class BddkSearchRequest(BaseModel): + """ + Request model for searching BDDK decisions via Tavily API. + + BDDK (Bankacılık Düzenleme ve Denetleme Kurumu) is Turkey's Banking + Regulation and Supervision Agency responsible for banking licenses, + electronic money institutions, and financial regulations. + """ + keywords: str = Field(..., description="Search keywords in Turkish") + page: int = Field(1, ge=1, description="Page number (1-indexed)") + pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)") + +class BddkDecisionSummary(BaseModel): + """Summary of a BDDK decision from search results.""" + title: str = Field(..., description="Decision title") + document_id: str = Field(..., description="BDDK document ID (e.g., '310')") + content: str = Field(..., description="Decision summary/excerpt") + +class BddkSearchResult(BaseModel): + """Response model for BDDK decision search results.""" + decisions: List[BddkDecisionSummary] = Field( + default_factory=list, + description="List of matching BDDK decisions" + ) + total_results: int = Field(0, description="Total number of results") + page: int = Field(1, description="Current page number") + pageSize: int = Field(10, description="Results per page") + +class BddkDocumentMarkdown(BaseModel): + """ + BDDK decision document converted to Markdown format. + + Supports paginated content for long documents (5000 chars per page). + """ + document_id: str = Field(..., description="BDDK document ID") + markdown_content: str = Field("", description="Document content in Markdown") + page_number: int = Field(1, description="Current page number") + total_pages: int = Field(1, description="Total number of pages") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/__init__.py new file mode 100644 index 0000000..90b95d3 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/__init__.py @@ -0,0 +1 @@ +# bedesten_mcp_module/__init__.py \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/client.py new file mode 100644 index 0000000..ad6f1a8 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/client.py @@ -0,0 +1,181 @@ +# bedesten_mcp_module/client.py + +import httpx +import base64 +from typing import Optional +import logging +from markitdown import MarkItDown +import io + +from .models import ( + BedestenSearchRequest, BedestenSearchResponse, + BedestenDocumentRequest, BedestenDocumentResponse, + BedestenDocumentMarkdown, BedestenDocumentRequestData +) +from .enums import get_full_birim_adi + +logger = logging.getLogger(__name__) + +class BedestenApiClient: + """ + API Client for Bedesten (bedesten.adalet.gov.tr) - Alternative legal decision search system. + Currently used for Yargıtay decisions, but can be extended for other court types. + """ + BASE_URL = "https://bedesten.adalet.gov.tr" + SEARCH_ENDPOINT = "/emsal-karar/searchDocuments" + DOCUMENT_ENDPOINT = "/emsal-karar/getDocumentContent" + + def __init__(self, request_timeout: float = 60.0): + self.http_client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Accept": "*/*", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "AdaletApplicationName": "UyapMevzuat", + "Content-Type": "application/json; charset=utf-8", + "Origin": "https://mevzuat.adalet.gov.tr", + "Referer": "https://mevzuat.adalet.gov.tr/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36" + }, + timeout=request_timeout + ) + + async def search_documents(self, search_request: BedestenSearchRequest) -> BedestenSearchResponse: + """ + Search for documents using Bedesten API. + Currently supports: YARGITAYKARARI, DANISTAYKARARI, YERELHUKMAHKARARI, etc. + """ + logger.info(f"BedestenApiClient: Searching documents with phrase: {search_request.data.phrase}") + + # Map abbreviated birimAdi to full Turkish name before sending to API + original_birim_adi = search_request.data.birimAdi + mapped_birim_adi = get_full_birim_adi(original_birim_adi) + search_request.data.birimAdi = mapped_birim_adi + if original_birim_adi != "ALL": + logger.info(f"BedestenApiClient: Mapped birimAdi '{original_birim_adi}' to '{mapped_birim_adi}'") + + try: + # Create request dict and remove birimAdi if empty + request_dict = search_request.model_dump() + if not request_dict["data"]["birimAdi"]: # Remove if empty string + del request_dict["data"]["birimAdi"] + + response = await self.http_client.post( + self.SEARCH_ENDPOINT, + json=request_dict + ) + response.raise_for_status() + response_json = response.json() + + # Parse and return the response + return BedestenSearchResponse(**response_json) + + except httpx.RequestError as e: + logger.error(f"BedestenApiClient: HTTP request error during search: {e}") + raise + except Exception as e: + logger.error(f"BedestenApiClient: Error processing search response: {e}") + raise + + async def get_document_as_markdown(self, document_id: str) -> BedestenDocumentMarkdown: + """ + Get document content and convert to markdown. + Handles both HTML (text/html) and PDF (application/pdf) content types. + """ + logger.info(f"BedestenApiClient: Fetching document for markdown conversion (ID: {document_id})") + + try: + # Prepare request + doc_request = BedestenDocumentRequest( + data=BedestenDocumentRequestData(documentId=document_id) + ) + + # Get document + response = await self.http_client.post( + self.DOCUMENT_ENDPOINT, + json=doc_request.model_dump() + ) + response.raise_for_status() + response_json = response.json() + doc_response = BedestenDocumentResponse(**response_json) + + # Decode base64 content + content_bytes = base64.b64decode(doc_response.data.content) + mime_type = doc_response.data.mimeType + + logger.info(f"BedestenApiClient: Document mime type: {mime_type}") + + # Convert to markdown based on mime type + if mime_type == "text/html": + html_content = content_bytes.decode('utf-8') + markdown_content = self._convert_html_to_markdown(html_content) + elif mime_type == "application/pdf": + markdown_content = self._convert_pdf_to_markdown(content_bytes) + else: + logger.warning(f"Unsupported mime type: {mime_type}") + markdown_content = f"Unsupported content type: {mime_type}. Unable to convert to markdown." + + return BedestenDocumentMarkdown( + documentId=document_id, + markdown_content=markdown_content, + source_url=f"{self.BASE_URL}/document/{document_id}", + mime_type=mime_type + ) + + except httpx.RequestError as e: + logger.error(f"BedestenApiClient: HTTP error fetching document {document_id}: {e}") + raise + except Exception as e: + logger.error(f"BedestenApiClient: Error processing document {document_id}: {e}") + raise + + def _convert_html_to_markdown(self, html_content: str) -> Optional[str]: + """Convert HTML to Markdown using MarkItDown""" + if not html_content: + return None + + try: + # Convert HTML string to bytes and create BytesIO stream + html_bytes = html_content.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown() + result = md_converter.convert(html_stream) + markdown_content = result.text_content + + logger.info("Successfully converted HTML to Markdown") + return markdown_content + + except Exception as e: + logger.error(f"Error converting HTML to Markdown: {e}") + return f"Error converting HTML content: {str(e)}" + + def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> Optional[str]: + """Convert PDF to Markdown using MarkItDown""" + if not pdf_bytes: + return None + + try: + # Create BytesIO stream from PDF bytes + pdf_stream = io.BytesIO(pdf_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown() + result = md_converter.convert(pdf_stream) + markdown_content = result.text_content + + logger.info("Successfully converted PDF to Markdown") + return markdown_content + + except Exception as e: + logger.error(f"Error converting PDF to Markdown: {e}") + return f"Error converting PDF content: {str(e)}. The document may be corrupted or in an unsupported format." + + async def close_client_session(self): + """Close HTTP client session""" + await self.http_client.aclose() + logger.info("BedestenApiClient: HTTP client session closed.") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/enums.py b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/enums.py new file mode 100644 index 0000000..7cef054 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/enums.py @@ -0,0 +1,113 @@ +# bedesten_mcp_module/enums.py + +from typing import Literal + +# Unified compressed enum for both Yargıtay and Danıştay chambers +BirimAdiEnum = Literal[ + "ALL", # All chambers + + # Yargıtay (Court of Cassation) - Civil Chambers + "H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "H10", + "H11", "H12", "H13", "H14", "H15", "H16", "H17", "H18", "H19", "H20", + "H21", "H22", "H23", + + # Yargıtay - Criminal Chambers + "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "C10", + "C11", "C12", "C13", "C14", "C15", "C16", "C17", "C18", "C19", "C20", + "C21", "C22", "C23", + + # Yargıtay - Councils and Assemblies + "HGK", # Hukuk Genel Kurulu + "CGK", # Ceza Genel Kurulu + "BGK", # Büyük Genel Kurulu + "HBK", # Hukuk Daireleri Başkanlar Kurulu + "CBK", # Ceza Daireleri Başkanlar Kurulu + + # Danıştay (Council of State) - Chambers + "D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10", + "D11", "D12", "D13", "D14", "D15", "D16", "D17", + + # Danıştay - Councils and Boards + "DBGK", # Büyük Gen.Kur. (Grand General Assembly) + "IDDK", # İdare Dava Daireleri Kurulu + "VDDK", # Vergi Dava Daireleri Kurulu + "IBK", # İçtihatları Birleştirme Kurulu + "IIK", # İdari İşler Kurulu + "DBK", # Başkanlar Kurulu + + # Military High Administrative Court + "AYIM", # Askeri Yüksek İdare Mahkemesi + "AYIMDK", # Askeri Yüksek İdare Mahkemesi Daireler Kurulu + "AYIMB", # Askeri Yüksek İdare Mahkemesi Başsavcılığı + "AYIM1", # Askeri Yüksek İdare Mahkemesi 1. Daire + "AYIM2", # Askeri Yüksek İdare Mahkemesi 2. Daire + "AYIM3" # Askeri Yüksek İdare Mahkemesi 3. Daire +] + +# Mapping from abbreviated values to full Turkish API values +BIRIM_ADI_MAPPING = { + "ALL": None, # Will be handled specially in client + + # Yargıtay Civil Chambers (1-23) + "H1": "1. Hukuk Dairesi", "H2": "2. Hukuk Dairesi", "H3": "3. Hukuk Dairesi", + "H4": "4. Hukuk Dairesi", "H5": "5. Hukuk Dairesi", "H6": "6. Hukuk Dairesi", + "H7": "7. Hukuk Dairesi", "H8": "8. Hukuk Dairesi", "H9": "9. Hukuk Dairesi", + "H10": "10. Hukuk Dairesi", "H11": "11. Hukuk Dairesi", "H12": "12. Hukuk Dairesi", + "H13": "13. Hukuk Dairesi", "H14": "14. Hukuk Dairesi", "H15": "15. Hukuk Dairesi", + "H16": "16. Hukuk Dairesi", "H17": "17. Hukuk Dairesi", "H18": "18. Hukuk Dairesi", + "H19": "19. Hukuk Dairesi", "H20": "20. Hukuk Dairesi", "H21": "21. Hukuk Dairesi", + "H22": "22. Hukuk Dairesi", "H23": "23. Hukuk Dairesi", + + # Yargıtay Criminal Chambers (1-23) + "C1": "1. Ceza Dairesi", "C2": "2. Ceza Dairesi", "C3": "3. Ceza Dairesi", + "C4": "4. Ceza Dairesi", "C5": "5. Ceza Dairesi", "C6": "6. Ceza Dairesi", + "C7": "7. Ceza Dairesi", "C8": "8. Ceza Dairesi", "C9": "9. Ceza Dairesi", + "C10": "10. Ceza Dairesi", "C11": "11. Ceza Dairesi", "C12": "12. Ceza Dairesi", + "C13": "13. Ceza Dairesi", "C14": "14. Ceza Dairesi", "C15": "15. Ceza Dairesi", + "C16": "16. Ceza Dairesi", "C17": "17. Ceza Dairesi", "C18": "18. Ceza Dairesi", + "C19": "19. Ceza Dairesi", "C20": "20. Ceza Dairesi", "C21": "21. Ceza Dairesi", + "C22": "22. Ceza Dairesi", "C23": "23. Ceza Dairesi", + + # Yargıtay Councils and Assemblies + "HGK": "Hukuk Genel Kurulu", + "CGK": "Ceza Genel Kurulu", + "BGK": "Büyük Genel Kurulu", + "HBK": "Hukuk Daireleri Başkanlar Kurulu", + "CBK": "Ceza Daireleri Başkanlar Kurulu", + + # Danıştay Chambers (1-17) + "D1": "1. Daire", "D2": "2. Daire", "D3": "3. Daire", "D4": "4. Daire", + "D5": "5. Daire", "D6": "6. Daire", "D7": "7. Daire", "D8": "8. Daire", + "D9": "9. Daire", "D10": "10. Daire", "D11": "11. Daire", "D12": "12. Daire", + "D13": "13. Daire", "D14": "14. Daire", "D15": "15. Daire", "D16": "16. Daire", + "D17": "17. Daire", + + # Danıştay Councils and Boards + "DBGK": "Büyük Gen.Kur.", + "IDDK": "İdare Dava Daireleri Kurulu", + "VDDK": "Vergi Dava Daireleri Kurulu", + "IBK": "İçtihatları Birleştirme Kurulu", + "IIK": "İdari İşler Kurulu", + "DBK": "Başkanlar Kurulu", + + # Military High Administrative Court + "AYIM": "Askeri Yüksek İdare Mahkemesi", + "AYIMDK": "Askeri Yüksek İdare Mahkemesi Daireler Kurulu", + "AYIMB": "Askeri Yüksek İdare Mahkemesi Başsavcılığı", + "AYIM1": "Askeri Yüksek İdare Mahkemesi 1. Daire", + "AYIM2": "Askeri Yüksek İdare Mahkemesi 2. Daire", + "AYIM3": "Askeri Yüksek İdare Mahkemesi 3. Daire" +} + +# Helper function to get full Turkish name from abbreviated value +def get_full_birim_adi(abbreviated_value: str) -> str: + """Convert abbreviated birimAdi value to full Turkish name for API calls.""" + if abbreviated_value == "ALL" or not abbreviated_value: + return "" # Empty string for ALL or None + + return BIRIM_ADI_MAPPING.get(abbreviated_value, abbreviated_value) + +# Helper function to validate abbreviated value +def is_valid_birim_adi(abbreviated_value: str) -> bool: + """Check if abbreviated birimAdi value is valid.""" + return abbreviated_value in BIRIM_ADI_MAPPING \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/models.py new file mode 100644 index 0000000..6c6c24d --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/bedesten_mcp_module/models.py @@ -0,0 +1,91 @@ +# bedesten_mcp_module/models.py + +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any, Literal, Union +from datetime import datetime + +# Import compressed BirimAdiEnum for chamber filtering +from .enums import BirimAdiEnum + +# Court Type Options for Unified Search +BedestenCourtTypeEnum = Literal[ + "YARGITAYKARARI", # Yargıtay (Court of Cassation) + "DANISTAYKARAR", # Danıştay (Council of State) + "YERELHUKUK", # Local Civil Courts + "ISTINAFHUKUK", # Civil Courts of Appeals + "KYB" # Extraordinary Appeals (Kanun Yararına Bozma) +] + +# Search Request Models +class BedestenSearchData(BaseModel): + pageSize: int = Field(..., description="Results per page (1-10)") + pageNumber: int = Field(..., description="Page number (1-indexed)") + itemTypeList: List[str] = Field(..., description="Court type filter (YARGITAYKARARI/DANISTAYKARAR/YERELHUKUK/ISTINAFHUKUK/KYB)") + phrase: str = Field(..., description="Search phrase. Supports: 'word', \"exact phrase\", +required, -exclude, AND/OR/NOT operators. No wildcards or regex.") + birimAdi: BirimAdiEnum = Field("ALL", description=""" + Chamber filter (optional). Abbreviated values with Turkish names: + • Yargıtay: H1-H23 (1-23. Hukuk Dairesi), C1-C23 (1-23. Ceza Dairesi), HGK (Hukuk Genel Kurulu), CGK (Ceza Genel Kurulu), BGK (Büyük Genel Kurulu), HBK (Hukuk Daireleri Başkanlar Kurulu), CBK (Ceza Daireleri Başkanlar Kurulu) + • Danıştay: D1-D17 (1-17. Daire), DBGK (Büyük Gen.Kur.), IDDK (İdare Dava Daireleri Kurulu), VDDK (Vergi Dava Daireleri Kurulu), IBK (İçtihatları Birleştirme Kurulu), IIK (İdari İşler Kurulu), DBK (Başkanlar Kurulu), AYIM (Askeri Yüksek İdare Mahkemesi), AYIM1-3 (Askeri Yüksek İdare Mahkemesi 1-3. Daire) + """) + kararTarihiStart: Optional[str] = Field(None, description="Start date (ISO 8601 format)") + kararTarihiEnd: Optional[str] = Field(None, description="End date (ISO 8601 format)") + sortFields: List[str] = Field(default=["KARAR_TARIHI"], description="Sort fields") + sortDirection: str = Field(default="desc", description="Sort direction (asc/desc)") + +class BedestenSearchRequest(BaseModel): + data: BedestenSearchData + applicationName: str = "UyapMevzuat" + paging: bool = True + +# Search Response Models +class BedestenItemType(BaseModel): + name: str + description: str + +class BedestenDecisionEntry(BaseModel): + documentId: str + itemType: BedestenItemType + birimId: Optional[str] = None + birimAdi: Optional[str] + esasNoYil: Optional[int] = None + esasNoSira: Optional[int] = None + kararNoYil: Optional[int] = None + kararNoSira: Optional[int] = None + kararTuru: Optional[str] = None + kararTarihi: str + kararTarihiStr: str + kesinlesmeDurumu: Optional[str] = None + kararNo: Optional[str] = None + esasNo: Optional[str] = None + +class BedestenSearchDataResponse(BaseModel): + emsalKararList: List[BedestenDecisionEntry] + total: int + start: int + +class BedestenSearchResponse(BaseModel): + data: Optional[BedestenSearchDataResponse] + metadata: Dict[str, Any] + +# Document Request/Response Models +class BedestenDocumentRequestData(BaseModel): + documentId: str + +class BedestenDocumentRequest(BaseModel): + data: BedestenDocumentRequestData + applicationName: str = "UyapMevzuat" + +class BedestenDocumentData(BaseModel): + content: str # Base64 encoded HTML or PDF + mimeType: str + version: int + +class BedestenDocumentResponse(BaseModel): + data: BedestenDocumentData + metadata: Dict[str, Any] + +class BedestenDocumentMarkdown(BaseModel): + documentId: str = Field(..., description="The document ID (Belge Kimliği) from Bedesten") + markdown_content: Optional[str] = Field(None, description="The decision content (Karar İçeriği) converted to Markdown") + source_url: str = Field(..., description="The source URL (Kaynak URL) of the document") + mime_type: Optional[str] = Field(None, description="Original content type (İçerik Türü) (text/html or application/pdf)") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/check_response_format.py b/saidsurucu-yargi-mcp-f5fa007/check_response_format.py new file mode 100644 index 0000000..1e814c4 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/check_response_format.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from fastmcp import Client +from mcp_server_main import app +import json +import asyncio + +async def check_response_format(): + client = Client(app) + async with client: + result = await client.call_tool('search_bedesten_unified', { + 'phrase': 'mülkiyet', + 'court_types': ['YARGITAYKARARI'], + 'birimAdi': 'H1', + 'pageSize': 3 + }) + if result and result.content: + data = json.loads(result.content[0].text) + print('Response keys:', list(data.keys())) + print('Sample response:', json.dumps(data, indent=2, ensure_ascii=False)[:500]) + +asyncio.run(check_response_format()) \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/client.py new file mode 100644 index 0000000..0a713ff --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/client.py @@ -0,0 +1,192 @@ +# danistay_mcp_module/client.py + +import httpx +from bs4 import BeautifulSoup +from typing import Dict, Any, List, Optional +import logging +import html +import re +import io +from markitdown import MarkItDown + +from .models import ( + DanistayKeywordSearchRequest, + DanistayDetailedSearchRequest, + DanistayApiResponse, + DanistayDocumentMarkdown, + DanistayKeywordSearchRequestData, + DanistayDetailedSearchRequestData +) + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +class DanistayApiClient: + BASE_URL = "https://karararama.danistay.gov.tr" + KEYWORD_SEARCH_ENDPOINT = "/aramalist" + DETAILED_SEARCH_ENDPOINT = "/aramadetaylist" + DOCUMENT_ENDPOINT = "/getDokuman" + + def __init__(self, request_timeout: float = 30.0): + self.http_client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Content-Type": "application/json; charset=UTF-8", # Arama endpoint'leri için + "Accept": "application/json, text/plain, */*", # Arama endpoint'leri için + "X-Requested-With": "XMLHttpRequest", + }, + timeout=request_timeout, + verify=False + ) + + def _prepare_keywords_for_api(self, keywords: List[str]) -> List[str]: + return ['"' + k.strip('"') + '"' for k in keywords if k and k.strip()] + + async def search_keyword_decisions( + self, + params: DanistayKeywordSearchRequest + ) -> DanistayApiResponse: + data_for_payload = DanistayKeywordSearchRequestData( + andKelimeler=self._prepare_keywords_for_api(params.andKelimeler), + orKelimeler=self._prepare_keywords_for_api(params.orKelimeler), + notAndKelimeler=self._prepare_keywords_for_api(params.notAndKelimeler), + notOrKelimeler=self._prepare_keywords_for_api(params.notOrKelimeler), + pageSize=params.pageSize, + pageNumber=params.pageNumber + ) + final_payload = {"data": data_for_payload.model_dump(exclude_none=True)} + logger.info(f"DanistayApiClient: Performing KEYWORD search via {self.KEYWORD_SEARCH_ENDPOINT} with payload: {final_payload}") + return await self._execute_api_search(self.KEYWORD_SEARCH_ENDPOINT, final_payload) + + async def search_detailed_decisions( + self, + params: DanistayDetailedSearchRequest + ) -> DanistayApiResponse: + data_for_payload = DanistayDetailedSearchRequestData( + daire=params.daire or "", + esasYil=params.esasYil or "", + esasIlkSiraNo=params.esasIlkSiraNo or "", + esasSonSiraNo=params.esasSonSiraNo or "", + kararYil=params.kararYil or "", + kararIlkSiraNo=params.kararIlkSiraNo or "", + kararSonSiraNo=params.kararSonSiraNo or "", + baslangicTarihi=params.baslangicTarihi or "", + bitisTarihi=params.bitisTarihi or "", + mevzuatNumarasi=params.mevzuatNumarasi or "", + mevzuatAdi=params.mevzuatAdi or "", + madde=params.madde or "", + siralama="1", + siralamaDirection="desc", + pageSize=params.pageSize, + pageNumber=params.pageNumber + ) + # Create request dict and remove empty string fields to avoid API issues + payload_dict = data_for_payload.model_dump(exclude_defaults=False, exclude_none=False) + # Remove empty string fields that might cause API issues + cleaned_payload = {k: v for k, v in payload_dict.items() if v != ""} + final_payload = {"data": cleaned_payload} + logger.info(f"DanistayApiClient: Performing DETAILED search via {self.DETAILED_SEARCH_ENDPOINT} with payload: {final_payload}") + return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload) + + async def _execute_api_search(self, endpoint: str, payload: Dict) -> DanistayApiResponse: + try: + response = await self.http_client.post(endpoint, json=payload) + response.raise_for_status() + response_json_data = response.json() + logger.debug(f"DanistayApiClient: Raw API response from {endpoint}: {response_json_data}") + api_response_parsed = DanistayApiResponse(**response_json_data) + if api_response_parsed.data and api_response_parsed.data.data: + for decision_item in api_response_parsed.data.data: + if decision_item.id: + decision_item.document_url = f"{self.BASE_URL}{self.DOCUMENT_ENDPOINT}?id={decision_item.id}" + return api_response_parsed + except httpx.RequestError as e: + logger.error(f"DanistayApiClient: HTTP request error during search to {endpoint}: {e}") + raise + except Exception as e: + logger.error(f"DanistayApiClient: Error processing or validating search response from {endpoint}: {e}") + raise + + def _convert_html_to_markdown_danistay(self, direct_html_content: str) -> Optional[str]: + """ + Converts direct HTML content (assumed from Danıştay /getDokuman) to Markdown. + """ + if not direct_html_content: + return None + + # Basic HTML unescaping and fixing common escaped characters + # This step might be less critical if MarkItDown handles them, but good for pre-cleaning. + processed_html = html.unescape(direct_html_content) + processed_html = processed_html.replace('\\"', '"') # If any such JS-escaped strings exist + # Danistay HTML doesn't seem to have \\r\\n etc. from the example, but keeping for robustness + processed_html = processed_html.replace('\\r\\n', '\n').replace('\\n', '\n').replace('\\t', '\t') + + # For simplicity and to leverage MarkItDown's capability to handle full docs, + # we pass the pre-processed full HTML. + html_input_for_markdown = processed_html + + markdown_text = None + try: + # Convert HTML string to bytes and create BytesIO stream + html_bytes = html_input_for_markdown.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown() + conversion_result = md_converter.convert(html_stream) + markdown_text = conversion_result.text_content + logger.info("DanistayApiClient: HTML to Markdown conversion successful.") + except Exception as e: + logger.error(f"DanistayApiClient: Error during MarkItDown HTML to Markdown conversion: {e}") + + return markdown_text + + async def get_decision_document_as_markdown(self, id: str) -> DanistayDocumentMarkdown: + """ + Retrieves a specific Danıştay decision by ID and returns its content as Markdown. + The /getDokuman endpoint for Danıştay requires arananKelime parameter. + """ + # Add required arananKelime parameter - using empty string as minimum requirement + document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={id}&arananKelime=" + source_url = f"{self.BASE_URL}{document_api_url}" + logger.info(f"DanistayApiClient: Fetching Danistay document for Markdown (ID: {id}) from {source_url}") + + try: + # For direct HTML response, we might want different headers if the API is sensitive, + # but httpx usually handles basic GET requests well. + response = await self.http_client.get(document_api_url) + response.raise_for_status() + + # Danıştay /getDokuman directly returns HTML text + html_content_from_api = response.text + + if not isinstance(html_content_from_api, str) or not html_content_from_api.strip(): + logger.warning(f"DanistayApiClient: Received empty or non-string HTML content for ID {id}.") + # Return with None markdown_content if HTML is effectively empty + return DanistayDocumentMarkdown( + id=id, + markdown_content=None, + source_url=source_url + ) + + markdown_content = self._convert_html_to_markdown_danistay(html_content_from_api) + + return DanistayDocumentMarkdown( + id=id, + markdown_content=markdown_content, + source_url=source_url + ) + except httpx.RequestError as e: + logger.error(f"DanistayApiClient: HTTP error fetching Danistay document (ID: {id}): {e}") + raise + # Removed ValueError for JSON as Danistay /getDokuman returns direct HTML + except Exception as e: # Catches other errors like MarkItDown issues if they propagate + logger.error(f"DanistayApiClient: General error processing Danistay document (ID: {id}): {e}") + raise + + async def close_client_session(self): + """Closes the HTTPX client session.""" + if self.http_client and not self.http_client.is_closed: + await self.http_client.aclose() + logger.info("DanistayApiClient: HTTP client session closed.") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/models.py new file mode 100644 index 0000000..8c657a9 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/danistay_mcp_module/models.py @@ -0,0 +1,112 @@ +# danistay_mcp_module/models.py + +from pydantic import BaseModel, Field, HttpUrl, ConfigDict +from typing import List, Optional, Dict, Any + +class DanistayBaseSearchRequest(BaseModel): + """Base model for common search parameters for Danistay.""" + pageSize: int = Field(default=10, ge=1, le=10) + pageNumber: int = Field(default=1, ge=1) + # siralama and siralamaDirection are part of detailed search, not necessarily keyword search + # as per user's provided payloads. + +class DanistayKeywordSearchRequestData(BaseModel): + """Internal data model for the keyword search payload's 'data' field.""" + andKelimeler: List[str] = Field(default_factory=list) + orKelimeler: List[str] = Field(default_factory=list) + notAndKelimeler: List[str] = Field(default_factory=list) + notOrKelimeler: List[str] = Field(default_factory=list) + pageSize: int + pageNumber: int + +class DanistayKeywordSearchRequest(BaseModel): # This is the model the MCP tool will accept + """Model for keyword-based search request for Danistay.""" + andKelimeler: List[str] = Field(default_factory=list, description="AND keywords") + orKelimeler: List[str] = Field(default_factory=list, description="OR keywords") + notAndKelimeler: List[str] = Field(default_factory=list, description="NOT AND keywords") + notOrKelimeler: List[str] = Field(default_factory=list, description="NOT OR keywords") + pageSize: int = Field(default=10, ge=1, le=10) + pageNumber: int = Field(default=1, ge=1) + +class DanistayDetailedSearchRequestData(BaseModel): # Internal data model for detailed search payload + """Internal data model for the detailed search payload's 'data' field.""" + daire: Optional[str] = "" # API expects empty string for None + esasYil: Optional[str] = "" + esasIlkSiraNo: Optional[str] = "" + esasSonSiraNo: Optional[str] = "" + kararYil: Optional[str] = "" + kararIlkSiraNo: Optional[str] = "" + kararSonSiraNo: Optional[str] = "" + baslangicTarihi: Optional[str] = "" + bitisTarihi: Optional[str] = "" + mevzuatNumarasi: Optional[str] = "" + mevzuatAdi: Optional[str] = "" + madde: Optional[str] = "" + siralama: str # Seems mandatory in detailed search payload + siralamaDirection: str # Seems mandatory + pageSize: int + pageNumber: int + # Note: 'arananKelime' is not in the detailed search payload example provided by user. + # If it can be included, it should be added here. + +class DanistayDetailedSearchRequest(DanistayBaseSearchRequest): # MCP tool will accept this + """Model for detailed search request for Danistay.""" + daire: str = Field("", description="Chamber") + esasYil: str = Field("", description="Case year") + esasIlkSiraNo: str = Field("", description="Start case no") + esasSonSiraNo: str = Field("", description="End case no") + kararYil: str = Field("", description="Decision year") + kararIlkSiraNo: str = Field("", description="Start decision no") + kararSonSiraNo: str = Field("", description="End decision no") + baslangicTarihi: str = Field("", description="Start date") + bitisTarihi: str = Field("", description="End date") + mevzuatNumarasi: str = Field("", description="Law number") + mevzuatAdi: str = Field("", description="Law name") + madde: str = Field("", description="Article") + # Add a general keyword field if detailed search also supports it + # arananKelime: Optional[str] = Field(None, description="General keyword for detailed search.") + + +class DanistayApiDecisionEntry(BaseModel): + """Model for an individual decision entry from the Danistay API search response. + Based on user-provided response samples for both keyword and detailed search. + """ + id: str + # The API response for keyword search uses "daireKurul", detailed search example uses "daire". + # We use an alias to handle both and map to a consistent field name "chamber". + chamber: str = Field("", alias="daire", description="Chamber") + esasNo: str = Field("", description="Case number") + kararNo: str = Field("", description="Decision number") + kararTarihi: str = Field("", description="Decision date") + arananKelime: str = Field("", description="Keyword") + # index: Optional[int] = None # Present in response, can be added if needed by MCP tool + # siraNo: Optional[int] = None # Present in detailed response, can be added + + document_url: Optional[HttpUrl] = Field(None, description="Document URL") + + model_config = ConfigDict(populate_by_name=True, extra='ignore') # Important for alias to work and ignore extra fields + +class DanistayApiResponseInnerData(BaseModel): + """Model for the inner 'data' object in the Danistay API search response.""" + data: List[DanistayApiDecisionEntry] + recordsTotal: int + recordsFiltered: int + draw: int = Field(0, description="Draw counter") + +class DanistayApiResponse(BaseModel): + """Model for the complete search response from the Danistay API.""" + data: Optional[DanistayApiResponseInnerData] = Field(None, description="Response data, can be null when no results found") + metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) from API.") + +class DanistayDocumentMarkdown(BaseModel): + """Model for a Danistay decision document, containing only Markdown content.""" + id: str + markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.") + source_url: HttpUrl + +class CompactDanistaySearchResult(BaseModel): + """A compact search result model for the MCP tool to return.""" + decisions: List[DanistayApiDecisionEntry] + total_records: int + requested_page: int + page_size: int \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/docker-compose.yml b/saidsurucu-yargi-mcp-f5fa007/docker-compose.yml new file mode 100644 index 0000000..d36eee7 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/docker-compose.yml @@ -0,0 +1,66 @@ +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/saidsurucu-yargi-mcp-f5fa007/docs/DEPLOYMENT.md b/saidsurucu-yargi-mcp-f5fa007/docs/DEPLOYMENT.md new file mode 100644 index 0000000..db5f361 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/docs/DEPLOYMENT.md @@ -0,0 +1,428 @@ +# 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/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/client.py new file mode 100644 index 0000000..235d515 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/client.py @@ -0,0 +1,177 @@ +# emsal_mcp_module/client.py + +import httpx +# from bs4 import BeautifulSoup # Uncomment if needed for advanced HTML pre-processing +from typing import Dict, Any, List, Optional +import logging +import html +import re +import io +from markitdown import MarkItDown + +from .models import ( + EmsalSearchRequest, + EmsalDetailedSearchRequestData, + EmsalApiResponse, + EmsalDocumentMarkdown +) + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +class EmsalApiClient: + """API Client for Emsal (UYAP Precedent Decision) search system.""" + BASE_URL = "https://emsal.uyap.gov.tr" + DETAILED_SEARCH_ENDPOINT = "/aramadetaylist" + DOCUMENT_ENDPOINT = "/getDokuman" + + def __init__(self, request_timeout: float = 30.0): + self.http_client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Accept": "application/json, text/plain, */*", + "X-Requested-With": "XMLHttpRequest", + }, + timeout=request_timeout, + verify=False # As per user's original FastAPI code + ) + + async def search_detailed_decisions( + self, + params: EmsalSearchRequest + ) -> EmsalApiResponse: + """Performs a detailed search for Emsal decisions.""" + + data_for_api_payload = EmsalDetailedSearchRequestData( + arananKelime=params.keyword or "", + Bam_Hukuk_Mahkemeleri=params.selected_bam_civil_court, # Uses alias "Bam Hukuk Mahkemeleri" + Hukuk_Mahkemeleri=params.selected_civil_court, # Uses alias "Hukuk Mahkemeleri" + birimHukukMah="+".join(params.selected_regional_civil_chambers) if params.selected_regional_civil_chambers else "", + esasYil=params.case_year_esas or "", + esasIlkSiraNo=params.case_start_seq_esas or "", + esasSonSiraNo=params.case_end_seq_esas or "", + kararYil=params.decision_year_karar or "", + kararIlkSiraNo=params.decision_start_seq_karar or "", + kararSonSiraNo=params.decision_end_seq_karar or "", + baslangicTarihi=params.start_date or "", + bitisTarihi=params.end_date or "", + siralama=params.sort_criteria, + siralamaDirection=params.sort_direction, + pageSize=params.page_size, + pageNumber=params.page_number + ) + + # Create request dict and remove empty string fields to avoid API issues + payload_dict = data_for_api_payload.model_dump(by_alias=True, exclude_none=True) + # Remove empty string fields that might cause API issues + cleaned_payload = {k: v for k, v in payload_dict.items() if v != ""} + final_payload = {"data": cleaned_payload} + + logger.info(f"EmsalApiClient: Performing DETAILED search with payload: {final_payload}") + return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload) + + async def _execute_api_search(self, endpoint: str, payload: Dict) -> EmsalApiResponse: + """Helper method to execute search POST request and process response for Emsal.""" + try: + response = await self.http_client.post(endpoint, json=payload) + response.raise_for_status() + response_json_data = response.json() + logger.debug(f"EmsalApiClient: Raw API response from {endpoint}: {response_json_data}") + + api_response_parsed = EmsalApiResponse(**response_json_data) + + if api_response_parsed.data and api_response_parsed.data.data: + for decision_item in api_response_parsed.data.data: + if decision_item.id: + decision_item.document_url = f"{self.BASE_URL}{self.DOCUMENT_ENDPOINT}?id={decision_item.id}" + + return api_response_parsed + except httpx.RequestError as e: + logger.error(f"EmsalApiClient: HTTP request error during Emsal search to {endpoint}: {e}") + raise + except Exception as e: + logger.error(f"EmsalApiClient: Error processing or validating Emsal search response from {endpoint}: {e}") + raise + + def _clean_html_and_convert_to_markdown_emsal(self, html_content_from_api_data_field: str) -> Optional[str]: + """ + Cleans HTML (from Emsal API 'data' field containing HTML string) + and converts it to Markdown using MarkItDown. + This assumes Emsal /getDokuman response is JSON with HTML in "data" field, + similar to Yargitay and the last Emsal /getDokuman example. + """ + if not html_content_from_api_data_field: + return None + + # Basic HTML unescaping and fixing common escaped characters + # Based on user's original fix_html_content in app/routers/emsal.py + content = html.unescape(html_content_from_api_data_field) + content = content.replace('\\"', '"') + content = content.replace('\\r\\n', '\n') + content = content.replace('\\n', '\n') + content = content.replace('\\t', '\t') + + # The HTML string from "data" field starts with "..." + html_input_for_markdown = content + + markdown_text = None + try: + # Convert HTML string to bytes and create BytesIO stream + html_bytes = html_input_for_markdown.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown() + conversion_result = md_converter.convert(html_stream) + markdown_text = conversion_result.text_content + logger.info("EmsalApiClient: HTML to Markdown conversion successful.") + except Exception as e: + logger.error(f"EmsalApiClient: Error during MarkItDown HTML to Markdown conversion for Emsal: {e}") + + return markdown_text + + async def get_decision_document_as_markdown(self, id: str) -> EmsalDocumentMarkdown: + """ + Retrieves a specific Emsal decision by ID and returns its content as Markdown. + Assumes Emsal /getDokuman endpoint returns JSON with HTML content in the 'data' field. + """ + document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={id}" + source_url = f"{self.BASE_URL}{document_api_url}" + logger.info(f"EmsalApiClient: Fetching Emsal document for Markdown (ID: {id}) from {source_url}") + + try: + response = await self.http_client.get(document_api_url) + response.raise_for_status() + + # Emsal /getDokuman returns JSON with HTML in 'data' field (confirmed by user example) + response_json = response.json() + html_content_from_api = response_json.get("data") + + if not isinstance(html_content_from_api, str) or not html_content_from_api.strip(): + logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {id}.") + return EmsalDocumentMarkdown(id=id, markdown_content=None, source_url=source_url) + + markdown_content = self._clean_html_and_convert_to_markdown_emsal(html_content_from_api) + + return EmsalDocumentMarkdown( + id=id, + markdown_content=markdown_content, + source_url=source_url + ) + except httpx.RequestError as e: + logger.error(f"EmsalApiClient: HTTP error fetching Emsal document (ID: {id}): {e}") + raise + except ValueError as e: + logger.error(f"EmsalApiClient: ValueError processing Emsal document response (ID: {id}): {e}") + raise + except Exception as e: + logger.error(f"EmsalApiClient: General error processing Emsal document (ID: {id}): {e}") + raise + + async def close_client_session(self): + """Closes the HTTPX client session.""" + if self.http_client and not self.http_client.is_closed: + await self.http_client.aclose() + logger.info("EmsalApiClient: HTTP client session closed.") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/models.py new file mode 100644 index 0000000..c94f0dd --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/emsal_mcp_module/models.py @@ -0,0 +1,101 @@ +# emsal_mcp_module/models.py + +from pydantic import BaseModel, Field, HttpUrl, ConfigDict +from typing import List, Optional, Dict, Any + +class EmsalDetailedSearchRequestData(BaseModel): + """ + Internal model for the 'data' object in the Emsal detailed search payload. + Field names use aliases to match the exact keys in the API payload + (e.g., "Bam Hukuk Mahkemeleri" with spaces). + The API expects empty strings for None/omitted optional fields. + """ + arananKelime: Optional[str] = "" + + Bam_Hukuk_Mahkemeleri: str = Field("", alias="Bam Hukuk Mahkemeleri") + Hukuk_Mahkemeleri: str = Field("", alias="Hukuk Mahkemeleri") + # Add other specific court type fields from the form if they are separate keys in payload + # E.g., "Ceza Mahkemeleri", "İdari Mahkemeler" etc. + + birimHukukMah: Optional[str] = Field("", description="Regional chambers (+ separated)") + + esasYil: Optional[str] = "" + esasIlkSiraNo: Optional[str] = "" + esasSonSiraNo: Optional[str] = "" + kararYil: Optional[str] = "" + kararIlkSiraNo: Optional[str] = "" + kararSonSiraNo: Optional[str] = "" + baslangicTarihi: Optional[str] = "" + bitisTarihi: Optional[str] = "" + siralama: str # Mandatory in payload example + siralamaDirection: str # Mandatory in payload example + pageSize: int + pageNumber: int + + model_config = ConfigDict(populate_by_name=True) # Enables use of alias in serialization (when dumping to dict for payload) + +class EmsalSearchRequest(BaseModel): # This is the model the MCP tool will accept + """Model for Emsal detailed search request, with user-friendly field names.""" + keyword: str = Field("", description="Keyword") + + selected_bam_civil_court: str = Field("", description="BAM Civil Court") + selected_civil_court: str = Field("", description="Civil Court") + selected_regional_civil_chambers: List[str] = Field(default_factory=list, description="Regional chambers") + + case_year_esas: str = Field("", description="Case year") + case_start_seq_esas: str = Field("", description="Start case no") + case_end_seq_esas: str = Field("", description="End case no") + + decision_year_karar: str = Field("", description="Decision year") + decision_start_seq_karar: str = Field("", description="Start decision no") + decision_end_seq_karar: str = Field("", description="End decision no") + + start_date: str = Field("", description="Start date (DD.MM.YYYY)") + end_date: str = Field("", description="End date (DD.MM.YYYY)") + + sort_criteria: str = Field("1", description="Sort by") + sort_direction: str = Field("desc", description="Direction") + + page_number: int = Field(default=1, ge=1) + page_size: int = Field(default=10, ge=1, le=10) + + +class EmsalApiDecisionEntry(BaseModel): + """Model for an individual decision entry from the Emsal API search response.""" + id: str + daire: str = Field("", description="Chamber") + esasNo: str = Field("", description="Case number") + kararNo: str = Field("", description="Decision number") + kararTarihi: str = Field("", description="Decision date") + arananKelime: str = Field("", description="Keyword") + durum: str = Field("", description="Status") + # index: Optional[int] = None # Present in Emsal response, can be added if tool needs it + + document_url: Optional[HttpUrl] = Field(None, description="Document URL") + + model_config = ConfigDict(extra='ignore') + +class EmsalApiResponseInnerData(BaseModel): + """Model for the inner 'data' object in the Emsal API search response.""" + data: List[EmsalApiDecisionEntry] + recordsTotal: int + recordsFiltered: int + draw: int = Field(0, description="Draw counter (Çizim Sayıcısı) from API, usually for DataTables.") + +class EmsalApiResponse(BaseModel): + """Model for the complete search response from the Emsal API.""" + data: EmsalApiResponseInnerData + metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) from API, if any.") + +class EmsalDocumentMarkdown(BaseModel): + """Model for an Emsal decision document, containing only Markdown content.""" + id: str + markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.") + source_url: HttpUrl + +class CompactEmsalSearchResult(BaseModel): + """A compact search result model for the MCP tool to return.""" + decisions: List[EmsalApiDecisionEntry] + total_records: int + requested_page: int + page_size: int \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/example_fastapi_app.py b/saidsurucu-yargi-mcp-f5fa007/example_fastapi_app.py new file mode 100644 index 0000000..5e4c3fd --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/example_fastapi_app.py @@ -0,0 +1,1680 @@ +""" +FastAPI Comprehensive Endpoints with Complete MCP Documentation +This is the complete version with all descriptions and docstrings from MCP server. +""" + +import os +from typing import List, Dict, Any, Optional +from datetime import datetime + +from fastapi import FastAPI, HTTPException, Query, Depends, Body +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +import json + +# Import the main MCP app +from mcp_server_main import app as mcp_server + +# Create MCP ASGI app +mcp_asgi_app = mcp_server.http_app(path="/mcp") + +# Create FastAPI app with MCP lifespan +app = FastAPI( + title="Yargı MCP API - Turkish Legal Database REST API", + description=""" + Comprehensive REST API for Turkish Legal Databases with complete MCP tool coverage. + + This API provides access to 8 major Turkish legal institutions including: + • Yargıtay (Court of Cassation) - Supreme civil/criminal court + • Danıştay (Council of State) - Supreme administrative court + • Constitutional Court - Constitutional review and individual applications + • Competition Authority - Antitrust and merger decisions + • Public Procurement Authority - Government contracting disputes + • Court of Accounts - Public audit and accountability + • Emsal (UYAP Precedents) - Cross-court precedent database + • Local and Appellate Courts - First and second instance decisions + + Features complete coverage of 33 MCP tools with enhanced documentation, + typed request models, and comprehensive legal context. + + Tool Properties (from MCP annotations): + • Read-only: All tools are read-only and do not modify system state + • Idempotent: Same inputs produce same outputs for reliable research + • Open-world search: Search tools explore comprehensive legal databases + • Deterministic document retrieval: Document tools return consistent content + """, + version="1.0.0", + lifespan=mcp_asgi_app.lifespan +) + +# Add CORS middleware +cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount MCP server +app.mount("/mcp-server", mcp_asgi_app) + +# Response models (keeping from original) +class ToolInfo(BaseModel): + name: str + description: str + parameters: Dict[str, Any] + +class ServerInfo(BaseModel): + name: str + version: str + description: str + tools_count: int + databases: List[str] + mcp_endpoint: str + api_docs: str + +class HealthCheck(BaseModel): + status: str + timestamp: datetime + uptime_seconds: Optional[float] = None + tools_operational: bool + +# Track server start time +SERVER_START_TIME = datetime.now() + +# MCP tool caller helper +async def call_mcp_tool(tool_name: str, arguments: Dict[str, Any]): + """Call an MCP tool with given arguments""" + try: + tool = mcp_server._tool_manager._tools.get(tool_name) + if not tool: + raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found") + result = await tool.fn(**arguments) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"Tool execution failed: {str(e)}") + +# ============================================================================ +# COMPREHENSIVE REQUEST MODELS WITH FULL MCP DOCUMENTATION +# ============================================================================ + +class YargitaySearchRequest(BaseModel): + """ + Search request for Court of Cassation (Yargıtay) decisions using primary official API. + + The Court of Cassation is Turkey's highest court for civil and criminal matters, + equivalent to a Supreme Court. Provides access to comprehensive supreme court precedents. + """ + arananKelime: str = Field( + ..., + description="""Keyword to search for with advanced operators: + • Space between words = OR logic (arsa payı → "arsa" OR "payı") + • "exact phrase" = Exact match ("arsa payı" → exact phrase) + • word1+word2 = AND logic (arsa+payı → both words required) + • word* = Wildcard (bozma* → bozma, bozması, bozmanın, etc.) + • +"phrase1" +"phrase2" = Multiple required phrases + • +"required" -"excluded" = Include and exclude + + Turkish Examples: + • Simple OR: arsa payı (~523K results) + • Exact phrase: "arsa payı" (~22K results) + • Multiple AND: +"arsa payı" +"bozma sebebi" (~234 results) + • Wildcard: bozma* (bozma, bozması, bozmanın, etc.) + • Exclude: +"arsa payı" -"kira sözleşmesi" + """, + example='+"mülkiyet hakkı" +"iptal"' + ) + birimYrgKurulDaire: Optional[str] = Field( + "", + description="""Chamber/board selection (52 options): + Civil Chambers: 1-23. Hukuk Dairesi + Criminal Chambers: 1-23. Ceza Dairesi + General Assemblies: Hukuk Genel Kurulu, Ceza Genel Kurulu + Special Boards: Hukuk/Ceza Daireleri Başkanlar Kurulu, Büyük Genel Kurulu + + Use "" for ALL chambers or specify exact chamber name. + """, + example="1. Hukuk Dairesi" + ) + baslangicTarihi: Optional[str] = Field(None, description="Start date (DD.MM.YYYY)", example="01.01.2020") + bitisTarihi: Optional[str] = Field(None, description="End date (DD.MM.YYYY)", example="31.12.2024") + pageSize: int = Field(20, description="Results per page (1-100)", ge=1, le=100, example=20) + +class YargitayBedestenSearchRequest(BaseModel): + """ + Search request for Court of Cassation using Bedesten API (alternative source). + Complements primary API with different search capabilities and recent decisions. + """ + phrase: str = Field( + ..., + description="""Aranacak kavram/kelime. İki farklı arama türü desteklenir: + • Normal arama: "mülkiyet hakkı" - kelimeler ayrı ayrı aranır + • Tam cümle arama: "\"mülkiyet hakkı\"" - tırnak içindeki ifade aynen aranır + Tam cümle aramalar daha kesin sonuçlar verir. + + Search phrase with exact matching support: + • Regular search: "mülkiyet hakkı" - searches individual words separately + • Exact phrase search: "\"mülkiyet hakkı\"" - searches for exact phrase as unit + Exact phrase search provides more precise results with fewer false positives. + """, + example="\"mülkiyet hakkı\"" + ) + birimAdi: Optional[str] = Field( + None, + description="""Daire/Kurul seçimi (52 seçenek - ana API ile aynı): + • Hukuk daireleri: 1. Hukuk Dairesi - 23. Hukuk Dairesi + • Ceza daireleri: 1. Ceza Dairesi - 23. Ceza Dairesi + • Genel kurullar: Hukuk Genel Kurulu, Ceza Genel Kurulu + • Özel kurullar: Hukuk/Ceza Daireleri Başkanlar Kurulu, Büyük Genel Kurulu + + Chamber filtering (52 options - same as primary API): + • Civil chambers: 1. Hukuk Dairesi through 23. Hukuk Dairesi + • Criminal chambers: 1. Ceza Dairesi through 23. Ceza Dairesi + • General assemblies: Hukuk Genel Kurulu, Ceza Genel Kurulu + • Special boards: Hukuk/Ceza Daireleri Başkanlar Kurulu, Büyük Genel Kurulu + + Use None for ALL chambers, or specify exact chamber name. + """, + example="1. Hukuk Dairesi" + ) + kararTarihiStart: Optional[str] = Field( + None, + description="""Karar başlangıç tarihi (ISO 8601 formatı): + Format: YYYY-MM-DDTHH:MM:SS.000Z + Örnek: "2024-01-01T00:00:00.000Z" - 1 Ocak 2024'ten itibaren kararlar + kararTarihiEnd ile birlikte tarih aralığı filtrelemesi için kullanılır. + + Decision start date filter (ISO 8601 format): + Format: YYYY-MM-DDTHH:MM:SS.000Z + Example: "2024-01-01T00:00:00.000Z" for decisions from Jan 1, 2024 + Use with kararTarihiEnd for date range filtering. + """, + example="2024-01-01T00:00:00.000Z" + ) + kararTarihiEnd: Optional[str] = Field( + None, + description="""Karar bitiş tarihi (ISO 8601 formatı): + Format: YYYY-MM-DDTHH:MM:SS.000Z + Örnek: "2024-12-31T23:59:59.999Z" - 31 Aralık 2024'e kadar kararlar + kararTarihiStart ile birlikte tarih aralığı filtrelemesi için kullanılır. + + Decision end date filter (ISO 8601 format): + Format: YYYY-MM-DDTHH:MM:SS.000Z + Example: "2024-12-31T23:59:59.999Z" for decisions until Dec 31, 2024 + Use with kararTarihiStart for date range filtering. + """, + example="2024-12-31T23:59:59.999Z" + ) + pageSize: int = Field(20, description="Results per page (1-100)", ge=1, le=100) + +class DanistayKeywordSearchRequest(BaseModel): + """ + Keyword-based search for Council of State (Danıştay) decisions with Boolean logic. + + The Council of State is Turkey's highest administrative court, providing final + rulings on administrative law matters with Boolean keyword operators. + """ + andKelimeler: List[str] = Field( + ..., + description="ALL keywords must be present (AND logic)", + example=["idari işlem", "iptal"] + ) + orKelimeler: Optional[List[str]] = Field( + None, + description="ANY keyword can be present (OR logic)", + example=["ruhsat", "izin", "lisans"] + ) + notKelimeler: Optional[List[str]] = Field( + None, + description="EXCLUDE if keywords present (NOT logic)", + example=["vergi"] + ) + pageSize: int = Field(20, description="Results per page (1-100)", ge=1, le=100) + +class DanistayDetailedSearchRequest(BaseModel): + """ + Detailed search for Council of State decisions with comprehensive filtering. + Provides the most comprehensive search capabilities for administrative court decisions. + """ + daire: Optional[str] = Field( + None, + description="Chamber/Department filter (1. Daire through 17. Daire, special councils)", + example="3. Daire" + ) + baslangicTarihi: Optional[str] = Field(None, description="Start date (DD.MM.YYYY)", example="01.01.2020") + bitisTarihi: Optional[str] = Field(None, description="End date (DD.MM.YYYY)", example="31.12.2024") + esas: Optional[str] = Field(None, description="Case number (Esas No)", example="2024/123") + karar: Optional[str] = Field(None, description="Decision number (Karar No)", example="2024/456") + +class DanistayBedestenSearchRequest(BaseModel): + """ + Council of State search using Bedesten API with chamber filtering and exact phrase search. + Provides access to administrative court decisions with 27 chamber options. + """ + phrase: str = Field(..., description="Search phrase (supports exact matching with quotes)") + birimAdi: Optional[str] = Field( + None, + description="""Chamber filtering (27 options): + Main Councils: Büyük Gen.Kur., İdare Dava Daireleri Kurulu, Vergi Dava Daireleri Kurulu + Chambers: 1. Daire through 17. Daire + Military: Askeri Yüksek İdare Mahkemesi chambers + """, + example="3. Daire" + ) + kararTarihiStart: Optional[str] = Field(None, description="Start date (ISO 8601)") + kararTarihiEnd: Optional[str] = Field(None, description="End date (ISO 8601)") + pageSize: int = Field(20, description="Results per page", ge=1, le=100) + +class EmsalSearchRequest(BaseModel): + """ + Search Precedent (Emsal) decisions from UYAP system across multiple court levels. + Provides access to precedent decisions from various Turkish courts. + """ + keyword: str = Field(..., description="Search keyword across decision texts") + decision_year_karar: Optional[str] = Field(None, description="Decision year filter", example="2024") + results_per_page: int = Field(20, description="Results per page", ge=1, le=100) + +class UyusmazlikSearchRequest(BaseModel): + """ + Search Court of Jurisdictional Disputes decisions. + Resolves jurisdictional disputes between different court systems. + """ + keywords: List[str] = Field(..., description="Search keywords", example=["görev", "uyuşmazlık"]) + page_to_fetch: int = Field(1, description="Page number", ge=1) + +class AnayasaNormSearchRequest(BaseModel): + """ + Search Constitutional Court norm control (judicial review) decisions. + Turkey's highest constitutional authority for reviewing law constitutionality. + """ + keywords_all: List[str] = Field(..., description="All required keywords", example=["eğitim hakkı", "anayasa"]) + period: Optional[str] = Field(None, description="Constitutional period (1=1961, 2=1982)", example="2") + application_type: Optional[str] = Field(None, description="Application type (1=İptal)", example="1") + results_per_page: int = Field(20, description="Results per page", ge=1, le=100) + +class AnayasaBireyselSearchRequest(BaseModel): + """ + Search Constitutional Court individual application decisions. + Human rights violation cases through individual citizen petitions. + """ + keywords: List[str] = Field(..., description="Search keywords", example=["ifade özgürlüğü", "basın"]) + page_to_fetch: int = Field(1, description="Page number", ge=1) + +class KikSearchRequest(BaseModel): + """ + Search Public Procurement Authority (KİK) decisions. + Government procurement disputes and regulatory interpretations. + """ + karar_tipi: Optional[str] = Field( + None, + description="Decision type (rbUyusmazlik=Disputes, rbDuzenleyici=Regulatory, rbMahkeme=Court)", + example="rbUyusmazlik" + ) + karar_metni: Optional[str] = Field(None, description="Decision text search", example="ihale iptali") + basvuru_konusu_ihale: Optional[str] = Field(None, description="Tender subject", example="danışmanlık") + karar_tarihi_baslangic: Optional[str] = Field(None, description="Start date", example="01.01.2023") + +class RekabetSearchRequest(BaseModel): + """ + Search Competition Authority decisions. + Antitrust, merger control, and competition law enforcement. + """ + KararTuru: Optional[str] = Field( + None, + description="Decision type (Birleşme ve Devralma, Rekabet İhlali, Muafiyet, etc.)", + example="Birleşme ve Devralma" + ) + PdfText: Optional[str] = Field( + None, + description="Full-text search in decisions. Use quotes for exact phrases.", + example="\"market definition\" telecommunications" + ) + YayinlanmaTarihi: Optional[str] = Field(None, description="Publication date", example="01.01.2020") + page: int = Field(1, description="Page number", ge=1) + +class BedestenSearchRequest(BaseModel): + """ + Generic search request for Bedesten API courts (Yerel Hukuk, İstinaf Hukuk, KYB). + Supports exact phrase search and date filtering. + """ + phrase: str = Field( + ..., + description="Search phrase. Use quotes for exact matching: \"legal term\"", + example="\"sözleşme ihlali\"" + ) + kararTarihiStart: Optional[str] = Field(None, description="Start date (ISO 8601)") + kararTarihiEnd: Optional[str] = Field(None, description="End date (ISO 8601)") + pageSize: int = Field(20, description="Results per page", ge=1, le=100) + +class SayistaySearchRequest(BaseModel): + """ + Search Court of Accounts (Sayıştay) decisions. + Public audit, accountability, and financial oversight decisions. + """ + keywords: List[str] = Field(..., description="Search keywords", example=["mali sorumluluk", "denetim"]) + page_to_fetch: int = Field(1, description="Page number", ge=1) + +# ============================================================================ +# BASIC SERVER ENDPOINTS (keeping from original) +# ============================================================================ + +@app.get("/", response_model=ServerInfo) +async def root(): + """Get comprehensive server information with database coverage""" + return ServerInfo( + name="Yargı MCP Server - Turkish Legal Database API", + version="1.0.0", + description="Complete REST API for Turkish legal databases with 33 MCP tools", + tools_count=len(mcp_server._tool_manager._tools), + databases=[ + "Yargıtay (Court of Cassation) - 4 tools", + "Danıştay (Council of State) - 5 tools", + "Emsal (UYAP Precedents) - 2 tools", + "Uyuşmazlık Mahkemesi (Jurisdictional Disputes) - 2 tools", + "Anayasa Mahkemesi (Constitutional Court) - 4 tools", + "Kamu İhale Kurulu (Public Procurement) - 2 tools", + "Rekabet Kurumu (Competition Authority) - 2 tools", + "Sayıştay (Court of Accounts) - 6 tools", + "Bedesten API Courts (Local/Appellate/KYB) - 6 tools" + ], + mcp_endpoint="/mcp-server/mcp/", + api_docs="/docs" + ) + +@app.get("/health", response_model=HealthCheck) +async def health_check(): + """Health check with comprehensive system status""" + uptime = (datetime.now() - SERVER_START_TIME).total_seconds() + return HealthCheck( + status="healthy", + timestamp=datetime.now(), + uptime_seconds=uptime, + tools_operational=len(mcp_server._tool_manager._tools) == 33 + ) + +@app.get("/api/tools", response_model=List[ToolInfo]) +async def list_tools( + search: Optional[str] = Query(None, description="Search tools by name or description"), + database: Optional[str] = Query(None, description="Filter by database name") +): + """List all 33 MCP tools with filtering capabilities""" + tools = [] + for tool in mcp_server._tool_manager._tools.values(): + if search and search.lower() not in tool.name.lower() and search.lower() not in tool.description.lower(): + continue + if database: + db_lower = database.lower() + if db_lower not in tool.name.lower() and db_lower not in tool.description.lower(): + continue + + params = {} + if hasattr(tool, 'schema') and tool.schema: + if hasattr(tool.schema, 'parameters'): + params = tool.schema.parameters + elif hasattr(tool.schema, '__annotations__'): + params = {k: str(v) for k, v in tool.schema.__annotations__.items()} + + tools.append(ToolInfo( + name=tool.name, + description=tool.description, + parameters=params + )) + return tools + +# ============================================================================ +# YARGITAY (COURT OF CASSATION) ENDPOINTS - 4 TOOLS +# ============================================================================ + +@app.post( + "/api/yargitay/search", + tags=["Yargıtay"], + summary="Search Court of Cassation (Primary API)", + description="""Search Turkey's Supreme Court for civil and criminal precedents using advanced operators. + +Key Features: +• Advanced search: AND (+), OR (space), NOT (-), wildcards (*), exact phrases ("") +• 52 chamber options (23 Civil + 23 Criminal + General Assemblies) +• Date range filtering • Case/decision number filtering • Pagination + +Search Examples: +• OR search: property share (finds ANY words) +• Exact phrase: "property share" (finds exact phrase) +• AND required: +"property share" +"annulment reason" +• Wildcard: construct* (construction, constructive, etc.) +• Exclude terms: +"property share" -"construction contract" + +Use for supreme court precedent research and legal principle analysis.""" +) +async def search_yargitay(request: YargitaySearchRequest): + """ + Searches Court of Cassation (Yargıtay) decisions using the primary official API. + + The Court of Cassation (Yargıtay) is Turkey's highest court for civil and criminal matters, + equivalent to a Supreme Court. This tool provides access to the most comprehensive database + of supreme court precedents with advanced search capabilities and filtering options. + + Key Features: + • Advanced search operators (AND, OR, wildcards, exclusions) + • Chamber filtering: 52 options (23 Civil (Hukuk) + 23 Criminal (Ceza) + General Assemblies (Genel Kurullar)) + • Date range filtering with DD.MM.YYYY format + • Case number filtering (Case No (Esas No) and Decision No (Karar No)) + • Pagination support (1-100 results per page) + • Multiple sorting options (by case number, decision number, date) + + SEARCH SYNTAX GUIDE: + • Words with spaces: OR search ("property share" finds ANY of the words) + • "Quotes": Exact phrase search ("property share" finds exact phrase) + • Plus sign (+): AND search (property+share requires both words) + • Asterisk (*): Wildcard (construct* matches variations) + • Minus sign (-): Exclude terms (avoid unwanted results) + + Common Search Patterns: + • Simple OR: property share (finds ~523K results) + • Exact phrase: "property share" (finds ~22K results) + • Multiple required: +"property share" +"annulment reason (bozma sebebi)" (finds ~234 results) + • Wildcard expansion: construct* (matches construction, constructive, etc.) + • Exclude unwanted: +"property share" -"construction contract" + + Use cases: + • Research supreme court precedents and legal principles + • Find decisions from specific chambers (Civil (Hukuk) vs Criminal (Ceza)) + • Search for interpretations of specific legal concepts + • Analyze court reasoning on complex legal issues + • Track legal developments over time periods + + Returns structured search results with decision metadata. Use get_yargitay_document_markdown() + to retrieve full decision texts for detailed analysis. + """ + args = { + "arananKelime": request.arananKelime, + "birimYrgKurulDaire": request.birimYrgKurulDaire, + "pageSize": request.pageSize + } + if request.baslangicTarihi: + args["baslangicTarihi"] = request.baslangicTarihi + if request.bitisTarihi: + args["bitisTarihi"] = request.bitisTarihi + return await call_mcp_tool("search_yargitay_detailed", args) + +@app.post( + "/api/yargitay/search-bedesten", + tags=["Yargıtay"], + summary="Search Court of Cassation (Bedesten API)", + description="""Alternative Court of Cassation search with exact phrase matching and recent decisions. + +Key Features: +• Exact phrase search: "\"legal term\"" for precise matching +• Regular search: "legal term" for individual word matching +• 52 chamber filtering options (same as primary API) +• ISO 8601 date filtering • Recent decision coverage + +Use alongside primary search for comprehensive coverage. Exact phrase search provides +higher precision with fewer false positives.""" +) +async def search_yargitay_bedesten(request: YargitayBedestenSearchRequest): + """Search Court of Cassation using Bedesten API. Complements primary API for complete coverage.""" + args = {"phrase": request.phrase, "pageSize": request.pageSize} + if request.birimAdi: + args["birimAdi"] = request.birimAdi + if request.kararTarihiStart: + args["kararTarihiStart"] = request.kararTarihiStart + if request.kararTarihiEnd: + args["kararTarihiEnd"] = request.kararTarihiEnd + return await call_mcp_tool("search_yargitay_bedesten", args) + +@app.get( + "/api/yargitay/document/{decision_id}", + tags=["Yargıtay"], + summary="Get Court of Cassation Document (Primary API)", + description="""Retrieve complete Court of Cassation decision in Markdown format. + +Content includes: +• Complete legal reasoning and precedent analysis +• Detailed examination of lower court decisions +• Citations of laws, regulations, and prior cases +• Final ruling with legal justification + +Perfect for detailed legal analysis, precedent research, and citation building.""" +) +async def get_yargitay_document(decision_id: str): + """Get full Court of Cassation decision text in clean Markdown format.""" + return await call_mcp_tool("get_yargitay_document_markdown", {"id": decision_id}) + +@app.get( + "/api/yargitay/bedesten-document/{document_id}", + tags=["Yargıtay"], + summary="Get Court of Cassation Document (Bedesten API)", + description="""Retrieve Court of Cassation decision from Bedesten API in Markdown format. + +Features: +• Supports both HTML and PDF source documents +• Clean Markdown conversion with legal structure preserved +• Removes technical artifacts for easy reading +• Compatible with documentId from Bedesten search results""" +) +async def get_yargitay_bedesten_document(document_id: str): + """Get Court of Cassation document from Bedesten API in Markdown format.""" + return await call_mcp_tool("get_yargitay_bedesten_document_markdown", {"documentId": document_id}) + +# ============================================================================ +# DANISTAY (COUNCIL OF STATE) ENDPOINTS - 5 TOOLS +# ============================================================================ + +@app.post( + "/api/danistay/search-keyword", + tags=["Danıştay"], + summary="Search Council of State (Keyword Logic)", + description="""Search Turkey's highest administrative court using Boolean keyword logic. + +Boolean Operators: +• AND keywords: ALL must be present (required terms) +• OR keywords: ANY can be present (alternative terms) +• NOT keywords: EXCLUDE if present (unwanted terms) + +Examples: +• Administrative acts: andKelimeler=["idari işlem", "iptal"] +• Permits/licenses: orKelimeler=["ruhsat", "izin", "lisans"] +• Exclude tax cases: notKelimeler=["vergi"] + +Perfect for administrative law research and government action reviews.""" +) +async def search_danistay_keyword(request: DanistayKeywordSearchRequest): + """ + Searches Council of State (Danıştay) decisions using keyword-based logic. + + The Council of State (Danıştay) is Turkey's highest administrative court, responsible for + reviewing administrative actions and providing administrative law precedents. This tool + provides flexible keyword-based searching with Boolean logic operators. + + Key Features: + • Boolean logic operators: AND, OR, NOT combinations + • Multiple keyword lists for complex search strategies + • Pagination support (1-100 results per page) + • Administrative law focus (permits, licenses, public administration) + • Complement to search_danistay_detailed for comprehensive coverage + + Keyword Logic: + • andKelimeler: ALL keywords must be present (AND logic) + • orKelimeler: ANY keyword can be present (OR logic) + • notAndKelimeler: EXCLUDE if ALL keywords present (NOT AND) + • notOrKelimeler: EXCLUDE if ANY keyword present (NOT OR) + + Administrative Law Use Cases: + • Research administrative court precedents + • Find decisions on specific government agencies + • Search for rulings on permits (ruhsat) and licenses (izin) + • Analyze administrative procedure interpretations + • Study public administration legal principles + + Examples: + • Simple AND: andKelimeler=["administrative act (idari işlem)", "annulment (iptal)"] + • OR search: orKelimeler=["permit (ruhsat)", "permission (izin)", "license (lisans)"] + • Complex: andKelimeler=["municipality (belediye)"], notOrKelimeler=["tax (vergi)"] + + Returns structured search results. Use get_danistay_document_markdown() for full texts. + For comprehensive Council of State (Danıştay) research, also use search_danistay_detailed and search_danistay_bedesten. + """ + args = {"andKelimeler": request.andKelimeler, "pageSize": request.pageSize} + if request.orKelimeler: + args["orKelimeler"] = request.orKelimeler + if request.notKelimeler: + args["notKelimeler"] = request.notKelimeler + return await call_mcp_tool("search_danistay_by_keyword", args) + +@app.post( + "/api/danistay/search-detailed", + tags=["Danıştay"], + summary="Search Council of State (Detailed Criteria)", + description="""Most comprehensive Council of State search with advanced filtering. + +Advanced Filtering: +• Chamber targeting (1. Daire through 17. Daire, special councils) +• Case/decision number ranges • Date range filtering +• Legislation cross-referencing • Multiple sorting options + +Use for specialized administrative law research, chamber-specific decisions, +and regulatory compliance analysis.""" +) +async def search_danistay_detailed(request: DanistayDetailedSearchRequest): + """Search Council of State with comprehensive filtering for specialized administrative law research.""" + args = {} + for field in ["daire", "baslangicTarihi", "bitisTarihi", "esas", "karar"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_danistay_detailed", args) + +@app.post( + "/api/danistay/search-bedesten", + tags=["Danıştay"], + summary="Search Council of State (Bedesten API)", + description="""Council of State search via Bedesten API with 27 chamber options and exact phrase search. + +Key Features: +• 27 chamber options (Main Councils, 17 Chambers, Military courts) +• Exact phrase search with double quotes for precision +• ISO 8601 date filtering • Alternative data source + +Use with other Danıştay tools for complete administrative law coverage.""" +) +async def search_danistay_bedesten(request: DanistayBedestenSearchRequest): + """Search Council of State via Bedesten API. Use with other Danıştay tools for complete coverage.""" + args = {"phrase": request.phrase, "pageSize": request.pageSize} + for field in ["birimAdi", "kararTarihiStart", "kararTarihiEnd"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_danistay_bedesten", args) + +@app.get( + "/api/danistay/document/{decision_id}", + tags=["Danıştay"], + summary="Get Council of State Document (Primary API)", + description="""Retrieve complete administrative court decision in Markdown format. + +Content includes: +• Complete administrative law reasoning and precedent analysis +• Review of administrative actions and government decisions +• Citations of administrative laws and regulations +• Final administrative ruling with legal justification + +Essential for administrative law research and government compliance analysis.""" +) +async def get_danistay_document(decision_id: str): + """Get full Council of State decision text in clean Markdown format.""" + return await call_mcp_tool("get_danistay_document_markdown", {"id": decision_id}) + +@app.get( + "/api/danistay/bedesten-document/{document_id}", + tags=["Danıştay"], + summary="Get Council of State Document (Bedesten API)", + description="""Retrieve Council of State decision from Bedesten API in Markdown format.""" +) +async def get_danistay_bedesten_document(document_id: str): + """Get Council of State document from Bedesten API in Markdown format.""" + return await call_mcp_tool("get_danistay_bedesten_document_markdown", {"documentId": document_id}) + +# ============================================================================ +# BEDESTEN API COURTS (LOCAL/APPELLATE/KYB) - 6 TOOLS +# ============================================================================ + +@app.post( + "/api/yerel-hukuk/search", + tags=["Yerel Hukuk"], + summary="Search Local Civil Courts", + description="""Search first-instance civil court decisions using Bedesten API. + +Local Civil Courts handle: +• Contract disputes • Property rights • Family law • Tort claims +• Commercial disputes • Consumer protection + +Only available tool for local court decisions. Supports exact phrase search +and date filtering for precise legal research.""" +) +async def search_yerel_hukuk(request: BedestenSearchRequest): + """ + Searches Yerel Hukuk Mahkemesi (Local Civil Court) decisions using Bedesten API. + + This provides access to local court decisions that are not available through other APIs. + Currently the only available tool for searching Yerel Hukuk Mahkemesi decisions. + Local civil courts represent the first instance of civil litigation in Turkey. + + Local Civil Courts handle: + • Contract disputes and commercial litigation + • Property rights and real estate disputes + • Family law matters (divorce, custody, inheritance) + • Tort claims and compensation cases + • Consumer protection issues + • Employment disputes + + Returns structured search results with decision metadata. Use get_yerel_hukuk_bedesten_document_markdown() + to retrieve full decision texts for detailed analysis. + """ + args = {"phrase": request.phrase, "pageSize": request.pageSize} + for field in ["kararTarihiStart", "kararTarihiEnd"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_yerel_hukuk_bedesten", args) + +@app.get("/api/yerel-hukuk/document/{document_id}", tags=["Yerel Hukuk"]) +async def get_yerel_hukuk_document(document_id: str): + """ + Retrieves a Yerel Hukuk Mahkemesi decision document from Bedesten API and converts to Markdown. + + This tool fetches complete local court decision texts using documentId from search results. + Perfect for detailed analysis of first-instance civil court rulings. + + Supports both HTML and PDF content types, automatically converting to clean Markdown format. + Use documentId from search_yerel_hukuk_bedesten results. + """ + return await call_mcp_tool("get_yerel_hukuk_bedesten_document_markdown", {"documentId": document_id}) + +@app.post( + "/api/istinaf-hukuk/search", + tags=["İstinaf Hukuk"], + summary="Search Civil Courts of Appeals", + description="""Search intermediate appellate court decisions using Bedesten API. + +İstinaf Courts are intermediate appellate courts handling appeals from local civil courts +before cases reach the Court of Cassation. Only available tool for İstinaf decisions.""" +) +async def search_istinaf_hukuk(request: BedestenSearchRequest): + """ + Searches İstinaf Hukuk Mahkemesi (Civil Court of Appeals) decisions using Bedesten API. + + İstinaf courts are intermediate appellate courts in the Turkish judicial system that handle + appeals from local civil courts before cases reach Yargıtay (Court of Cassation). + This is the only available tool for accessing İstinaf Hukuk Mahkemesi decisions. + + Key Features: + • Date range filtering with ISO 8601 format (YYYY-MM-DDTHH:MM:SS.000Z) + • Exact phrase search using double quotes: "\"legal term\"" + • Regular search for individual keywords + • Pagination support (1-100 results per page) + + Use cases: + • Research appellate court precedents + • Track appeals from specific lower courts + • Find decisions on specific legal issues at appellate level + • Analyze intermediate court reasoning before supreme court review + + Returns structured data with decision metadata including dates, case numbers, and summaries. + Use get_istinaf_hukuk_bedesten_document_markdown() to retrieve full decision texts. + """ + args = {"phrase": request.phrase, "pageSize": request.pageSize} + for field in ["kararTarihiStart", "kararTarihiEnd"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_istinaf_hukuk_bedesten", args) + +@app.get("/api/istinaf-hukuk/document/{document_id}", tags=["İstinaf Hukuk"]) +async def get_istinaf_hukuk_document(document_id: str): + """ + Retrieves the full text of an İstinaf Hukuk Mahkemesi decision document in Markdown format. + + This tool converts the original decision document (HTML or PDF) from Bedesten API + into clean, readable Markdown format suitable for analysis and processing. + + Input Requirements: + • documentId: Use the ID from search_istinaf_hukuk_bedesten results + • Document ID must be non-empty string + + Output Format: + • Clean Markdown text with proper formatting + • Preserves legal structure (headers, paragraphs, citations) + • Removes extraneous HTML/PDF artifacts + + Use for: + • Reading full appellate court decision texts + • Legal analysis of İstinaf court reasoning + • Citation extraction and reference building + • Content analysis and summarization + """ + return await call_mcp_tool("get_istinaf_hukuk_bedesten_document_markdown", {"documentId": document_id}) + +@app.post( + "/api/kyb/search", + tags=["KYB"], + summary="Search Extraordinary Appeals (KYB)", + description="""Search Kanun Yararına Bozma (Extraordinary Appeal) decisions. + +KYB is an extraordinary legal remedy where the Public Prosecutor's Office requests +review of finalized decisions in favor of law and defendants. Very rare but important +legal precedents. Only available tool for KYB decisions.""" +) +async def search_kyb(request: BedestenSearchRequest): + """ + Searches Kanun Yararına Bozma (KYB - Extraordinary Appeal) decisions using Bedesten API. + + KYB is an extraordinary legal remedy in the Turkish judicial system where the + Public Prosecutor's Office can request review of finalized decisions in favor of + the law and defendants. This is the only available tool for accessing KYB decisions. + + Key Features: + • Date range filtering with ISO 8601 format (YYYY-MM-DDTHH:MM:SS.000Z) + • Exact phrase search using double quotes: "\"extraordinary appeal\"" + • Regular search for individual keywords + • Pagination support (1-100 results per page) + + Legal Significance: + • Extraordinary remedy beyond regular appeals + • Initiated by Public Prosecutor's Office + • Reviews finalized decisions for legal errors + • Can benefit defendants retroactively + • Rare but important legal precedents + + Use cases: + • Research extraordinary appeal precedents + • Study prosecutorial challenges to final decisions + • Analyze legal errors in finalized cases + • Track KYB success rates and patterns + + Returns structured data with decision metadata. Use get_kyb_bedesten_document_markdown() + to retrieve full decision texts for detailed analysis. + """ + args = {"phrase": request.phrase, "pageSize": request.pageSize} + for field in ["kararTarihiStart", "kararTarihiEnd"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_kyb_bedesten", args) + +@app.get("/api/kyb/document/{document_id}", tags=["KYB"]) +async def get_kyb_document(document_id: str): + """ + Retrieves the full text of a Kanun Yararına Bozma (KYB) decision document in Markdown format. + + This tool converts the original extraordinary appeal decision document (HTML or PDF) + from Bedesten API into clean, readable Markdown format for analysis. + + Input Requirements: + • documentId: Use the ID from search_kyb_bedesten results + • Document ID must be non-empty string + + Output Format: + • Clean Markdown text with legal formatting preserved + • Structured content with headers and citations + • Removes technical artifacts from source documents + + Special Value for KYB Documents: + • Contains rare extraordinary appeal reasoning + • Shows prosecutorial arguments for legal review + • Documents correction of finalized legal errors + • Provides precedent for similar extraordinary circumstances + + Use for: + • Analyzing extraordinary appeal legal reasoning + • Understanding prosecutorial review criteria + • Research on legal error correction mechanisms + • Studying retroactive benefit applications + """ + return await call_mcp_tool("get_kyb_bedesten_document_markdown", {"documentId": document_id}) + +# ============================================================================ +# ADDITIONAL COURTS - 12 TOOLS +# ============================================================================ + +@app.post("/api/emsal/search", tags=["Emsal"], summary="Search UYAP Precedents") +async def search_emsal(request: EmsalSearchRequest): + """ + Searches for Precedent (Emsal) decisions using detailed criteria. + + The Precedent (Emsal) database contains precedent decisions from various Turkish courts + integrated through the UYAP (National Judiciary Informatics System). This tool provides + access to a comprehensive collection of court decisions that serve as legal precedents. + + Key Features: + • Multi-court coverage (BAM, Civil courts, Regional chambers) + • Keyword-based search across decision texts + • Court-specific filtering for targeted research + • Case number filtering (Case No (Esas No) and Decision No (Karar No) with ranges) + • Date range filtering with DD.MM.YYYY format + • Multiple sorting options and pagination support + + Court Selection Options: + • BAM Civil Courts: Higher regional civil courts + • Civil Courts: Local and first-instance civil courts + • Regional Civil Chambers: Specialized civil court departments + + Precedent Research Use Cases: + • Find precedent (emsal) decisions across multiple court levels + • Research court interpretations of specific legal concepts + • Analyze consistent legal reasoning patterns + • Study regional variations in legal decisions + • Track precedent development over time + • Compare decisions from different court types + + Returns structured precedent data with court information and decision metadata. + Use get_emsal_document_markdown() to retrieve full precedent decision texts. + """ + args = {"keyword": request.keyword, "results_per_page": request.results_per_page} + if request.decision_year_karar: + args["decision_year_karar"] = request.decision_year_karar + return await call_mcp_tool("search_emsal_detailed_decisions", args) + +@app.get("/api/emsal/document/{decision_id}", tags=["Emsal"]) +async def get_emsal_document(decision_id: str): + """ + Retrieves the full text of a specific Emsal (UYAP Precedent) decision in Markdown format. + + This tool fetches complete precedent decision documents from the UYAP system and converts + them to clean, readable Markdown format suitable for legal precedent analysis. + + Input Requirements: + • decision_id: Decision ID from search_emsal_detailed_decisions results + • ID must be non-empty string from UYAP Emsal database + + Output Format: + • Clean Markdown text with legal precedent structure preserved + • Organized sections: court info, case facts, legal reasoning, conclusion + • Proper formatting for legal citations and cross-references + • Removes technical artifacts from source documents + + Precedent Decision Content: + • Complete court reasoning and legal analysis + • Detailed examination of legal principles applied + • Citation of relevant laws, regulations, and prior precedents + • Final ruling with precedent-setting reasoning + • Court-specific interpretations and legal standards + + Use for legal precedent research, citation building, and comparative legal analysis. + """ + return await call_mcp_tool("get_emsal_document_markdown", {"decision_id": decision_id}) + +@app.post("/api/uyusmazlik/search", tags=["Uyuşmazlık"], summary="Search Jurisdictional Disputes") +async def search_uyusmazlik(request: UyusmazlikSearchRequest): + """ + Searches for Court of Jurisdictional Disputes (Uyuşmazlık Mahkemesi) decisions. + + The Court of Jurisdictional Disputes (Uyuşmazlık Mahkemesi) resolves jurisdictional disputes between different court systems + in Turkey, determining which court has jurisdiction over specific cases. This specialized + court handles conflicts between civil, criminal, and administrative jurisdictions. + + Key Features: + • Department filtering (Criminal, Civil, General Assembly decisions) + • Dispute type classification (Jurisdiction vs Judgment disputes) + • Decision outcome filtering (dispute resolution results) + • Case number and date range filtering + • Advanced text search with Boolean logic operators + • Official Gazette reference search + + Dispute Types: + • Jurisdictional Disputes (Görev Uyuşmazlığı): Which court has authority + • Judgment Disputes (Hüküm Uyuşmazlığı): Conflicting final decisions + + Departments: + • Criminal Section (Ceza Bölümü): Criminal section decisions + • Civil Section (Hukuk Bölümü): Civil section decisions + • General Assembly Decisions (Genel Kurul Kararları): General Assembly decisions + + Use cases: + • Research jurisdictional precedents + • Understand court system boundaries + • Analyze dispute resolution patterns + • Study inter-court conflict resolution + • Legal procedure and jurisdiction research + + Returns structured search results with dispute resolution information. + """ + return await call_mcp_tool("search_uyusmazlik_decisions", { + "keywords": request.keywords, + "page_to_fetch": request.page_to_fetch + }) + +@app.get("/api/uyusmazlik/document", tags=["Uyuşmazlık"]) +async def get_uyusmazlik_document(document_url: str): + """ + Retrieves the full text of a specific Uyuşmazlık Mahkemesi decision from its URL in Markdown format. + + This tool fetches complete jurisdictional dispute resolution decisions and converts them + to clean, readable Markdown format suitable for legal analysis of inter-court disputes. + + Input Requirements: + • document_url: Full URL to the decision document from search_uyusmazlik_decisions results + • URL must be valid HttpUrl format from official Uyuşmazlık Mahkemesi database + + Output Format: + • Clean Markdown text with jurisdictional dispute structure preserved + • Organized sections: dispute facts, jurisdictional analysis, resolution ruling + • Proper formatting for legal citations and court system references + • Removes technical artifacts from source documents + + Jurisdictional Dispute Decision Content: + • Complete analysis of jurisdictional conflicts between court systems + • Detailed examination of applicable jurisdictional rules + • Citation of relevant procedural laws and court organization statutes + • Final resolution determining proper court jurisdiction + • Reasoning for jurisdictional boundaries and court authority + + Use for understanding court system boundaries, analyzing jurisdictional precedents, + and legal practice guidance on proper court selection. + """ + return await call_mcp_tool("get_uyusmazlik_document_markdown_from_url", {"document_url": document_url}) + +@app.post("/api/anayasa/search-norm", tags=["Anayasa"], summary="Search Constitutional Court (Norm Control)") +async def search_anayasa_norm(request: AnayasaNormSearchRequest): + """ + Searches Constitutional Court (Anayasa Mahkemesi) norm control decisions with comprehensive filtering. + + The Constitutional Court is Turkey's highest constitutional authority, responsible for judicial + review of laws, regulations, and constitutional amendments. Norm control (Norm Denetimi) is the + court's power to review the constitutionality of legal norms. + + Key Features: + • Boolean keyword search (AND, OR, NOT logic) + • Constitutional period filtering (1961 vs 1982 Constitution) + • Case and decision number filtering + • Date range filtering for review and decision dates + • Application type classification (İptal, İtiraz, etc.) + • Applicant filtering (government entities, opposition parties) + • Official Gazette publication filtering + • Judicial opinion analysis (dissents, different reasoning) + • Court member and rapporteur filtering + • Norm type classification (laws, regulations, decrees) + • Review outcome filtering (constitutionality determinations) + • Constitutional basis article referencing + + Constitutional Review Types: + • Abstract review: Ex ante constitutional control + • Concrete review: Constitutional questions during litigation + • Legislative review: Parliamentary acts and government decrees + • Regulatory review: Administrative regulations and bylaws + + Use cases: + • Constitutional law research and analysis + • Legislative drafting constitutional compliance + • Academic constitutional law study + • Legal precedent analysis for constitutional questions + • Government policy constitutional assessment + + Returns structured constitutional court data with comprehensive metadata. + """ + args = {"keywords_all": request.keywords_all, "results_per_page": request.results_per_page} + for field in ["period", "application_type"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_anayasa_norm_denetimi_decisions", args) + +@app.get("/api/anayasa/norm-document", tags=["Anayasa"]) +async def get_anayasa_norm_document(document_url: str, page_number: int = 1): + """Get Constitutional Court norm control decision in paginated Markdown format.""" + return await call_mcp_tool("get_anayasa_norm_denetimi_document_markdown", { + "document_url": document_url, "page_number": page_number + }) + +@app.post("/api/anayasa/search-bireysel", tags=["Anayasa"], summary="Search Constitutional Court (Individual Applications)") +async def search_anayasa_bireysel(request: AnayasaBireyselSearchRequest): + """ + Search Constitutional Court individual application (Bireysel Başvuru) decisions for human rights violation reports with keyword filtering. + + Individual applications allow citizens to petition the Constitutional Court directly for + violations of fundamental rights and freedoms. This tool generates decision search reports + that help identify relevant human rights violation cases. + + Key Features: + • Keyword-based search with AND logic + • Human rights violation case identification + • Individual petition decision analysis + • Fundamental rights and freedoms research + • Pagination support for large result sets + + Individual Application System: + • Direct citizen access to Constitutional Court + • Human rights and fundamental freedoms protection + • Alternative to European Court of Human Rights + • Domestic remedy for constitutional violations + • Individual justice and rights enforcement + + Human Rights Categories: + • Right to life and personal liberty + • Right to fair trial and due process + • Freedom of expression and press + • Freedom of religion and conscience + • Property rights and economic freedoms + • Right to privacy and family life + • Political rights and democratic participation + + Use cases: + • Human rights violation research + • Individual petition precedent analysis + • Constitutional rights interpretation study + • Legal remedies for rights violations + • Academic human rights law research + • Civil society and NGO legal research + + Returns search report with case summaries and violation categories. + Use get_anayasa_bireysel_document for full decision texts. + """ + return await call_mcp_tool("search_anayasa_bireysel_basvuru_report", { + "keywords": request.keywords, "page_to_fetch": request.page_to_fetch + }) + +@app.get("/api/anayasa/bireysel-document", tags=["Anayasa"]) +async def get_anayasa_bireysel_document(document_url: str, page_number: int = 1): + """ + Retrieve the full text of a Constitutional Court individual application decision in paginated Markdown format. + + This tool fetches complete human rights violation decisions from individual applications + and converts them to clean, readable Markdown format. Content is paginated into + 5,000-character chunks for easier processing. + + Input Requirements: + • document_url: URL path (e.g., /BB/YYYY/NNNN) from search results + • page_number: Page number for pagination (1-indexed, default: 1) + + Output Format: + • Clean Markdown text with human rights case structure preserved + • Organized sections: applicant info, violation claims, court analysis, ruling + • Proper formatting for human rights law citations and references + • Paginated content with navigation information + + Individual Application Decision Content: + • Complete human rights violation analysis + • Detailed examination of fundamental rights claims + • Citation of constitutional articles and international human rights law + • Final determination on rights violations with remedies + • Analysis of domestic court proceedings and their adequacy + • Individual remedy recommendations and compensation + + Use for: + • Reading full human rights violation decisions + • Human rights law research and precedent analysis + • Understanding constitutional rights protection standards + • Individual petition strategy development + • Academic human rights and constitutional law study + • Civil society monitoring of rights violations + """ + return await call_mcp_tool("get_anayasa_bireysel_basvuru_document_markdown", { + "document_url": document_url, "page_number": page_number + }) + +@app.post("/api/kik/search", tags=["KİK"], summary="Search Public Procurement Authority") +async def search_kik(request: KikSearchRequest): + """ + Search Public Procurement Authority (Kamu İhale Kurulu - KIK) decisions with comprehensive filtering for public procurement law and administrative dispute research. + + The Public Procurement Authority (KIK) is Turkey's procurement oversight body, responsible for + regulating public procurement processes, resolving procurement disputes, and issuing interpretive + decisions on public contracting laws. This tool provides access to official procurement decisions + and regulatory guidance. + + Key Features: + • Decision type filtering (Disputes, Regulatory, Court decisions) + • Decision date range filtering for temporal analysis + • Applicant and procuring entity filtering + • Tender subject and content-based search + • Decision number and reference tracking + • Comprehensive metadata extraction + + Public Procurement Decision Types: + • Uyuşmazlık Kararları: Dispute resolution decisions + • Düzenleyici Kararlar: Regulatory and interpretive decisions + • Mahkeme Kararları: Court decisions and judicial precedents + + Procurement Law Areas: + • Tender procedure compliance and violations + • Bid evaluation and award criteria disputes + • Contractor qualification and eligibility + • Contract modification and scope changes + • Performance guarantees and penalty applications + • Public procurement ethics and transparency + • Emergency procurement and exceptional procedures + + Use cases: + • Public procurement law research and compliance guidance + • Tender dispute resolution precedent analysis + • Government contracting risk assessment + • Procurement policy and regulatory interpretation + • Academic public administration and law study + • Legal strategy development for procurement disputes + + Returns structured procurement authority data with comprehensive case metadata. + Use get_kik_document for full decision texts with detailed reasoning. + """ + args = {} + for field in ["karar_tipi", "karar_metni", "basvuru_konusu_ihale", "karar_tarihi_baslangic"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_kik_decisions", args) + +@app.get("/api/kik/document/{decision_id}", tags=["KİK"]) +async def get_kik_document(decision_id: str): + """ + Retrieve the full text of a Public Procurement Authority (KIK) decision in paginated Markdown format. + + This tool fetches complete public procurement decisions and converts them from PDF to clean, + readable Markdown format. Content is paginated into manageable chunks for processing lengthy + procurement law decisions and regulatory interpretations. + + Input Requirements: + • decision_id: KIK decision ID (base64 encoded karar_id) from search results + • Decision ID must be non-empty string + + Output Format: + • Clean Markdown text converted from original PDF documents + • Organized sections: case summary, legal analysis, regulatory interpretation, decision + • Proper formatting for procurement law citations and regulatory references + • Paginated content with navigation information + • Metadata including PDF source link and document information + + Public Procurement Decision Content: + • Complete procurement dispute analysis and resolution + • Detailed examination of tender procedures and compliance + • Citation of procurement laws, regulations, and precedents + • Final determination on procurement violations with corrective measures + • Regulatory guidance and policy interpretations + • Contractor liability and penalty determinations + + Use for: + • Reading full public procurement authority decisions + • Procurement law research and precedent analysis + • Government contracting compliance and risk assessment + • Tender dispute resolution strategy development + • Academic public administration and procurement law study + • Policy analysis and regulatory interpretation + """ + return await call_mcp_tool("get_kik_document_markdown", {"decision_id": decision_id}) + +@app.post("/api/rekabet/search", tags=["Rekabet"], summary="Search Competition Authority") +async def search_rekabet(request: RekabetSearchRequest): + """ + Search Competition Authority (Rekabet Kurumu) decisions with comprehensive filtering for competition law and antitrust research. + + The Competition Authority (Rekabet Kurumu) is Turkey's competition authority, responsible for enforcing antitrust laws, + preventing anti-competitive practices, and regulating mergers and acquisitions. This tool + provides access to official competition law decisions and regulatory interpretations. + + Key Features: + • Decision type filtering (Mergers, Violations, Exemptions, etc.) + • Title and content-based text search with exact phrase matching + • Publication date filtering + • Case year and decision number filtering + • Pagination support for large result sets + + Competition Law Decision Types: + • Birleşme ve Devralma: Merger and acquisition approvals + • Rekabet İhlali: Competition violation investigations + • Muafiyet: Exemption and negative clearance decisions + • Geçici Tedbir: Interim measures and emergency orders + • Sektör İncelemesi: Sector inquiry and market studies + • Diğer: Other regulatory and interpretive decisions + + Competition Law Areas: + • Anti-competitive agreements and cartels + • Abuse of dominant market position + • Merger control and market concentration + • Vertical agreements and distribution restrictions + • Unfair competition and consumer protection + • Market definition and economic analysis + + Advanced Search: + • Exact phrase matching with double quotes for precise legal terms + • Content search across full decision texts (PdfText parameter) + • Title search for specific case names or topics + • Date range filtering for temporal analysis + + Example for exact phrase search: PdfText=\"tender process\" consultancy + + Use cases: + • Competition law research and precedent analysis + • Merger and acquisition due diligence + • Antitrust compliance and risk assessment + • Market analysis and competitive intelligence + • Academic competition economics study + • Legal strategy development for competition cases + + Returns structured competition authority data with comprehensive metadata. + Use get_rekabet_document for full decision texts (paginated PDF conversion). + """ + args = {"page": request.page} + for field in ["KararTuru", "PdfText", "YayinlanmaTarihi"]: + if getattr(request, field): + args[field] = getattr(request, field) + return await call_mcp_tool("search_rekabet_kurumu_decisions", args) + +@app.get("/api/rekabet/document/{karar_id}", tags=["Rekabet"]) +async def get_rekabet_document(karar_id: str, page_number: int = 1): + """ + Retrieve the full text of a Competition Authority (Rekabet Kurumu) decision in paginated Markdown format converted from PDF. + + This tool fetches complete competition authority decisions and converts them from PDF to clean, + readable Markdown format. Content is paginated for easier processing of lengthy competition law decisions. + + Input Requirements: + • karar_id: GUID (kararId) from search_rekabet_kurumu_decisions results + • page_number: Page number for pagination (1-indexed, default: 1) + + Output Format: + • Clean Markdown text converted from original PDF documents + • Organized sections: case summary, market analysis, legal reasoning, decision + • Proper formatting for competition law citations and references + • Paginated content with navigation information + • Metadata including PDF source link and document information + + Competition Authority Decision Content: + • Complete competition law analysis and market assessment + • Detailed examination of anti-competitive practices + • Economic analysis and market definition studies + • Citation of competition laws, regulations, and precedents + • Final determination on competition violations with remedies + • Merger and acquisition approval conditions + • Regulatory guidance and policy interpretations + + Use for: + • Reading full competition authority decisions + • Competition law research and precedent analysis + • Market analysis and economic impact assessment + • Antitrust compliance and risk evaluation + • Academic competition economics and law study + • Legal strategy development for competition cases + """ + return await call_mcp_tool("get_rekabet_kurumu_document", { + "karar_id": karar_id, "page_number": page_number + }) + +# ============================================================================ +# SAYISTAY (COURT OF ACCOUNTS) ENDPOINTS - 6 TOOLS +# ============================================================================ + +@app.post("/api/sayistay/search-genel-kurul", tags=["Sayıştay"], summary="Search Court of Accounts (General Assembly)") +async def search_sayistay_genel_kurul(request: SayistaySearchRequest): + """ + Search Sayıştay Genel Kurul (General Assembly) decisions - highest-level audit precedents and interpretive rulings with keyword-based filtering. + + The General Assembly represents the highest decision-making body of Turkey's Court of Accounts, + issuing authoritative interpretations of audit laws, precedential rulings on complex accountability + issues, and binding guidance for audit practice. These decisions establish fundamental principles + for public financial oversight and accountability standards. + + Key Features: + • Keyword-based search with AND logic for precise case finding + • Highest-level audit precedent identification + • Interpretive ruling analysis and legal guidance extraction + • Complex accountability issue resolution tracking + • Pagination support for comprehensive result sets + + General Assembly Decision Authority: + • Ultimate audit law interpretation and clarification + • Precedential rulings binding on all audit chambers + • Complex inter-chamber jurisdiction dispute resolution + • Policy guidance for audit methodology and standards + • Final determination on constitutional audit questions + + Public Audit Areas: + • Government budget execution and compliance + • Public institution financial management + • State-owned enterprise oversight and accountability + • Local government and municipal audit standards + • Public procurement oversight and compliance monitoring + • Performance audit methodology and effectiveness standards + • Public revenue collection and tax administration audit + + Use Cases: + • Research authoritative audit law interpretations + • Find binding precedents for complex audit questions + • Study evolution of public accountability standards + • Analyze audit methodology development and refinement + • Academic public administration and audit research + • Government policy and accountability framework analysis + + Returns structured General Assembly data with comprehensive legal precedent metadata. + Use get_sayistay_genel_kurul_document for full decision texts with detailed reasoning. + """ + return await call_mcp_tool("search_sayistay_genel_kurul", { + "keywords": request.keywords, "page_to_fetch": request.page_to_fetch + }) + +@app.get("/api/sayistay/genel-kurul-document", tags=["Sayıştay"]) +async def get_sayistay_genel_kurul_document(document_url: str, page_number: int = 1): + """ + Retrieve the full text of a Sayıştay Genel Kurul decision document in Markdown format for detailed analysis. + + This tool converts the original General Assembly decision document into clean, + readable Markdown format suitable for legal analysis and research. + + Input Requirements: + • decision_id: Use the ID from search_sayistay_genel_kurul results + • Decision ID must be non-empty string + + Output Format: + • Clean Markdown text with legal formatting preserved + • Structured content with reasoning and conclusions + • Removes technical artifacts from source documents + + Use for: + • Detailed analysis of audit precedents + • Research on public accountability standards + • Citation and reference building + • Legal interpretation and case study development + """ + return await call_mcp_tool("get_sayistay_genel_kurul_document_markdown", { + "document_url": document_url, "page_number": page_number + }) + +@app.post("/api/sayistay/search-temyiz-kurulu", tags=["Sayıştay"], summary="Search Court of Accounts (Appeals Board)") +async def search_sayistay_temyiz_kurulu(request: SayistaySearchRequest): + """ + Search Sayıştay Temyiz Kurulu (Appeals Board) decisions - appellate review of audit chamber findings with advanced filtering and institutional analysis. + + The Appeals Board serves as the intermediate appellate authority in Turkey's audit system, + reviewing first-instance chamber decisions on audit findings, liability determinations, + and sanctions. Appeals Board decisions refine audit standards and ensure consistency + across different audit chambers. + + Key Features: + • Chamber-specific filtering for targeted appeals analysis + • Institutional type categorization for audit pattern analysis + • Decision and account year filtering for temporal trends + • Audit subject matter classification and content search + • Appeal outcome tracking and precedent identification + • Pagination support for comprehensive coverage + + Appeals Board Authority: + • Review and modification of chamber audit findings + • Standardization of audit liability determinations + • Consistency enforcement across audit chambers + • Intermediate precedent development for audit practice + • Quality control for first-instance audit decisions + + Audit Review Areas: + • Government agency financial accountability appeals + • Municipality and local government audit review + • State enterprise oversight and performance audit appeals + • Educational institution audit finding review + • Healthcare system financial accountability appeals + • Public procurement oversight and compliance review + • Tax administration and revenue audit appeals + + Use Cases: + • Research audit appeals patterns and outcomes + • Study chamber decision consistency and standards + • Analyze audit liability determination evolution + • Find precedents for specific audit finding types + • Track institutional audit patterns and compliance + • Academic public accountability and audit law research + + Returns structured Appeals Board data with comprehensive appellate analysis metadata. + Use get_sayistay_temyiz_kurulu_document for full appeals decisions with detailed reasoning. + """ + return await call_mcp_tool("search_sayistay_temyiz_kurulu", { + "keywords": request.keywords, "page_to_fetch": request.page_to_fetch + }) + +@app.get("/api/sayistay/temyiz-kurulu-document", tags=["Sayıştay"]) +async def get_sayistay_temyiz_kurulu_document(document_url: str, page_number: int = 1): + """ + Retrieve the full text of a Sayıştay Temyiz Kurulu decision document in Markdown format for detailed appeals analysis. + + This tool converts the original Appeals Board decision document into clean, + readable Markdown format for analysis of appellate reasoning and standards. + + Input Requirements: + • decision_id: Use the ID from search_sayistay_temyiz_kurulu results + • Decision ID must be non-empty string + + Output Format: + • Clean Markdown text with appellate reasoning preserved + • Structured content with original findings and appeals analysis + • Removes technical artifacts from source documents + + Use for: + • Analysis of appeals board reasoning and standards + • Research on audit liability determination evolution + • Understanding chamber decision review criteria + • Precedent analysis for audit appeal cases + """ + return await call_mcp_tool("get_sayistay_temyiz_kurulu_document_markdown", { + "document_url": document_url, "page_number": page_number + }) + +@app.post("/api/sayistay/search-daire", tags=["Sayıştay"], summary="Search Court of Accounts (Chambers)") +async def search_sayistay_daire(request: SayistaySearchRequest): + """ + Search Sayıştay Daire (Chamber) decisions - first-instance audit findings and sanctions from individual audit chambers with comprehensive filtering and subject categorization. + + Chamber decisions represent first-instance audit findings, sanctions, and + liability determinations issued by specialized audit chambers. These form + the foundation of Turkey's public financial accountability system. + + Key Features: + • Chamber-specific filtering (8 specialized audit chambers) + • Decision and account year filtering (2012-2025) + • Public administration type categorization + • Subject matter classification and full-text search + • Audit report tracking and institutional analysis + + Use Cases: + • Research specific audit findings and sanctions + • Study chamber specialization and jurisdiction + • Analyze audit patterns by institution type + • Find precedents for financial irregularities + • Track audit evolution across fiscal years + """ + return await call_mcp_tool("search_sayistay_daire", { + "keywords": request.keywords, "page_to_fetch": request.page_to_fetch + }) + +@app.get("/api/sayistay/daire-document", tags=["Sayıştay"]) +async def get_sayistay_daire_document(document_url: str, page_number: int = 1): + """ + Retrieve the full text of a Sayıştay Daire decision document in Markdown format for detailed audit findings analysis. + + This tool converts the original chamber decision document into clean, + readable Markdown format for analysis of first-instance audit findings. + + Input Requirements: + • decision_id: Use the ID from search_sayistay_daire results + • Decision ID must be non-empty string + + Output Format: + • Clean Markdown text with audit findings preserved + • Structured content with violations and sanctions + • Removes technical artifacts from source documents + + Use for: + • Detailed analysis of audit findings and methodology + • Research on specific types of financial irregularities + • Understanding chamber jurisdiction and specialization + • Case study development for audit training and compliance + """ + return await call_mcp_tool("get_sayistay_daire_document_markdown", { + "document_url": document_url, "page_number": page_number + }) + +# ============================================================================ +# ADDITIONAL API ENDPOINTS +# ============================================================================ + +@app.get("/api/databases") +async def list_databases(): + """Comprehensive database information with tool mappings""" + return { + "total_tools": 33, + "databases": { + "yargitay": { + "name": "Yargıtay (Court of Cassation)", + "description": "Turkey's Supreme Court for civil and criminal matters", + "tools": 4, + "chambers": 52, + "search_tools": ["search_yargitay", "search_yargitay_bedesten"], + "document_tools": ["get_yargitay_document", "get_yargitay_bedesten_document"] + }, + "danistay": { + "name": "Danıştay (Council of State)", + "description": "Turkey's Supreme Administrative Court", + "tools": 5, + "chambers": 27, + "search_tools": ["search_danistay_keyword", "search_danistay_detailed", "search_danistay_bedesten"], + "document_tools": ["get_danistay_document", "get_danistay_bedesten_document"] + }, + "anayasa": { + "name": "Anayasa Mahkemesi (Constitutional Court)", + "description": "Constitutional review and individual applications", + "tools": 4, + "features": ["norm_control", "individual_applications", "human_rights"] + }, + "rekabet": { + "name": "Rekabet Kurumu (Competition Authority)", + "description": "Antitrust and merger control", + "tools": 2, + "coverage": ["mergers", "cartels", "market_abuse", "sector_inquiries"] + }, + "kik": { + "name": "Kamu İhale Kurulu (Public Procurement Authority)", + "description": "Government procurement disputes", + "tools": 2, + "coverage": ["procurement_disputes", "regulatory_decisions", "tender_violations"] + }, + "sayistay": { + "name": "Sayıştay (Court of Accounts)", + "description": "Public audit and financial accountability", + "tools": 6, + "levels": ["general_assembly", "appeals_board", "audit_chambers"] + }, + "emsal": { + "name": "Emsal (UYAP Precedents)", + "description": "Cross-court precedent database", + "tools": 2, + "coverage": ["multi_court", "precedent_analysis"] + }, + "uyusmazlik": { + "name": "Uyuşmazlık Mahkemesi (Jurisdictional Disputes)", + "description": "Inter-court jurisdiction disputes", + "tools": 2, + "specialization": "jurisdictional_conflicts" + }, + "bedesten_courts": { + "name": "Bedesten API Courts (Local/Appellate/KYB)", + "description": "First instance, appellate, and extraordinary appeal courts", + "tools": 6, + "courts": ["yerel_hukuk", "istinaf_hukuk", "kyb"], + "coverage": "complete_court_hierarchy" + } + } + } + +@app.get("/api/stats") +async def get_statistics(): + """Comprehensive API statistics and capabilities""" + uptime = (datetime.now() - SERVER_START_TIME).total_seconds() + return { + "server": { + "uptime_seconds": uptime, + "start_time": SERVER_START_TIME.isoformat(), + "version": "1.0.0", + "status": "operational" + }, + "coverage": { + "total_tools": 33, + "total_databases": 9, + "total_chambers": 79, # 52 Yargıtay + 27 Danıştay + "search_tools": 16, + "document_tools": 17 + }, + "capabilities": { + "advanced_search_operators": True, + "exact_phrase_search": True, + "date_range_filtering": True, + "chamber_filtering": True, + "boolean_logic": True, + "wildcard_search": True, + "pagination": True, + "markdown_conversion": True, + "pdf_processing": True, + "dual_api_support": True + }, + "legal_coverage": { + "supreme_courts": ["Yargıtay", "Danıştay"], + "constitutional_law": "Anayasa Mahkemesi", + "administrative_law": "Full coverage", + "competition_law": "Rekabet Kurumu", + "public_procurement": "KİK", + "public_audit": "Sayıştay", + "court_hierarchy": "Complete (Local → Appellate → Supreme)", + "specialized_courts": ["Uyuşmazlık", "Constitutional", "Administrative"] + } + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/client.py new file mode 100644 index 0000000..5a3d51a --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/client.py @@ -0,0 +1,1199 @@ +# kik_mcp_module/client.py +import asyncio +from playwright.async_api import ( + async_playwright, + Page, + BrowserContext, + Browser, + Error as PlaywrightError, + TimeoutError as PlaywrightTimeoutError +) +from bs4 import BeautifulSoup +import logging +from typing import Dict, Any, List, Optional +import urllib.parse +import base64 # Base64 için +import re +import html as html_parser +from markitdown import MarkItDown +import os +import math +import io +import random + +from .models import ( + KikSearchRequest, + KikDecisionEntry, + KikSearchResult, + KikDocumentMarkdown, + KikKararTipi +) + +logger = logging.getLogger(__name__) + +class KikApiClient: + BASE_URL = "https://ekap.kik.gov.tr" + SEARCH_PAGE_PATH = "/EKAP/Vatandas/kurulkararsorgu.aspx" + FIELD_LOCATORS = { + "karar_tipi_radio_group": "input[name='ctl00$ContentPlaceHolder1$kurulKararTip']", + "karar_no": "input[name='ctl00$ContentPlaceHolder1$txtKararNo']", + "karar_tarihi_baslangic": "input[name='ctl00$ContentPlaceHolder1$etKararTarihBaslangic$EkapTakvimTextBox_etKararTarihBaslangic']", + "karar_tarihi_bitis": "input[name='ctl00$ContentPlaceHolder1$etKararTarihBitis$EkapTakvimTextBox_etKararTarihBitis']", + "resmi_gazete_sayisi": "input[name='ctl00$ContentPlaceHolder1$txtResmiGazeteSayisi']", + "resmi_gazete_tarihi": "input[name='ctl00$ContentPlaceHolder1$etResmiGazeteTarihi$EkapTakvimTextBox_etResmiGazeteTarihi']", + "basvuru_konusu_ihale": "input[name='ctl00$ContentPlaceHolder1$txtBasvuruKonusuIhale']", + "basvuru_sahibi": "input[name='ctl00$ContentPlaceHolder1$txtSikayetci']", + "ihaleyi_yapan_idare": "input[name='ctl00$ContentPlaceHolder1$txtIhaleyiYapanIdare']", + "yil": "select[name='ctl00$ContentPlaceHolder1$ddlYil']", + "karar_metni": "input[name='ctl00$ContentPlaceHolder1$txtKararMetni']", + "search_button_id": "ctl00_ContentPlaceHolder1_btnAra" + } + RESULTS_TABLE_ID = "grdKurulKararSorguSonuc" + NO_RESULTS_MESSAGE_SELECTOR = "div#ctl00_MessageContent1" + VALIDATION_SUMMARY_SELECTOR = "div#ctl00_ValidationSummary1" + MODAL_CLOSE_BUTTON_SELECTOR = "div#detayPopUp.in a#btnKapatPencere_0.close" + DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 + + def __init__(self, request_timeout: float = 60000): + self.playwright_instance: Optional[async_playwright] = None + self.browser: Optional[Browser] = None + self.context: Optional[BrowserContext] = None + self.page: Optional[Page] = None + self.request_timeout = request_timeout + self._lock = asyncio.Lock() + + async def _ensure_playwright_ready(self, force_new_page: bool = False): + async with self._lock: + browser_recreated = False + context_recreated = False + if not self.playwright_instance: + self.playwright_instance = await async_playwright().start() + if not self.browser or not self.browser.is_connected(): + if self.browser: await self.browser.close() + # Ultra stealth browser configuration + self.browser = await self.playwright_instance.chromium.launch( + headless=True, + args=[ + # Disable automation indicators + '--no-first-run', + '--no-default-browser-check', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-gpu', + '--disable-default-apps', + '--disable-translate', + '--disable-blink-features=AutomationControlled', + '--disable-ipc-flooding-protection', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-client-side-phishing-detection', + '--disable-sync', + '--disable-features=TranslateUI,BlinkGenPropertyTrees', + '--disable-component-extensions-with-background-pages', + '--no-sandbox', # Sometimes needed for headless + '--disable-web-security', + '--disable-features=VizDisplayCompositor', + # Language and locale + '--lang=tr-TR', + '--accept-lang=tr-TR,tr;q=0.9,en;q=0.8', + # Performance optimizations + '--memory-pressure-off', + '--max_old_space_size=4096', + ] + ) + browser_recreated = True + if not self.context or browser_recreated: + if self.context: await self.context.close() + if not self.browser: raise PlaywrightError("Browser not initialized.") + # Ultra realistic context configuration + self.context = await self.browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + viewport={'width': 1920, 'height': 1080}, + screen={'width': 1920, 'height': 1080}, + device_scale_factor=1.0, + is_mobile=False, + has_touch=False, + # Localization + locale='tr-TR', + timezone_id='Europe/Istanbul', + # Realistic browser features + java_script_enabled=True, + accept_downloads=True, + ignore_https_errors=True, + # Color scheme and media + color_scheme='light', + reduced_motion='no-preference', + forced_colors='none', + # Additional headers for realism + extra_http_headers={ + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'tr-TR,tr;q=0.9,en;q=0.8', + 'Cache-Control': 'max-age=0', + 'DNT': '1', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Windows"', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + }, + # Permissions to appear realistic + permissions=['geolocation'], + geolocation={'latitude': 41.0082, 'longitude': 28.9784}, # Istanbul + ) + context_recreated = True + if not self.page or self.page.is_closed() or force_new_page or context_recreated or browser_recreated: + if self.page and not self.page.is_closed(): await self.page.close() + if not self.context: raise PlaywrightError("Context is None.") + self.page = await self.context.new_page() + if not self.page: raise PlaywrightError("Failed to create new page.") + self.page.set_default_navigation_timeout(self.request_timeout) + self.page.set_default_timeout(self.request_timeout) + + # CRITICAL: Anti-detection JavaScript injection + await self._inject_stealth_scripts() + if not self.page or self.page.is_closed(): + raise PlaywrightError("Playwright page initialization failed.") + logger.debug("_ensure_playwright_ready completed.") + + async def close_client_session(self): + async with self._lock: + # ... (öncekiyle aynı) + if self.page and not self.page.is_closed(): await self.page.close(); self.page = None + if self.context: await self.context.close(); self.context = None + if self.browser: await self.browser.close(); self.browser = None + if self.playwright_instance: await self.playwright_instance.stop(); self.playwright_instance = None + logger.info("KikApiClient (Playwright): Resources closed.") + + async def _inject_stealth_scripts(self): + """ + Inject comprehensive stealth JavaScript to evade bot detection. + Overrides navigator properties and other fingerprinting vectors. + """ + if not self.page: + logger.warning("Cannot inject stealth scripts: page is None") + return + + logger.debug("Injecting comprehensive stealth scripts...") + + stealth_script = ''' + // Override navigator.webdriver + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + configurable: true + }); + + // Override navigator properties to appear more human + Object.defineProperty(navigator, 'languages', { + get: () => ['tr-TR', 'tr', 'en-US', 'en'], + configurable: true + }); + + Object.defineProperty(navigator, 'platform', { + get: () => 'Win32', + configurable: true + }); + + Object.defineProperty(navigator, 'vendor', { + get: () => 'Google Inc.', + configurable: true + }); + + Object.defineProperty(navigator, 'deviceMemory', { + get: () => 8, + configurable: true + }); + + Object.defineProperty(navigator, 'hardwareConcurrency', { + get: () => 8, + configurable: true + }); + + Object.defineProperty(navigator, 'maxTouchPoints', { + get: () => 0, + configurable: true + }); + + // Override plugins to appear realistic + Object.defineProperty(navigator, 'plugins', { + get: () => { + return [ + { + 0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format", enabledPlugin: Plugin}, + description: "Portable Document Format", + filename: "internal-pdf-viewer", + length: 1, + name: "Chrome PDF Plugin" + }, + { + 0: {type: "application/pdf", suffixes: "pdf", description: "", enabledPlugin: Plugin}, + description: "", + filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", + length: 1, + name: "Chrome PDF Viewer" + } + ]; + }, + configurable: true + }); + + // Override permissions + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: Notification.permission }) : + originalQuery(parameters) + ); + + // Override WebGL rendering context + const getParameter = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function(parameter) { + if (parameter === 37445) { // UNMASKED_VENDOR_WEBGL + return 'Intel Inc.'; + } + if (parameter === 37446) { // UNMASKED_RENDERER_WEBGL + return 'Intel(R) Iris(R) Plus Graphics 640'; + } + return getParameter(parameter); + }; + + // Override canvas fingerprinting + const toBlob = HTMLCanvasElement.prototype.toBlob; + const toDataURL = HTMLCanvasElement.prototype.toDataURL; + const getImageData = CanvasRenderingContext2D.prototype.getImageData; + + const noisify = (canvas, context) => { + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + for (let i = 0; i < imageData.data.length; i += 4) { + imageData.data[i] += Math.floor(Math.random() * 10) - 5; + imageData.data[i + 1] += Math.floor(Math.random() * 10) - 5; + imageData.data[i + 2] += Math.floor(Math.random() * 10) - 5; + } + context.putImageData(imageData, 0, 0); + }; + + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + value: function(callback, type, encoderOptions) { + noisify(this, this.getContext('2d')); + return toBlob.apply(this, arguments); + } + }); + + Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', { + value: function(type, encoderOptions) { + noisify(this, this.getContext('2d')); + return toDataURL.apply(this, arguments); + } + }); + + // Override AudioContext for audio fingerprinting + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const originalAnalyser = audioCtx.createAnalyser; + audioCtx.createAnalyser = function() { + const analyser = originalAnalyser.apply(this, arguments); + const getFloatFrequencyData = analyser.getFloatFrequencyData; + analyser.getFloatFrequencyData = function(array) { + getFloatFrequencyData.apply(this, arguments); + for (let i = 0; i < array.length; i++) { + array[i] += Math.random() * 0.0001; + } + }; + return analyser; + }; + + // Override screen properties + Object.defineProperty(window.screen, 'colorDepth', { + get: () => 24, + configurable: true + }); + + Object.defineProperty(window.screen, 'pixelDepth', { + get: () => 24, + configurable: true + }); + + // Override timezone + Date.prototype.getTimezoneOffset = function() { + return -180; // UTC+3 (Istanbul) + }; + + // Override document.cookie to prevent tracking + const originalCookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + if (originalCookieDescriptor && originalCookieDescriptor.configurable) { + Object.defineProperty(document, 'cookie', { + get: function() { + return originalCookieDescriptor.get.call(this); + }, + set: function(val) { + console.log('Cookie set blocked:', val); + return originalCookieDescriptor.set.call(this, val); + }, + configurable: true + }); + } + + // Remove automation traces + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_JSON; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Object; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Proxy; + + // Add realistic performance timing + if (window.performance && window.performance.timing) { + const timing = window.performance.timing; + const now = Date.now(); + Object.defineProperty(timing, 'navigationStart', { value: now - Math.floor(Math.random() * 1000) + 1000, configurable: false }); + Object.defineProperty(timing, 'loadEventEnd', { value: now - Math.floor(Math.random() * 100) + 100, configurable: false }); + } + + console.log('✓ Stealth scripts injected successfully'); + ''' + + try: + await self.page.add_init_script(stealth_script) + logger.debug("✅ Stealth scripts injected successfully") + except Exception as e: + logger.warning(f"⚠️ Failed to inject stealth scripts: {e}") + + async def _simulate_human_behavior(self, fast_mode: bool = True): + """ + Simulate realistic human behavior patterns to avoid detection. + Includes mouse movements, typing patterns, and natural delays. + + Args: + fast_mode: If True, use minimal timing for speed optimization + """ + if not self.page: + logger.warning("Cannot simulate human behavior: page is None") + return + + logger.debug("🤖 Simulating human behavior patterns...") + + try: + if fast_mode: + # ULTRA-FAST MODE: Minimal human behavior + viewport_size = self.page.viewport_size + if viewport_size and random.random() < 0.7: # 70% chance to do movement + width, height = viewport_size['width'], viewport_size['height'] + + # Single quick mouse movement + x = random.randint(200, width - 200) + y = random.randint(200, height - 200) + await self.page.mouse.move(x, y) + + # Brief scroll (50% chance) + if random.random() < 0.5: + await self.page.mouse.wheel(0, random.randint(50, 100)) + + # Ultra-minimal delay + await asyncio.sleep(random.uniform(0.05, 0.15)) # Reduced from 0.1-0.3 + + else: + # FULL MODE: Original comprehensive behavior + viewport_size = self.page.viewport_size + if viewport_size: + width, height = viewport_size['width'], viewport_size['height'] + + # Generate 3-5 random mouse movements + movements = random.randint(3, 5) + logger.debug(f" 🖱️ Performing {movements} random mouse movements") + + for i in range(movements): + x = random.randint(100, width - 100) + y = random.randint(100, height - 100) + + # Move mouse with realistic speed (not instant) + await self.page.mouse.move(x, y) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 2. Scroll simulation + logger.debug(" 📜 Simulating scroll behavior") + scroll_amount = random.randint(100, 300) + await self.page.mouse.wheel(0, scroll_amount) + await asyncio.sleep(random.uniform(0.2, 0.5)) + + # Scroll back up + await self.page.mouse.wheel(0, -scroll_amount) + await asyncio.sleep(random.uniform(0.2, 0.4)) + + # 3. Random page interaction delays + await asyncio.sleep(random.uniform(0.5, 1.5)) + + logger.debug("✅ Human behavior simulation completed") + + except Exception as e: + logger.warning(f"⚠️ Human behavior simulation failed: {e}") + + async def _human_type(self, selector: str, text: str, clear_first: bool = True, fast_mode: bool = True): + """ + Type text with human-like patterns and delays. + + Args: + selector: CSS selector for the input element + text: Text to type + clear_first: Whether to clear the field first + fast_mode: If True, use minimal delays for speed optimization + """ + if not self.page: + logger.warning("Cannot perform human typing: page is None") + return + + try: + if fast_mode: + # FAST MODE: Direct fill for speed + await self.page.fill(selector, text) + await asyncio.sleep(random.uniform(0.02, 0.05)) # Reduced from 0.05-0.1 + else: + # FULL MODE: Character-by-character human typing + # Focus on the element first + await self.page.focus(selector) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # Clear field if requested + if clear_first: + await self.page.keyboard.press('Control+a') + await asyncio.sleep(random.uniform(0.05, 0.15)) + await self.page.keyboard.press('Delete') + await asyncio.sleep(random.uniform(0.05, 0.15)) + + # Type each character with human-like delays + for char in text: + await self.page.keyboard.type(char) + # Human typing speed: 50-150ms between characters + delay = random.uniform(0.05, 0.15) + + # Occasional longer pauses (thinking) + if random.random() < 0.1: # 10% chance + delay += random.uniform(0.2, 0.8) + + await asyncio.sleep(delay) + + # Brief pause after typing + await asyncio.sleep(random.uniform(0.2, 0.6)) + + logger.debug(f"✅ Human-typed '{text}' into {selector}") + + except Exception as e: + logger.warning(f"⚠️ Human typing failed: {e}") + + async def _human_click(self, selector: str, wait_before: bool = True, wait_after: bool = True, fast_mode: bool = True): + """ + Perform a human-like click with realistic delays and mouse movement. + + Args: + selector: CSS selector or element to click + wait_before: Whether to wait before clicking + wait_after: Whether to wait after clicking + fast_mode: If True, use minimal delays for speed optimization + """ + if not self.page: + logger.warning("Cannot perform human click: page is None") + return + + try: + if fast_mode: + # FAST MODE: Direct click with minimal delay + if wait_before: + await asyncio.sleep(random.uniform(0.02, 0.08)) # Reduced from 0.05-0.15 + + await self.page.click(selector) + + if wait_after: + await asyncio.sleep(random.uniform(0.02, 0.08)) # Reduced from 0.05-0.15 + + else: + # FULL MODE: Realistic mouse movement and timing + # Wait before clicking (thinking time) + if wait_before: + await asyncio.sleep(random.uniform(0.3, 0.8)) + + # Get element bounds for realistic mouse movement + element = await self.page.query_selector(selector) + if element: + box = await element.bounding_box() + if box: + # Move to element with slight randomness + center_x = box['x'] + box['width'] / 2 + center_y = box['y'] + box['height'] / 2 + + # Add small random offset + offset_x = random.uniform(-10, 10) + offset_y = random.uniform(-5, 5) + + await self.page.mouse.move(center_x + offset_x, center_y + offset_y) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # Perform click + await self.page.mouse.click(center_x + offset_x, center_y + offset_y) + + logger.debug(f"✅ Human-clicked {selector}") + else: + # Fallback to regular click + await self.page.click(selector) + logger.debug(f"✅ Fallback-clicked {selector}") + else: + logger.warning(f"⚠️ Element not found for human click: {selector}") + return + + # Wait after clicking (processing time) + if wait_after: + await asyncio.sleep(random.uniform(0.2, 0.6)) + + logger.debug(f"✅ Human-clicked {selector}") + + except Exception as e: + logger.warning(f"⚠️ Human click failed: {e}") + + async def _simulate_page_exploration(self, fast_mode: bool = True): + """ + Simulate natural page exploration before performing the main task. + This helps establish a more human-like session. + + Args: + fast_mode: If True, use minimal exploration for speed optimization + """ + if not self.page: + return + + logger.debug("🕵️ Simulating page exploration...") + + try: + if fast_mode: + # ULTRA-FAST MODE: Minimal exploration + await asyncio.sleep(random.uniform(0.05, 0.1)) # Reduced from 0.1-0.3 + + # Single mouse movement (optional) + try: + elements = await self.page.query_selector_all("input, button") + if elements and random.random() < 0.5: # 50% chance to skip + element = random.choice(elements) + box = await element.bounding_box() + if box: + center_x = box['x'] + box['width'] / 2 + center_y = box['y'] + box['height'] / 2 + await self.page.mouse.move(center_x, center_y) + except: + pass + + await asyncio.sleep(random.uniform(0.02, 0.05)) # Reduced from 0.05-0.15 + + else: + # FULL MODE: Comprehensive exploration + # 1. Brief pause to "read" the page + await asyncio.sleep(random.uniform(1.0, 2.5)) + + # 2. Move mouse to various UI elements (like a human would explore) + explore_selectors = [ + "h1", "h2", ".navbar", "#header", ".logo", + "input", "button", "a", ".form-group" + ] + + explored = 0 + for selector in explore_selectors: + elements = await self.page.query_selector_all(selector) + if elements and explored < 3: # Explore max 3 elements + element = random.choice(elements) + box = await element.bounding_box() + if box: + center_x = box['x'] + box['width'] / 2 + center_y = box['y'] + box['height'] / 2 + + await self.page.mouse.move(center_x, center_y) + await asyncio.sleep(random.uniform(0.3, 0.8)) + explored += 1 + + # 3. Small scroll to simulate reading + await self.page.mouse.wheel(0, random.randint(50, 150)) + await asyncio.sleep(random.uniform(0.5, 1.2)) + + logger.debug("✅ Page exploration completed") + + except Exception as e: + logger.debug(f"⚠️ Page exploration failed: {e}") + + def _parse_decision_entries_from_soup(self, soup: BeautifulSoup, search_karar_tipi: KikKararTipi) -> List[KikDecisionEntry]: + entries: List[KikDecisionEntry] = [] + table = soup.find("table", {"id": self.RESULTS_TABLE_ID}) + + logger.debug(f"Looking for table with ID: {self.RESULTS_TABLE_ID}") + if not table: + logger.warning(f"Table with ID '{self.RESULTS_TABLE_ID}' not found in HTML") + # Log available tables for debugging + all_tables = soup.find_all("table") + logger.debug(f"Found {len(all_tables)} tables in HTML") + for idx, tbl in enumerate(all_tables): + table_id = tbl.get('id', 'no-id') + table_class = tbl.get('class', 'no-class') + rows = tbl.find_all('tr') + logger.debug(f"Table {idx}: id='{table_id}', class='{table_class}', rows={len(rows)}") + + # If this looks like a results table, try to use it + if (table_id and ('grd' in table_id.lower() or 'kurul' in table_id.lower() or 'sonuc' in table_id.lower())) or \ + (isinstance(table_class, list) and any('grid' in cls.lower() or 'result' in cls.lower() for cls in table_class)) or \ + len(rows) > 3: # Table with multiple rows might be results + logger.info(f"Trying to parse table {idx} as potential results table: id='{table_id}'") + table = tbl + break + + if not table: + logger.error("No suitable results table found") + return entries + + rows = table.find_all("tr") + logger.info(f"Found {len(rows)} rows in results table") + + # Debug: Log first few rows structure + for i, row in enumerate(rows[:3]): + cells = row.find_all(["td", "th"]) + cell_texts = [cell.get_text(strip=True)[:30] for cell in cells] + logger.info(f"Row {i} structure: {len(cells)} cells: {cell_texts}") + + for row_idx, row in enumerate(rows): + # Skip first row (search bar with colspan=7) and second row (header with 6 cells) + if row_idx < 2: + logger.debug(f"Skipping header row {row_idx}") + continue + + cells = row.find_all("td") + logger.debug(f"Row {row_idx}: Found {len(cells)} cells") + + # Log cell contents for debugging + if cells and row_idx < 5: # Log first few data rows + for cell_idx, cell in enumerate(cells): + cell_text = cell.get_text(strip=True)[:50] # First 50 chars + logger.debug(f" Cell {cell_idx}: '{cell_text}...'") + + # Be more flexible with cell count - try 6 cells first, then adapt + if len(cells) >= 5: # At least 5 cells for minimum required data + try: + # Try to find preview button in first cell or any cell with a link + preview_button_tag = None + event_target = "" + + # Look for preview button in first few cells + for cell_idx in range(min(3, len(cells))): + cell = cells[cell_idx] + # Try multiple patterns for preview button (based on actual HTML structure) + preview_candidates = [ + cell.find("a", id="btnOnizle"), # Exact match + cell.find("a", id=re.compile(r"btnOnizle$")), + cell.find("a", id=re.compile(r"btn.*Onizle")), + cell.find("a", id=re.compile(r".*Onizle.*")), + cell.find("a", href=re.compile(r"__doPostBack")) + ] + + for candidate in preview_candidates: + if candidate and candidate.has_attr('href'): + match = re.search(r"__doPostBack\('([^']*)','([^']*)'\)", candidate['href']) + if match: + event_target = match.group(1) + preview_button_tag = candidate + logger.debug(f"Row {row_idx}: Found event_target '{event_target}' in cell {cell_idx}") + break + + if preview_button_tag: + break + + if not preview_button_tag: + logger.debug(f"Row {row_idx}: No preview button found in any cell") + # Log what links we found + for cell_idx, cell in enumerate(cells[:3]): + links_in_cell = cell.find_all("a") + logger.debug(f" Cell {cell_idx}: {len(links_in_cell)} links") + for link in links_in_cell[:2]: + logger.debug(f" Link id='{link.get('id')}', href='{link.get('href', '')[:50]}...'") + + # Try to find decision data spans with more flexible patterns + karar_no_span = None + karar_tarihi_span = None + idare_span = None + basvuru_sahibi_span = None + ihale_span = None + + # Try different span patterns for karar no (usually in cell 1) + for cell_idx in range(min(4, len(cells))): + if not karar_no_span: + cell = cells[cell_idx] + candidates = [ + cell.find("span", id="lblKno"), # Exact match based on actual HTML + cell.find("span", id=re.compile(r"lblKno$")), + cell.find("span", id=re.compile(r".*Kno.*")), + cell.find("span", id=re.compile(r".*KararNo.*")), + cell.find("span", id=re.compile(r".*No.*")) + ] + for candidate in candidates: + if candidate and candidate.get_text(strip=True): + karar_no_span = candidate + logger.debug(f"Row {row_idx}: Found karar_no in cell {cell_idx}") + break + + # Try different patterns for karar tarihi (usually in cell 2) + for cell_idx in range(min(4, len(cells))): + if not karar_tarihi_span: + cell = cells[cell_idx] + candidates = [ + cell.find("span", id="lblKtar"), # Exact match based on actual HTML + cell.find("span", id=re.compile(r"lblKtar$")), + cell.find("span", id=re.compile(r".*Ktar.*")), + cell.find("span", id=re.compile(r".*Tarih.*")), + cell.find("span", id=re.compile(r".*Date.*")) + ] + for candidate in candidates: + if candidate and candidate.get_text(strip=True): + # Check if it looks like a date + text = candidate.get_text(strip=True) + if re.match(r'\d{1,2}[./]\d{1,2}[./]\d{4}', text): + karar_tarihi_span = candidate + logger.debug(f"Row {row_idx}: Found karar_tarihi in cell {cell_idx}") + break + + # Find other spans in remaining cells (if we have 6 cells) - using exact IDs + if len(cells) >= 6: + idare_span = cells[3].find("span", id="lblIdare") or cells[3].find("span") + basvuru_sahibi_span = cells[4].find("span", id="lblSikayetci") or cells[4].find("span") + ihale_span = cells[5].find("span", id="lblIhale") or cells[5].find("span") + elif len(cells) == 5: + # Adjust for 5-cell layout + idare_span = cells[2].find("span") if cells[2] != cells[1] else None + basvuru_sahibi_span = cells[3].find("span") if len(cells) > 3 else None + ihale_span = cells[4].find("span") if len(cells) > 4 else None + + # Log what we found + logger.debug(f"Row {row_idx}: karar_no_span={karar_no_span is not None}, " + f"karar_tarihi_span={karar_tarihi_span is not None}, " + f"event_target={bool(event_target)}") + + # For KIK, we need at least karar_no and karar_tarihi, event_target is helpful but not critical + if not (karar_no_span and karar_tarihi_span): + logger.debug(f"Row {row_idx}: Missing required fields (karar_no or karar_tarihi), skipping") + # Log what spans we found in cells + for i, cell in enumerate(cells): + spans = cell.find_all("span") + if spans: + span_info = [] + for s in spans: + span_id = s.get('id', 'no-id') + span_text = s.get_text(strip=True)[:20] + span_info.append(f"{span_id}:'{span_text}...'") + logger.debug(f" Cell {i} spans: {span_info}") + continue + + # If we don't have event_target, we can still create an entry but mark it specially + if not event_target: + logger.warning(f"Row {row_idx}: No event_target found, document retrieval won't work") + event_target = f"missing_target_row_{row_idx}" # Placeholder + + # Karar tipini arama parametresinden alıyoruz, çünkü HTML'de direkt olarak bulunmuyor. + try: + entry = KikDecisionEntry( + preview_event_target=event_target, + kararNo=karar_no_span.get_text(strip=True), + karar_tipi=search_karar_tipi, # Arama yapılan karar tipini ekle + kararTarihi=karar_tarihi_span.get_text(strip=True), + idare=idare_span.get_text(strip=True) if idare_span else None, + basvuruSahibi=basvuru_sahibi_span.get_text(strip=True) if basvuru_sahibi_span else None, + ihaleKonusu=ihale_span.get_text(strip=True) if ihale_span else None, + ) + entries.append(entry) + logger.info(f"Row {row_idx}: Successfully parsed decision: {entry.karar_no_str}") + except Exception as e: + logger.error(f"Row {row_idx}: Error creating KikDecisionEntry: {e}") + continue + + except Exception as e: + logger.error(f"Error parsing row {row_idx}: {e}", exc_info=True) + else: + logger.warning(f"Row {row_idx}: Expected at least 5 cells but found {len(cells)}, skipping") + if len(cells) > 0: + cell_texts = [cell.get_text(strip=True)[:50] for cell in cells[:3]] + logger.debug(f"Row {row_idx} cells preview: {cell_texts}") + + logger.info(f"Parsed {len(entries)} decision entries from {len(rows)} rows") + return entries + + def _parse_total_records_from_soup(self, soup: BeautifulSoup) -> int: + # ... (öncekiyle aynı) ... + try: + pager_div = soup.find("div", class_="gridToplamSayi") + if pager_div: + match = re.search(r"Toplam Kayıt Sayısı:(\d+)", pager_div.get_text(strip=True)) + if match: return int(match.group(1)) + except: pass + return 0 + + def _parse_current_page_from_soup(self, soup: BeautifulSoup) -> int: + # ... (öncekiyle aynı) ... + try: + pager_div = soup.find("div", class_="sayfalama") + if pager_div: + active_page_span = pager_div.find("span", class_="active") + if active_page_span: return int(active_page_span.get_text(strip=True)) + except: pass + return 1 + + async def search_decisions(self, search_params: KikSearchRequest) -> KikSearchResult: + await self._ensure_playwright_ready() + page = self.page + search_url = f"{self.BASE_URL}{self.SEARCH_PAGE_PATH}" + try: + if page.url != search_url: + await page.goto(search_url, wait_until="networkidle", timeout=self.request_timeout) + + # Simulate natural page exploration after navigation (FAST MODE) + await self._simulate_page_exploration(fast_mode=True) + + search_button_selector = f"a[id='{self.FIELD_LOCATORS['search_button_id']}']" + await page.wait_for_selector(search_button_selector, state="visible", timeout=self.request_timeout) + + current_karar_tipi_value = search_params.karar_tipi.value + radio_locator_selector = f"{self.FIELD_LOCATORS['karar_tipi_radio_group']}[value='{current_karar_tipi_value}']" + if not await page.locator(radio_locator_selector).is_checked(): + js_target_radio = f"ctl00$ContentPlaceHolder1${current_karar_tipi_value}" + logger.info(f"Selecting radio button: {js_target_radio}") + async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout): + await page.evaluate(f"javascript:__doPostBack('{js_target_radio}','')") + # Ultra-fast wait for page to stabilize after radio button change + await page.wait_for_timeout(300) # Reduced from 1000ms + logger.info("Radio button selection completed") + + # Helper function for human-like form filling (FAST MODE) + async def human_fill_if_value(selector_key: str, value: Optional[str]): + if value is not None: + selector = self.FIELD_LOCATORS[selector_key] + await self._human_type(selector, value, fast_mode=True) + + # Karar No'yu KİK sitesine göndermeden önce '_' -> '/' dönüşümü yap + karar_no_for_kik_form = None + if search_params.karar_no: # search_params.karar_no Claude'dan '_' ile gelmiş olabilir + karar_no_for_kik_form = search_params.karar_no.replace('_', '/') + logger.info(f"Using karar_no '{karar_no_for_kik_form}' (transformed from '{search_params.karar_no}') for KIK form.") + + # Fill form fields with FAST human-like behavior + logger.info("Filling form fields with fast mode...") + + # Start with FAST mouse behavior simulation + await self._simulate_human_behavior(fast_mode=True) + + await human_fill_if_value('karar_metni', search_params.karar_metni) + await human_fill_if_value('karar_no', karar_no_for_kik_form) # Dönüştürülmüş halini kullan + await human_fill_if_value('karar_tarihi_baslangic', search_params.karar_tarihi_baslangic) + await human_fill_if_value('karar_tarihi_bitis', search_params.karar_tarihi_bitis) + await human_fill_if_value('resmi_gazete_sayisi', search_params.resmi_gazete_sayisi) + await human_fill_if_value('resmi_gazete_tarihi', search_params.resmi_gazete_tarihi) + await human_fill_if_value('basvuru_konusu_ihale', search_params.basvuru_konusu_ihale) + await human_fill_if_value('basvuru_sahibi', search_params.basvuru_sahibi) + await human_fill_if_value('ihaleyi_yapan_idare', search_params.ihaleyi_yapan_idare) + + if search_params.yil: + await page.select_option(self.FIELD_LOCATORS['yil'], value=search_params.yil) + await page.wait_for_timeout(50) # Reduced from 100ms + + logger.info("Form filling completed, preparing for search...") + + # Additional FAST human behavior before search + await self._simulate_human_behavior(fast_mode=True) + + action_is_search_button_click = (search_params.page == 1) + event_target_for_submit: str + + try: + if action_is_search_button_click: + event_target_for_submit = self.FIELD_LOCATORS['search_button_id'] + # Use human-like clicking for search button + search_button_selector = f"a[id='{event_target_for_submit}']" + logger.info(f"Performing human-like search button click...") + + try: + # Hide datepicker first to prevent interference + await page.evaluate("$('#ui-datepicker-div').hide()") + + # FAST Human-like click on search button + await self._human_click(search_button_selector, wait_before=True, wait_after=False, fast_mode=True) + + # Wait for navigation + await page.wait_for_load_state("networkidle", timeout=self.request_timeout) + logger.info("Search navigation completed successfully") + except Exception as e: + logger.warning(f"Human click failed, falling back to JavaScript: {e}") + # Hide datepicker and use JavaScript fallback + await page.evaluate("$('#ui-datepicker-div').hide()") + async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout): + await page.evaluate(f"javascript:__doPostBack('{event_target_for_submit}','')") + logger.info("Search navigation completed via fallback") + else: + # Pagination - use original method for consistency + page_link_ctl_number = search_params.page + 2 + event_target_for_submit = f"ctl00$ContentPlaceHolder1$grdKurulKararSorguSonuc$ctl14$ctl{page_link_ctl_number:02d}" + logger.info(f"Executing pagination with event target: {event_target_for_submit}") + + async with page.expect_navigation(wait_until="networkidle", timeout=self.request_timeout): + await page.evaluate(f"javascript:__doPostBack('{event_target_for_submit}','')") + logger.info("Pagination navigation completed successfully") + except PlaywrightTimeoutError: + logger.warning("Search navigation timed out, but continuing...") + await page.wait_for_timeout(5000) # Longer wait if navigation fails + + # Ultra-fast wait time for results to load + logger.info("Waiting for search results to load...") + await page.wait_for_timeout(500) # Reduced from 1000ms + + results_table_dom_selector = f"table#{self.RESULTS_TABLE_ID}" + try: + # First wait for any tables to appear (more general check) + logger.info("Waiting for any tables to appear...") + await page.wait_for_function(""" + () => document.querySelectorAll('table').length > 0 + """, timeout=4000) # Reduced from 8000ms + logger.info("At least one table appeared") + + # Then wait for our specific table + await page.wait_for_selector(results_table_dom_selector, timeout=4000, state="attached") # Reduced from 8000ms + logger.debug("Results table attached to DOM") + + # Wait for table to have some content (more than just headers) + await page.wait_for_function(f""" + () => {{ + const table = document.querySelector('{results_table_dom_selector}'); + return table && table.querySelectorAll('tr').length > 2; + }} + """, timeout=4000) # Reduced from 20000ms + logger.debug("Results table populated with data") + + # Ultra-fast additional wait for any remaining JavaScript + await page.wait_for_timeout(500) # Reduced from 3000ms + + except PlaywrightTimeoutError: + logger.warning(f"Timeout waiting for results table '{results_table_dom_selector}'.") + # Try one more wait for content placeholder + try: + await page.wait_for_selector("#ctl00_ContentPlaceHolder1", timeout=10000) + logger.info("ContentPlaceHolder1 found, checking for tables...") + await page.wait_for_timeout(5000) + except PlaywrightTimeoutError: + logger.warning("ContentPlaceHolder1 also not found - content may not have loaded") + + html_content = await page.content() + soup = BeautifulSoup(html_content, "html.parser") + # ... (hata ve sonuç yok mesajı kontrolü aynı) ... + validation_summary_tag = soup.find("div", id=self.VALIDATION_SUMMARY_SELECTOR.split('[')[0].split(':')[0]) + if validation_summary_tag and validation_summary_tag.get_text(strip=True) and \ + ("display: none" not in validation_summary_tag.get("style", "").lower() if validation_summary_tag.has_attr("style") else True) and \ + validation_summary_tag.get_text(strip=True) != "": + return KikSearchResult(decisions=[], total_records=0, current_page=search_params.page) + message_content_div = soup.find("div", id=self.NO_RESULTS_MESSAGE_SELECTOR.split(':')[0]) + if message_content_div and "kayıt bulunamamıştır" in message_content_div.get_text(strip=True).lower(): + return KikSearchResult(decisions=[], total_records=0, current_page=1) + + # _parse_decision_entries_from_soup'a arama yapılan karar_tipi'ni gönder + decisions = self._parse_decision_entries_from_soup(soup, search_params.karar_tipi) + total_records = self._parse_total_records_from_soup(soup) + current_page_from_html = self._parse_current_page_from_soup(soup) + return KikSearchResult(decisions=decisions, total_records=total_records, current_page=current_page_from_html) + except Exception as e: + logger.error(f"Error during KIK decision search: {e}", exc_info=True) + return KikSearchResult(decisions=[], current_page=search_params.page) + + def _clean_html_for_markdown(self, html_content: str) -> str: + # ... (öncekiyle aynı) ... + if not html_content: return "" + return html_parser.unescape(html_content) + + def _convert_html_to_markdown_internal(self, html_fragment: str) -> Optional[str]: + # ... (öncekiyle aynı) ... + if not html_fragment: return None + cleaned_html = self._clean_html_for_markdown(html_fragment) + markdown_output = None + try: + # Convert HTML string to bytes and create BytesIO stream + html_bytes = cleaned_html.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown(enable_plugins=True, remove_alt_whitespace=True, keep_underline=True) + markdown_output = md_converter.convert(html_stream).text_content + if markdown_output: markdown_output = re.sub(r'\n{3,}', '\n\n', markdown_output).strip() + except Exception as e: logger.error(f"MarkItDown conversion error: {e}", exc_info=True) + return markdown_output + + + async def get_decision_document_as_markdown( + self, + karar_id_b64: str, + page_number: int = 1 + ) -> KikDocumentMarkdown: + await self._ensure_playwright_ready() + # Bu metodun kendi içinde yeni bir 'page' nesnesi ('doc_page_for_content') kullanacağını unutmayın, + # ana 'self.page' arama sonuçları sayfasında kalır. + current_main_page = self.page # Ana arama sonuçları sayfasını referans alalım + + try: + decoded_key = base64.b64decode(karar_id_b64.encode('utf-8')).decode('utf-8') + karar_tipi_value, karar_no_for_search = decoded_key.split('|', 1) + original_karar_tipi = KikKararTipi(karar_tipi_value) + logger.info(f"KIK Get Detail: Decoded karar_id '{karar_id_b64}' to Karar Tipi: {original_karar_tipi.value}, Karar No: {karar_no_for_search}. Requested Markdown Page: {page_number}") + except Exception as e_decode: + logger.error(f"Invalid karar_id format. Could not decode Base64 or split: {karar_id_b64}. Error: {e_decode}") + return KikDocumentMarkdown(retrieved_with_karar_id=karar_id_b64, error_message="Invalid karar_id format.", current_page=page_number) + + default_error_response_data = { + "retrieved_with_karar_id": karar_id_b64, + "retrieved_karar_no": karar_no_for_search, + "retrieved_karar_tipi": original_karar_tipi, + "error_message": "An unspecified error occurred.", + "current_page": page_number, "total_pages": 1, "is_paginated": False + } + + # Ana arama sayfasında olduğumuzdan emin olalım + if self.SEARCH_PAGE_PATH not in current_main_page.url: + logger.info(f"Not on search page ({current_main_page.url}). Navigating to {self.SEARCH_PAGE_PATH} before targeted search for document.") + await current_main_page.goto(f"{self.BASE_URL}{self.SEARCH_PAGE_PATH}", wait_until="networkidle", timeout=self.request_timeout) + await current_main_page.wait_for_selector(f"a[id='{self.FIELD_LOCATORS['search_button_id']}']", state="visible", timeout=self.request_timeout) + + targeted_search_params = KikSearchRequest( + karar_no=karar_no_for_search, + karar_tipi=original_karar_tipi, + page=1 + ) + logger.info(f"Performing targeted search for Karar No: {karar_no_for_search}") + # search_decisions kendi içinde _ensure_playwright_ready çağırır ve self.page'i kullanır. + # Bu, current_main_page ile aynı olmalı. + search_results = await self.search_decisions(targeted_search_params) + + if not search_results.decisions: + default_error_response_data["error_message"] = f"Decision with Karar No '{karar_no_for_search}' (Tipi: {original_karar_tipi.value}) not found by internal search." + return KikDocumentMarkdown(**default_error_response_data) + + decision_to_fetch = None + for dec_entry in search_results.decisions: + if dec_entry.karar_no_str == karar_no_for_search and dec_entry.karar_tipi == original_karar_tipi: + decision_to_fetch = dec_entry + break + + if not decision_to_fetch: + default_error_response_data["error_message"] = f"Karar No '{karar_no_for_search}' (Tipi: {original_karar_tipi.value}) not present with an exact match in first page of targeted search results." + return KikDocumentMarkdown(**default_error_response_data) + + decision_preview_event_target = decision_to_fetch.preview_event_target + logger.info(f"Found target decision. Using preview_event_target: {decision_preview_event_target} for Karar No: {decision_to_fetch.karar_no_str}") + + iframe_document_url_str = None + karar_id_param_from_url_on_doc_page = None + document_html_content = "" + + try: + logger.info(f"Evaluating __doPostBack on main page to show modal for: {decision_preview_event_target}") + # Bu evaluate, self.page (yani current_main_page) üzerinde çalışır + await current_main_page.evaluate(f"javascript:__doPostBack('{decision_preview_event_target}','')") + await current_main_page.wait_for_timeout(1000) + logger.info(f"Executed __doPostBack for {decision_preview_event_target} on main page.") + + iframe_selector = "iframe#iframe_detayPopUp" + modal_visible_selector = "div#detayPopUp.in" + + try: + logger.info(f"Waiting for modal '{modal_visible_selector}' to be visible and iframe '{iframe_selector}' src to be populated on main page...") + await current_main_page.wait_for_function( + f""" + () => {{ + const modal = document.querySelector('{modal_visible_selector}'); + const iframe = document.querySelector('{iframe_selector}'); + const modalIsTrulyVisible = modal && (window.getComputedStyle(modal).display !== 'none'); + return modalIsTrulyVisible && + iframe && iframe.getAttribute('src') && + iframe.getAttribute('src').includes('KurulKararGoster.aspx'); + }} + """, + timeout=self.request_timeout / 2 + ) + iframe_src_value = await current_main_page.locator(iframe_selector).get_attribute("src") + logger.info(f"Iframe src populated: {iframe_src_value}") + + except PlaywrightTimeoutError: + logger.warning(f"Timeout waiting for KIK iframe src for {decision_preview_event_target}. Trying to parse from static content after presumed update.") + html_after_postback = await current_main_page.content() + # ... (fallback parsing öncekiyle aynı, default_error_response_data set edilir ve return edilir) ... + soup_after_postback = BeautifulSoup(html_after_postback, "html.parser") + detay_popup_div = soup_after_postback.find("div", {"id": "detayPopUp", "class": re.compile(r"\bin\b")}) + if not detay_popup_div: detay_popup_div = soup_after_postback.find("div", {"id": "detayPopUp", "style": re.compile(r"display:\s*block", re.I)}) + iframe_tag = detay_popup_div.find("iframe", {"id": "iframe_detayPopUp"}) if detay_popup_div else None + if iframe_tag and iframe_tag.has_attr("src") and iframe_tag["src"]: iframe_src_value = iframe_tag["src"] + else: + default_error_response_data["error_message"]="Timeout or failure finding decision content iframe URL after postback." + return KikDocumentMarkdown(**default_error_response_data) + + if not iframe_src_value or not iframe_src_value.strip(): + default_error_response_data["error_message"]="Extracted iframe URL for decision content is empty." + return KikDocumentMarkdown(**default_error_response_data) + + # iframe_src_value göreceli bir URL ise, ana sayfanın URL'si ile birleştir + iframe_document_url_str = urllib.parse.urljoin(current_main_page.url, iframe_src_value) + logger.info(f"Constructed absolute iframe_document_url_str for goto: {iframe_document_url_str}") # Log this absolute URL + default_error_response_data["source_url"] = iframe_document_url_str + + parsed_url = urllib.parse.urlparse(iframe_document_url_str) + query_params = urllib.parse.parse_qs(parsed_url.query) + karar_id_param_from_url_on_doc_page = query_params.get("KararId", [None])[0] + default_error_response_data["karar_id_param_from_url"] = karar_id_param_from_url_on_doc_page + if not karar_id_param_from_url_on_doc_page: + default_error_response_data["error_message"]="KararId (KIK internal ID) not found in extracted iframe URL." + return KikDocumentMarkdown(**default_error_response_data) + + logger.info(f"Fetching KIK decision content from iframe URL using a new Playwright page: {iframe_document_url_str}") + + doc_page_for_content = await self.context.new_page() + try: + # `goto` metoduna MUTLAK URL verilmeli. Loglanan URL'nin mutlak olduğundan emin olalım. + await doc_page_for_content.goto(iframe_document_url_str, wait_until="domcontentloaded", timeout=self.request_timeout) + document_html_content = await doc_page_for_content.content() + except Exception as e_doc_page: + logger.error(f"Error navigating or getting content from doc_page ({iframe_document_url_str}): {e_doc_page}") + if doc_page_for_content and not doc_page_for_content.is_closed(): await doc_page_for_content.close() + default_error_response_data["error_message"]=f"Failed to load decision detail page: {e_doc_page}" + return KikDocumentMarkdown(**default_error_response_data) + finally: + if doc_page_for_content and not doc_page_for_content.is_closed(): + await doc_page_for_content.close() + + soup_decision_detail = BeautifulSoup(document_html_content, "html.parser") + karar_content_span = soup_decision_detail.find("span", {"id": "ctl00_ContentPlaceHolder1_lblKarar"}) + actual_decision_html = karar_content_span.decode_contents() if karar_content_span else document_html_content + full_markdown_content = self._convert_html_to_markdown_internal(actual_decision_html) + + if not full_markdown_content: + default_error_response_data["error_message"]="Markdown conversion failed or returned empty content." + try: + if await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).is_visible(timeout=1000): + await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).click() + except: pass + return KikDocumentMarkdown(**default_error_response_data) + + content_length = len(full_markdown_content); total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) or 1 + current_page_clamped = max(1, min(page_number, total_pages)) + start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE + markdown_chunk = full_markdown_content[start_index : start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE] + + try: + if await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).is_visible(timeout=2000): + await current_main_page.locator(self.MODAL_CLOSE_BUTTON_SELECTOR).click() + await current_main_page.wait_for_selector(f"div#detayPopUp:not(.in)", timeout=5000) + except: pass + + return KikDocumentMarkdown( + retrieved_with_karar_id=karar_id_b64, + retrieved_karar_no=karar_no_for_search, + retrieved_karar_tipi=original_karar_tipi, + kararIdParam=karar_id_param_from_url_on_doc_page, + markdown_chunk=markdown_chunk, source_url=iframe_document_url_str, + current_page=current_page_clamped, total_pages=total_pages, + is_paginated=(total_pages > 1), full_content_char_count=content_length + ) + except Exception as e: + logger.error(f"Error in get_decision_document_as_markdown for Karar ID {karar_id_b64}: {e}", exc_info=True) + default_error_response_data["error_message"] = f"General error: {str(e)}" + return KikDocumentMarkdown(**default_error_response_data) diff --git a/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/models.py new file mode 100644 index 0000000..6b6632b --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/kik_mcp_module/models.py @@ -0,0 +1,74 @@ +# kik_mcp_module/models.py +from pydantic import BaseModel, Field, HttpUrl, computed_field, ConfigDict +from typing import List, Optional +from enum import Enum +import base64 # Base64 encoding/decoding için + +class KikKararTipi(str, Enum): + """Enum for KIK (Public Procurement Authority) Decision Types.""" + UYUSMAZLIK = "rbUyusmazlik" + DUZENLEYICI = "rbDuzenleyici" + MAHKEME = "rbMahkeme" + +class KikSearchRequest(BaseModel): + """Model for KIK Decision search criteria.""" + karar_tipi: KikKararTipi = Field(KikKararTipi.UYUSMAZLIK, description="Type") + karar_no: str = Field("", description="No") + karar_tarihi_baslangic: str = Field("", description="Start", pattern=r"^\d{2}\.\d{2}\.\d{4}$|^$") + karar_tarihi_bitis: str = Field("", description="End", pattern=r"^\d{2}\.\d{2}\.\d{4}$|^$") + resmi_gazete_sayisi: str = Field("", description="Gazette") + resmi_gazete_tarihi: str = Field("", description="Date", pattern=r"^\d{2}\.\d{2}\.\d{4}$|^$") + basvuru_konusu_ihale: str = Field("", description="Subject") + basvuru_sahibi: str = Field("", description="Applicant") + ihaleyi_yapan_idare: str = Field("", description="Entity") + yil: str = Field("", description="Year") + karar_metni: str = Field("", description="Text") + page: int = Field(1, ge=1, description="Page") + +class KikDecisionEntry(BaseModel): + """Represents a single decision entry from KIK search results.""" + preview_event_target: str = Field(..., description="Event target") + karar_no_str: str = Field(..., alias="kararNo", description="Decision number") + karar_tipi: KikKararTipi = Field(..., description="Decision type") + + karar_tarihi_str: str = Field(..., alias="kararTarihi", description="Date") + idare_str: str = Field("", alias="idare", description="Entity") + basvuru_sahibi_str: str = Field("", alias="basvuruSahibi", description="Applicant") + ihale_konusu_str: str = Field("", alias="ihaleKonusu", description="Subject") + + @computed_field + @property + def karar_id(self) -> str: + """ + A Base64 encoded unique ID for the decision, combining decision type and number. + Format before encoding: "{karar_tipi.value}|{karar_no_str}" + """ + combined_key = f"{self.karar_tipi.value}|{self.karar_no_str}" + return base64.b64encode(combined_key.encode('utf-8')).decode('utf-8') + + model_config = ConfigDict(populate_by_name=True) + +class KikSearchResult(BaseModel): + """Model for KIK search results.""" + decisions: List[KikDecisionEntry] + total_records: int = 0 + current_page: int = 1 + +class KikDocumentMarkdown(BaseModel): + """ + KIK decision document, with Markdown content potentially paginated. + """ + retrieved_with_karar_id: Optional[str] = Field(None, description="Request ID") + retrieved_karar_no: Optional[str] = Field(None, description="Decision number") + retrieved_karar_tipi: Optional[KikKararTipi] = Field(None, description="Decision type") + + karar_id_param_from_url: Optional[str] = Field(None, alias="kararIdParam", description="Internal ID") + markdown_chunk: Optional[str] = Field(None, description="Content") + source_url: Optional[str] = Field(None, description="Source URL") + error_message: Optional[str] = Field(None, description="Error") + current_page: int = Field(1, description="Page") + total_pages: int = Field(1, description="Total pages") + is_paginated: bool = Field(False, description="Paginated") + full_content_char_count: Optional[int] = Field(None, description="Char count") + + model_config = ConfigDict(populate_by_name=True) \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/__init__.py new file mode 100644 index 0000000..0409b6b --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/__init__.py @@ -0,0 +1 @@ +# kvkk_mcp_module/__init__.py \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/client.py new file mode 100644 index 0000000..ab26bb8 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/client.py @@ -0,0 +1,372 @@ +# kvkk_mcp_module/client.py + +import httpx +from bs4 import BeautifulSoup +from typing import List, Optional, Dict, Any +import logging +import os +import re +import io +import math +from urllib.parse import urljoin, urlparse, parse_qs +from markitdown import MarkItDown +from pydantic import HttpUrl + +from .models import ( + KvkkSearchRequest, + KvkkDecisionSummary, + KvkkSearchResult, + KvkkDocumentMarkdown +) + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + +class KvkkApiClient: + """ + API client for searching and retrieving KVKK (Personal Data Protection Authority) decisions + using Brave Search API for discovery and direct HTTP requests for content retrieval. + """ + + BRAVE_API_URL = "https://api.search.brave.com/res/v1/web/search" + KVKK_BASE_URL = "https://www.kvkk.gov.tr" + DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page + + def __init__(self, request_timeout: float = 60.0): + """Initialize the KVKK API client.""" + self.brave_api_token = os.getenv("BRAVE_API_TOKEN") + if not self.brave_api_token: + # Fallback to provided free token + self.brave_api_token = "BSAuaRKB-dvSDSQxIN0ft1p2k6N82Kq" + logger.info("Using fallback Brave API token (limited free token)") + else: + logger.info("Using Brave API token from environment variable") + + self.http_client = httpx.AsyncClient( + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=request_timeout, + verify=True, + follow_redirects=True + ) + + def _construct_search_query(self, keywords: str) -> str: + """Construct the search query for Brave API.""" + base_query = 'site:kvkk.gov.tr "karar özeti"' + if keywords.strip(): + return f"{base_query} {keywords.strip()}" + return base_query + + def _extract_decision_id_from_url(self, url: str) -> Optional[str]: + """Extract decision ID from KVKK decision URL.""" + try: + # Example URL: https://www.kvkk.gov.tr/Icerik/7288/2021-1303 + parsed_url = urlparse(url) + path_parts = parsed_url.path.strip('/').split('/') + + if len(path_parts) >= 3 and path_parts[0] == 'Icerik': + # Extract the decision ID from the path + decision_id = '/'.join(path_parts[1:]) # e.g., "7288/2021-1303" + return decision_id + + except Exception as e: + logger.debug(f"Could not extract decision ID from URL {url}: {e}") + + return None + + def _extract_decision_metadata_from_title(self, title: str) -> Dict[str, Optional[str]]: + """Extract decision metadata from title string.""" + metadata = { + "decision_date": None, + "decision_number": None + } + + if not title: + return metadata + + # Extract decision date (DD/MM/YYYY format) + date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', title) + if date_match: + metadata["decision_date"] = date_match.group(1) + + # Extract decision number (YYYY/XXXX format) + number_match = re.search(r'(\d{4}/\d+)', title) + if number_match: + metadata["decision_number"] = number_match.group(1) + + return metadata + + async def search_decisions(self, params: KvkkSearchRequest) -> KvkkSearchResult: + """Search for KVKK decisions using Brave API.""" + + search_query = self._construct_search_query(params.keywords) + logger.info(f"KvkkApiClient: Searching with query: {search_query}") + + try: + # Calculate offset for pagination + offset = (params.page - 1) * params.pageSize + + response = await self.http_client.get( + self.BRAVE_API_URL, + headers={ + "Accept": "application/json", + "Accept-Encoding": "gzip", + "x-subscription-token": self.brave_api_token + }, + params={ + "q": search_query, + "country": "TR", + "search_lang": "tr", + "ui_lang": "tr-TR", + "offset": offset, + "count": params.pageSize + } + ) + + response.raise_for_status() + data = response.json() + + # Extract search results + decisions = [] + web_results = data.get("web", {}).get("results", []) + + for result in web_results: + title = result.get("title", "") + url = result.get("url", "") + description = result.get("description", "") + + # Extract metadata from title + metadata = self._extract_decision_metadata_from_title(title) + + # Extract decision ID from URL + decision_id = self._extract_decision_id_from_url(url) + + decision = KvkkDecisionSummary( + title=title, + url=HttpUrl(url) if url else None, + description=description, + decision_id=decision_id, + publication_date=metadata.get("decision_date"), + decision_number=metadata.get("decision_number") + ) + decisions.append(decision) + + # Get total results if available + total_results = None + query_info = data.get("query", {}) + if "total_results" in query_info: + total_results = query_info["total_results"] + + return KvkkSearchResult( + decisions=decisions, + total_results=total_results, + page=params.page, + pageSize=params.pageSize, + query=search_query + ) + + except httpx.RequestError as e: + logger.error(f"KvkkApiClient: HTTP request error during search: {e}") + return KvkkSearchResult( + decisions=[], + total_results=0, + page=params.page, + pageSize=params.pageSize, + query=search_query + ) + except Exception as e: + logger.error(f"KvkkApiClient: Unexpected error during search: {e}") + return KvkkSearchResult( + decisions=[], + total_results=0, + page=params.page, + pageSize=params.pageSize, + query=search_query + ) + + def _extract_decision_content_from_html(self, html: str, url: str) -> Dict[str, Any]: + """Extract decision content from KVKK decision page HTML.""" + try: + soup = BeautifulSoup(html, 'html.parser') + + # Extract title + title = None + title_element = soup.find('h3', class_='blog-post-title') + if title_element: + title = title_element.get_text(strip=True) + elif soup.title: + title = soup.title.get_text(strip=True) + + # Extract decision content from the main content div + content_div = soup.find('div', class_='blog-post-inner') + if not content_div: + # Fallback to other possible content containers + content_div = soup.find('div', style='text-align:justify;') + if not content_div: + logger.warning(f"Could not find decision content div in {url}") + return { + "title": title, + "decision_date": None, + "decision_number": None, + "subject_summary": None, + "html_content": None + } + + # Extract decision metadata from table + decision_date = None + decision_number = None + subject_summary = None + + table = content_div.find('table') + if table: + rows = table.find_all('tr') + for row in rows: + cells = row.find_all('td') + if len(cells) >= 3: + field_name = cells[0].get_text(strip=True) + field_value = cells[2].get_text(strip=True) + + if 'Karar Tarihi' in field_name: + decision_date = field_value + elif 'Karar No' in field_name: + decision_number = field_value + elif 'Konu Özeti' in field_name: + subject_summary = field_value + + return { + "title": title, + "decision_date": decision_date, + "decision_number": decision_number, + "subject_summary": subject_summary, + "html_content": str(content_div) + } + + except Exception as e: + logger.error(f"Error extracting content from HTML for {url}: {e}") + return { + "title": None, + "decision_date": None, + "decision_number": None, + "subject_summary": None, + "html_content": None + } + + def _convert_html_to_markdown(self, html_content: str) -> Optional[str]: + """Convert HTML content to Markdown using MarkItDown with BytesIO to avoid filename length issues.""" + if not html_content: + return None + + try: + # Convert HTML string to bytes and create BytesIO stream + html_bytes = html_content.encode('utf-8') + html_stream = io.BytesIO(html_bytes) + + # Pass BytesIO stream to MarkItDown to avoid temp file creation + md_converter = MarkItDown(enable_plugins=False) + result = md_converter.convert(html_stream) + return result.text_content + except Exception as e: + logger.error(f"Error converting HTML to Markdown: {e}") + return None + + async def get_decision_document(self, decision_url: str, page_number: int = 1) -> KvkkDocumentMarkdown: + """Retrieve and convert a KVKK decision document to paginated Markdown.""" + logger.info(f"KvkkApiClient: Getting decision document from: {decision_url}, page: {page_number}") + + try: + # Fetch the decision page + response = await self.http_client.get(decision_url) + response.raise_for_status() + + # Extract content from HTML + extracted_data = self._extract_decision_content_from_html(response.text, decision_url) + + # Convert HTML content to Markdown + full_markdown_content = None + if extracted_data["html_content"]: + full_markdown_content = self._convert_html_to_markdown(extracted_data["html_content"]) + + if not full_markdown_content: + return KvkkDocumentMarkdown( + source_url=HttpUrl(decision_url), + title=extracted_data["title"], + decision_date=extracted_data["decision_date"], + decision_number=extracted_data["decision_number"], + subject_summary=extracted_data["subject_summary"], + markdown_chunk=None, + current_page=page_number, + total_pages=0, + is_paginated=False, + error_message="Could not convert document content to Markdown" + ) + + # Calculate pagination + content_length = len(full_markdown_content) + total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE) + if total_pages == 0: + total_pages = 1 + + # Clamp page number to valid range + current_page_clamped = max(1, min(page_number, total_pages)) + + # Extract the requested chunk + start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE + end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE + markdown_chunk = full_markdown_content[start_index:end_index] + + return KvkkDocumentMarkdown( + source_url=HttpUrl(decision_url), + title=extracted_data["title"], + decision_date=extracted_data["decision_date"], + decision_number=extracted_data["decision_number"], + subject_summary=extracted_data["subject_summary"], + markdown_chunk=markdown_chunk, + current_page=current_page_clamped, + total_pages=total_pages, + is_paginated=(total_pages > 1), + error_message=None + ) + + except httpx.HTTPStatusError as e: + error_msg = f"HTTP error {e.response.status_code} when fetching decision document" + logger.error(f"KvkkApiClient: {error_msg}") + return KvkkDocumentMarkdown( + source_url=HttpUrl(decision_url), + title=None, + decision_date=None, + decision_number=None, + subject_summary=None, + markdown_chunk=None, + current_page=page_number, + total_pages=0, + is_paginated=False, + error_message=error_msg + ) + except Exception as e: + error_msg = f"Unexpected error when fetching decision document: {str(e)}" + logger.error(f"KvkkApiClient: {error_msg}") + return KvkkDocumentMarkdown( + source_url=HttpUrl(decision_url), + title=None, + decision_date=None, + decision_number=None, + subject_summary=None, + markdown_chunk=None, + current_page=page_number, + total_pages=0, + is_paginated=False, + error_message=error_msg + ) + + async def close_client_session(self): + """Close the HTTP client session.""" + if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed: + await self.http_client.aclose() + logger.info("KvkkApiClient: HTTP client session closed.") \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/models.py b/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/models.py new file mode 100644 index 0000000..7db1c3c --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/kvkk_mcp_module/models.py @@ -0,0 +1,49 @@ +# kvkk_mcp_module/models.py + +from pydantic import BaseModel, Field, HttpUrl +from typing import List, Optional, Any + +class KvkkSearchRequest(BaseModel): + """Model for KVKK (Personal Data Protection Authority) search request via Brave API.""" + keywords: str = Field(..., description=""" + Keywords to search for in KVKK decisions. + The search will automatically include 'site:kvkk.gov.tr "karar özeti"' to target KVKK decision summaries. + Examples: "açık rıza", "veri güvenliği", "kişisel veri işleme" + """) + page: int = Field(1, ge=1, le=50, description="Page number for search results (1-50).") + pageSize: int = Field(10, ge=1, le=10, description="Number of results per page (1-10).") + +class KvkkDecisionSummary(BaseModel): + """Model for a single KVKK decision summary from Brave search results.""" + title: Optional[str] = Field(None, description="Decision title from search results.") + url: Optional[HttpUrl] = Field(None, description="URL to the KVKK decision page.") + description: Optional[str] = Field(None, description="Brief description or snippet from search results.") + decision_id: Optional[str] = Field(None, description="Value") + publication_date: Optional[str] = Field(None, description="Value") + decision_number: Optional[str] = Field(None, description="Value") + +class KvkkSearchResult(BaseModel): + """Model for the overall search result for KVKK decisions.""" + decisions: List[KvkkDecisionSummary] = Field(default_factory=list, description="List of KVKK decisions found.") + total_results: Optional[int] = Field(None, description="Value") + page: int = Field(1, description="Current page number of results.") + pageSize: int = Field(10, description="Number of results per page.") + query: Optional[str] = Field(None, description="The actual search query sent to Brave API.") + +class KvkkDocumentMarkdown(BaseModel): + """Model for KVKK decision document content converted to paginated Markdown.""" + source_url: HttpUrl = Field(description="URL of the original KVKK decision page.") + title: Optional[str] = Field(None, description="Title of the KVKK decision.") + decision_date: Optional[str] = Field(None, description="Decision date (Karar Tarihi).") + decision_number: Optional[str] = Field(None, description="Decision number (Karar No).") + subject_summary: Optional[str] = Field(None, description="Subject summary (Konu Özeti).") + markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content.") + current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).") + total_pages: int = Field(description="Total number of pages for the full markdown content.") + is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.") + error_message: Optional[str] = Field(None, description="Value") + + class Config: + json_encoders = { + HttpUrl: str + } \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/mcp_auth/__init__.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/__init__.py new file mode 100644 index 0000000..0ed22c9 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/__init__.py @@ -0,0 +1,28 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth/clerk_config.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/clerk_config.py new file mode 100644 index 0000000..2c3e205 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/clerk_config.py @@ -0,0 +1,73 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth/middleware.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/middleware.py new file mode 100644 index 0000000..0dc6301 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/middleware.py @@ -0,0 +1,315 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth/oauth.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/oauth.py new file mode 100644 index 0000000..efc3adc --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/oauth.py @@ -0,0 +1,304 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth/policy.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/policy.py new file mode 100644 index 0000000..e947af3 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/policy.py @@ -0,0 +1,201 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth/storage.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/storage.py new file mode 100644 index 0000000..ea1ad8f --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth/storage.py @@ -0,0 +1,112 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth_factory.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth_factory.py new file mode 100644 index 0000000..a5760f6 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth_factory.py @@ -0,0 +1,193 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_adapter.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_adapter.py new file mode 100644 index 0000000..442b540 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_adapter.py @@ -0,0 +1,383 @@ +""" +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/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_simple.py b/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_simple.py new file mode 100644 index 0000000..4311ef9 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_simple.py @@ -0,0 +1,522 @@ +""" +Simplified MCP OAuth HTTP adapter - only Clerk JWT based authentication +Uses Redis for authorization code storage to support multi-machine deployment +""" + +import os +import logging +from typing import Optional +from urllib.parse import urlencode, quote + +from fastapi import APIRouter, Request, Query, HTTPException +from fastapi.responses import RedirectResponse, JSONResponse + +# Import Redis session store +from redis_session_store import get_redis_store + +# 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__) + +router = APIRouter() + +# OAuth configuration +BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com") +CLERK_DOMAIN = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com") + +# Initialize Redis store +redis_store = None + +def get_redis_session_store(): + """Get Redis store instance with lazy initialization.""" + global redis_store + if redis_store is None: + try: + import concurrent.futures + import functools + + # Use thread pool with timeout to prevent hanging + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(get_redis_store) + try: + # 5 second timeout for Redis initialization + redis_store = future.result(timeout=5.0) + if redis_store: + logger.info("Redis session store initialized for OAuth handler") + else: + logger.warning("Redis store initialization returned None") + except concurrent.futures.TimeoutError: + logger.error("Redis initialization timed out after 5 seconds") + redis_store = None + future.cancel() # Try to cancel the hanging operation + + except Exception as e: + logger.error(f"Failed to initialize Redis store: {e}") + redis_store = None + + if redis_store is None: + # Fall back to in-memory storage with warning + logger.warning("Falling back to in-memory storage - multi-machine deployment will not work") + + return redis_store + +@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": "https://yargimcp.com/mcp-callback", + "token_endpoint": f"{BASE_URL}/token", + "registration_endpoint": f"{BASE_URL}/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["read", "search", "openid", "profile", "email"], + "service_documentation": f"{BASE_URL}/mcp/" + }) + +@router.get("/auth/login") +async def oauth_authorize( + request: Request, + client_id: str = Query(...), + redirect_uri: str = Query(...), + response_type: str = Query("code"), + scope: Optional[str] = Query("read search"), + state: Optional[str] = Query(None), + code_challenge: Optional[str] = Query(None), + code_challenge_method: Optional[str] = Query(None) +): + """OAuth 2.1 Authorization Endpoint - redirects to Clerk""" + + logger.info(f"OAuth authorize request - client_id: {client_id}") + logger.info(f"Redirect URI: {redirect_uri}") + logger.info(f"State: {state}") + logger.info(f"PKCE Challenge: {bool(code_challenge)}") + + try: + # Build callback URL with all necessary parameters + callback_url = f"{BASE_URL}/auth/callback" + callback_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state or "", + "scope": scope or "read search" + } + + # Add PKCE parameters if present + if code_challenge: + callback_params["code_challenge"] = code_challenge + callback_params["code_challenge_method"] = code_challenge_method or "S256" + + # Encode callback URL as redirect_url for Clerk + callback_with_params = f"{callback_url}?{urlencode(callback_params)}" + + # Build Clerk sign-in URL - use yargimcp.com frontend for JWT token generation + clerk_params = { + "redirect_url": callback_with_params + } + + # Use frontend sign-in page that handles JWT token generation + clerk_signin_url = f"https://yargimcp.com/sign-in?{urlencode(clerk_params)}" + + logger.info(f"Redirecting to Clerk: {clerk_signin_url}") + + return RedirectResponse(url=clerk_signin_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, + client_id: str = Query(...), + redirect_uri: str = Query(...), + state: Optional[str] = Query(None), + scope: Optional[str] = Query("read search"), + code_challenge: Optional[str] = Query(None), + code_challenge_method: Optional[str] = Query(None), + clerk_token: Optional[str] = Query(None) +): + """OAuth callback from Clerk - generates authorization code""" + + logger.info(f"OAuth callback - client_id: {client_id}") + logger.info(f"Clerk token provided: {bool(clerk_token)}") + + try: + # Validate user with Clerk and generate real JWT token + user_authenticated = False + user_id = None + session_id = None + real_jwt_token = None + + if clerk_token and CLERK_AVAILABLE: + try: + # Extract user info from JWT token (no Clerk session verification needed) + import jwt + decoded_token = jwt.decode(clerk_token, options={"verify_signature": False}) + user_id = decoded_token.get("user_id") or decoded_token.get("sub") + user_email = decoded_token.get("email") + token_scopes = decoded_token.get("scopes", ["read", "search"]) + + logger.info(f"JWT token claims - user_id: {user_id}, email: {user_email}, scopes: {token_scopes}") + + if user_id and user_email: + # JWT token is already signed by Clerk and contains valid user info + user_authenticated = True + logger.info(f"User authenticated via JWT token - user_id: {user_id}") + + # Use the JWT token directly as the real token (it's already from Clerk template) + real_jwt_token = clerk_token + logger.info("Using Clerk JWT token directly (already real token)") + + else: + logger.error(f"Missing required fields in JWT token - user_id: {bool(user_id)}, email: {bool(user_email)}") + + except Exception as e: + logger.error(f"JWT validation failed: {e}") + + # Fallback to cookie validation + if not user_authenticated: + clerk_session = request.cookies.get("__session") + if clerk_session: + user_authenticated = True + logger.info("User authenticated via cookie") + + # Try to get session from cookie and generate JWT + if CLERK_AVAILABLE: + try: + clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY")) + # Note: sessions.verify_session is deprecated, but we'll try + # In practice, you'd need to extract session_id from cookie + logger.info("Cookie authentication - JWT generation not implemented yet") + except Exception as e: + logger.warning(f"Failed to generate JWT from cookie: {e}") + + # Only generate authorization code if we have a real JWT token + if user_authenticated and real_jwt_token: + # Generate authorization code + auth_code = f"clerk_auth_{os.urandom(16).hex()}" + + # Prepare code data + import time + code_data = { + "user_id": user_id, + "session_id": session_id, + "real_jwt_token": real_jwt_token, + "user_authenticated": user_authenticated, + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope or "read search" + } + + # Try to store in Redis, fall back to in-memory if Redis unavailable + store = get_redis_session_store() + if store: + # Store in Redis with automatic expiration + success = store.set_oauth_code(auth_code, code_data) + if success: + logger.info(f"Stored authorization code {auth_code[:10]}... in Redis with real JWT token") + else: + logger.error(f"Failed to store authorization code in Redis, falling back to in-memory") + # Fall back to in-memory storage + if not hasattr(oauth_callback, '_code_storage'): + oauth_callback._code_storage = {} + oauth_callback._code_storage[auth_code] = code_data + else: + # Fall back to in-memory storage + logger.warning("Redis not available, using in-memory storage") + if not hasattr(oauth_callback, '_code_storage'): + oauth_callback._code_storage = {} + oauth_callback._code_storage[auth_code] = code_data + logger.info(f"Stored authorization code in memory (fallback)") + + # Redirect back to client with authorization code + redirect_params = { + "code": auth_code, + "state": state or "" + } + + final_redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}" + logger.info(f"Redirecting back to client: {final_redirect_url}") + + return RedirectResponse(url=final_redirect_url) + else: + # No JWT token yet - redirect back to sign-in page to wait for authentication + logger.info("No JWT token provided - redirecting back to sign-in to complete authentication") + + # Keep the same redirect URL so the flow continues + sign_in_params = { + "redirect_url": f"{request.url._url}" # Current callback URL with all params + } + + sign_in_url = f"https://yargimcp.com/sign-in?{urlencode(sign_in_params)}" + logger.info(f"Redirecting back to sign-in: {sign_in_url}") + + return RedirectResponse(url=sign_in_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("/auth/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"], + "response_types": ["code"], + "client_name": data.get("client_name", "MCP Client"), + "token_endpoint_auth_method": "none" + }) + +@router.post("/auth/callback") +async def oauth_callback_post(request: Request): + """OAuth callback POST endpoint for token exchange""" + + # Parse form data (standard OAuth token exchange format) + 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"OAuth callback POST - grant_type: {grant_type}") + logger.info(f"Code: {code[:20] if code else 'None'}...") + logger.info(f"Client ID: {client_id}") + logger.info(f"PKCE verifier: {bool(code_verifier)}") + + if grant_type != "authorization_code": + return JSONResponse( + status_code=400, + content={"error": "unsupported_grant_type"} + ) + + if not code or not redirect_uri: + return JSONResponse( + status_code=400, + content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"} + ) + + try: + # Validate authorization code + if not code.startswith("clerk_auth_"): + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid authorization code"} + ) + + # Retrieve stored JWT token using authorization code from Redis or in-memory fallback + stored_code_data = None + + # Try to get from Redis first, then fall back to in-memory + store = get_redis_session_store() + if store: + stored_code_data = store.get_oauth_code(code, delete_after_use=True) + if stored_code_data: + logger.info(f"Retrieved authorization code {code[:10]}... from Redis") + else: + logger.warning(f"Authorization code {code[:10]}... not found in Redis") + + # Fall back to in-memory storage if Redis unavailable or code not found + if not stored_code_data and hasattr(oauth_callback, '_code_storage'): + stored_code_data = oauth_callback._code_storage.get(code) + if stored_code_data: + # Clean up in-memory storage + oauth_callback._code_storage.pop(code, None) + logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage") + + if not stored_code_data: + logger.error(f"No stored data found for authorization code: {code}") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"} + ) + + # Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback + import time + expires_at = stored_code_data.get("expires_at", 0) + if expires_at and time.time() > expires_at: + logger.error(f"Authorization code expired: {code}") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Authorization code expired"} + ) + + # Get the real JWT token + real_jwt_token = stored_code_data.get("real_jwt_token") + + if real_jwt_token: + logger.info("Returning real Clerk JWT token") + # Note: Code already deleted from Redis, clean up in-memory fallback if used + if hasattr(oauth_callback, '_code_storage'): + oauth_callback._code_storage.pop(code, None) + + return JSONResponse({ + "access_token": real_jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read search" + }) + else: + logger.warning("No real JWT token found, generating mock token") + # Fallback to mock token for testing + mock_token = f"mock_clerk_jwt_{code}" + return JSONResponse({ + "access_token": mock_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read search" + }) + + except Exception as e: + logger.exception(f"OAuth callback POST 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"], + "response_types": ["code"], + "client_name": data.get("client_name", "MCP Client"), + "token_endpoint_auth_method": "none" + }) + +@router.post("/token") +async def token_endpoint(request: Request): + """OAuth 2.1 Token Endpoint - exchanges code for Clerk JWT""" + + # 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}") + logger.info(f"Code: {code[:20] if code else 'None'}...") + + if grant_type != "authorization_code": + return JSONResponse( + status_code=400, + content={"error": "unsupported_grant_type"} + ) + + if not code or not redirect_uri: + return JSONResponse( + status_code=400, + content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"} + ) + + try: + # Validate authorization code + if not code.startswith("clerk_auth_"): + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Invalid authorization code"} + ) + + # Retrieve stored JWT token using authorization code from Redis or in-memory fallback + stored_code_data = None + + # Try to get from Redis first, then fall back to in-memory + store = get_redis_session_store() + if store: + stored_code_data = store.get_oauth_code(code, delete_after_use=True) + if stored_code_data: + logger.info(f"Retrieved authorization code {code[:10]}... from Redis (/token endpoint)") + else: + logger.warning(f"Authorization code {code[:10]}... not found in Redis (/token endpoint)") + + # Fall back to in-memory storage if Redis unavailable or code not found + if not stored_code_data and hasattr(oauth_callback, '_code_storage'): + stored_code_data = oauth_callback._code_storage.get(code) + if stored_code_data: + # Clean up in-memory storage + oauth_callback._code_storage.pop(code, None) + logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage (/token endpoint)") + + if not stored_code_data: + logger.error(f"No stored data found for authorization code: {code}") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"} + ) + + # Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback + import time + expires_at = stored_code_data.get("expires_at", 0) + if expires_at and time.time() > expires_at: + logger.error(f"Authorization code expired: {code}") + return JSONResponse( + status_code=400, + content={"error": "invalid_grant", "error_description": "Authorization code expired"} + ) + + # Get the real JWT token + real_jwt_token = stored_code_data.get("real_jwt_token") + + if real_jwt_token: + logger.info("Returning real Clerk JWT token from /token endpoint") + # Note: Code already deleted from Redis, clean up in-memory fallback if used + if hasattr(oauth_callback, '_code_storage'): + oauth_callback._code_storage.pop(code, None) + + return JSONResponse({ + "access_token": real_jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read search" + }) + else: + logger.warning("No real JWT token found in /token endpoint, generating mock token") + # Fallback to mock token for testing + mock_token = f"mock_clerk_jwt_{code}" + return JSONResponse({ + "access_token": mock_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read search" + }) + + 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/saidsurucu-yargi-mcp-f5fa007/mcp_server_main.py b/saidsurucu-yargi-mcp-f5fa007/mcp_server_main.py new file mode 100644 index 0000000..7c3e512 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/mcp_server_main.py @@ -0,0 +1,2883 @@ +# mcp_server_main.py +import asyncio +import atexit +import logging +import os +import httpx +import json +import time +from collections import defaultdict +from pydantic import HttpUrl, Field +from typing import Optional, Dict, List, Literal, Any, Union +import urllib.parse +import tiktoken +from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.dependencies import get_access_token, AccessToken +from fastmcp import Context + +# Use standard exception for tool errors +class ToolError(Exception): + """Tool execution error""" + pass + +# --- Logging Configuration Start --- +LOG_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") +if not os.path.exists(LOG_DIRECTORY): + os.makedirs(LOG_DIRECTORY) +LOG_FILE_PATH = os.path.join(LOG_DIRECTORY, "mcp_server.log") + +root_logger = logging.getLogger() +root_logger.setLevel(logging.DEBUG) + +log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(threadName)s - %(message)s') + +file_handler = logging.FileHandler(LOG_FILE_PATH, mode='a', encoding='utf-8') +file_handler.setFormatter(log_formatter) +file_handler.setLevel(logging.DEBUG) +root_logger.addHandler(file_handler) + +console_handler = logging.StreamHandler() +console_handler.setFormatter(log_formatter) +console_handler.setLevel(logging.INFO) +root_logger.addHandler(console_handler) + +logger = logging.getLogger(__name__) +# --- Logging Configuration End --- + +# --- Token Counting Middleware --- +class TokenCountingMiddleware(Middleware): + """Middleware for counting input/output tokens using tiktoken.""" + + def __init__(self, model: str = "cl100k_base"): + """Initialize token counting middleware. + + Args: + model: Tiktoken model name (cl100k_base for GPT-4/Claude compatibility) + """ + self.encoder = tiktoken.get_encoding(model) + self.model = model + self.token_stats = defaultdict(lambda: {"input": 0, "output": 0, "calls": 0}) + self.logger = logging.getLogger("token_counter") + + # Create separate log file for token metrics + token_log_path = os.path.join(LOG_DIRECTORY, "token_metrics.log") + token_handler = logging.FileHandler(token_log_path, mode='a', encoding='utf-8') + token_formatter = logging.Formatter('%(asctime)s - %(message)s') + token_handler.setFormatter(token_formatter) + token_handler.setLevel(logging.INFO) + self.logger.addHandler(token_handler) + self.logger.setLevel(logging.INFO) + + def count_tokens(self, text: str) -> int: + """Count tokens in text using tiktoken.""" + if not text: + return 0 + try: + return len(self.encoder.encode(str(text))) + except Exception as e: + logger.warning(f"Token counting failed: {e}") + return 0 + + def extract_text_content(self, data: Any) -> str: + """Extract text content from various data types.""" + if isinstance(data, str): + return data + elif isinstance(data, dict): + # Extract text from common response fields + text_parts = [] + for key, value in data.items(): + if isinstance(value, str): + text_parts.append(value) + elif isinstance(value, list): + for item in value: + if isinstance(item, str): + text_parts.append(item) + elif isinstance(item, dict) and 'text' in item: + text_parts.append(str(item['text'])) + return ' '.join(text_parts) + elif isinstance(data, list): + text_parts = [] + for item in data: + text_parts.append(self.extract_text_content(item)) + return ' '.join(text_parts) + else: + return str(data) + + def log_token_usage(self, operation: str, input_tokens: int, output_tokens: int, + tool_name: str = None, duration_ms: float = None): + """Log token usage with structured format.""" + log_data = { + "operation": operation, + "tool_name": tool_name, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + "duration_ms": duration_ms, + "timestamp": time.time() + } + + # Update statistics + key = tool_name if tool_name else operation + self.token_stats[key]["input"] += input_tokens + self.token_stats[key]["output"] += output_tokens + self.token_stats[key]["calls"] += 1 + + # Log as JSON for easy parsing + self.logger.info(json.dumps(log_data)) + + # Also log human-readable format to main logger + logger.info(f"Token Usage - {operation}" + + (f" ({tool_name})" if tool_name else "") + + f": {input_tokens} in + {output_tokens} out = {input_tokens + output_tokens} total") + + async def on_call_tool(self, context: MiddlewareContext, call_next): + """Count tokens for tool calls.""" + start_time = time.perf_counter() + + # Extract tool name and arguments + tool_name = getattr(context.message, 'name', 'unknown_tool') + tool_args = getattr(context.message, 'arguments', {}) + + # Count input tokens (tool arguments) + input_text = self.extract_text_content(tool_args) + input_tokens = self.count_tokens(input_text) + + try: + # Execute the tool + result = await call_next(context) + + # Count output tokens (tool result) + output_text = self.extract_text_content(result) + output_tokens = self.count_tokens(output_text) + + # Calculate duration + duration_ms = (time.perf_counter() - start_time) * 1000 + + # Log token usage + self.log_token_usage("tool_call", input_tokens, output_tokens, + tool_name, duration_ms) + + return result + + except Exception as e: + duration_ms = (time.perf_counter() - start_time) * 1000 + self.log_token_usage("tool_call_error", input_tokens, 0, + tool_name, duration_ms) + raise + + async def on_read_resource(self, context: MiddlewareContext, call_next): + """Count tokens for resource reads.""" + start_time = time.perf_counter() + + # Extract resource URI + resource_uri = getattr(context.message, 'uri', 'unknown_resource') + + try: + # Execute the resource read + result = await call_next(context) + + # Count output tokens (resource content) + output_text = self.extract_text_content(result) + output_tokens = self.count_tokens(output_text) + + # Calculate duration + duration_ms = (time.perf_counter() - start_time) * 1000 + + # Log token usage (no input tokens for resource reads) + self.log_token_usage("resource_read", 0, output_tokens, + resource_uri, duration_ms) + + return result + + except Exception as e: + duration_ms = (time.perf_counter() - start_time) * 1000 + self.log_token_usage("resource_read_error", 0, 0, + resource_uri, duration_ms) + raise + + async def on_get_prompt(self, context: MiddlewareContext, call_next): + """Count tokens for prompt retrievals.""" + start_time = time.perf_counter() + + # Extract prompt name + prompt_name = getattr(context.message, 'name', 'unknown_prompt') + + try: + # Execute the prompt retrieval + result = await call_next(context) + + # Count output tokens (prompt content) + output_text = self.extract_text_content(result) + output_tokens = self.count_tokens(output_text) + + # Calculate duration + duration_ms = (time.perf_counter() - start_time) * 1000 + + # Log token usage + self.log_token_usage("prompt_get", 0, output_tokens, + prompt_name, duration_ms) + + return result + + except Exception as e: + duration_ms = (time.perf_counter() - start_time) * 1000 + self.log_token_usage("prompt_get_error", 0, 0, + prompt_name, duration_ms) + raise + + def get_token_stats(self) -> Dict[str, Any]: + """Get current token usage statistics.""" + return dict(self.token_stats) + + def reset_token_stats(self): + """Reset token usage statistics.""" + self.token_stats.clear() + +# --- End Token Counting 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.""" + global app + if auth: + # Set auth on existing app instead of creating new one + app.auth = auth + app.name = "Yargı MCP Server" + logger.info("MCP server created with Bearer authentication enabled") + else: + # Update placeholder app name only + app.name = "Yargı MCP Server" + logger.info("MCP server created with standard capabilities (FastMCP handles tools.listChanged automatically)") + + # Add token counting middleware + token_counter = TokenCountingMiddleware() + app.add_middleware(token_counter) + logger.info("Token counting middleware added to MCP server") + + return app + +# --- Module Imports --- +from yargitay_mcp_module.client import YargitayOfficialApiClient +from yargitay_mcp_module.models import ( + YargitayDetailedSearchRequest, YargitayDocumentMarkdown, CompactYargitaySearchResult, + YargitayBirimEnum, CleanYargitayDecisionEntry +) +from bedesten_mcp_module.client import BedestenApiClient +from bedesten_mcp_module.models import ( + BedestenSearchRequest, BedestenSearchData, + BedestenDocumentMarkdown, BedestenCourtTypeEnum +) +from bedesten_mcp_module.enums import BirimAdiEnum +from danistay_mcp_module.client import DanistayApiClient +from danistay_mcp_module.models import ( + DanistayKeywordSearchRequest, DanistayDetailedSearchRequest, + DanistayDocumentMarkdown, CompactDanistaySearchResult +) +from emsal_mcp_module.client import EmsalApiClient +from emsal_mcp_module.models import ( + EmsalSearchRequest, EmsalDocumentMarkdown, CompactEmsalSearchResult +) +from uyusmazlik_mcp_module.client import UyusmazlikApiClient +from uyusmazlik_mcp_module.models import ( + UyusmazlikSearchRequest, UyusmazlikSearchResponse, UyusmazlikDocumentMarkdown, + UyusmazlikBolumEnum, UyusmazlikTuruEnum, UyusmazlikKararSonucuEnum +) +from anayasa_mcp_module.client import AnayasaMahkemesiApiClient +from anayasa_mcp_module.bireysel_client import AnayasaBireyselBasvuruApiClient +from anayasa_mcp_module.unified_client import AnayasaUnifiedClient +from anayasa_mcp_module.models import ( + AnayasaNormDenetimiSearchRequest, + AnayasaSearchResult, + AnayasaDocumentMarkdown, + AnayasaBireyselReportSearchRequest, + AnayasaBireyselReportSearchResult, + AnayasaBireyselBasvuruDocumentMarkdown, + AnayasaUnifiedSearchRequest, + AnayasaUnifiedSearchResult, + AnayasaUnifiedDocumentMarkdown, + # Removed enum imports - now using Literal strings in models +) +# KIK Module Imports +from kik_mcp_module.client import KikApiClient +from kik_mcp_module.models import ( + KikKararTipi, + KikSearchRequest, + KikSearchResult, + KikDocumentMarkdown +) + +from rekabet_mcp_module.client import RekabetKurumuApiClient +from rekabet_mcp_module.models import ( + RekabetKurumuSearchRequest, + RekabetSearchResult, + RekabetDocument, + RekabetKararTuruGuidEnum +) + +from sayistay_mcp_module.client import SayistayApiClient +from sayistay_mcp_module.models import ( + GenelKurulSearchRequest, GenelKurulSearchResponse, + TemyizKuruluSearchRequest, TemyizKuruluSearchResponse, + DaireSearchRequest, DaireSearchResponse, + SayistayDocumentMarkdown, + SayistayUnifiedSearchRequest, SayistayUnifiedSearchResult, + SayistayUnifiedDocumentMarkdown +) +from sayistay_mcp_module.enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum +from sayistay_mcp_module.unified_client import SayistayUnifiedClient + +# KVKK Module Imports +from kvkk_mcp_module.client import KvkkApiClient +from kvkk_mcp_module.models import ( + KvkkSearchRequest, + KvkkSearchResult, + KvkkDocumentMarkdown +) + +# BDDK Module Imports +from bddk_mcp_module.client import BddkApiClient +from bddk_mcp_module.models import ( + BddkSearchRequest, + BddkSearchResult, + BddkDocumentMarkdown +) + + +# Create a placeholder app that will be properly initialized after tools are defined +from fastmcp import FastMCP + +# Placeholder app for decorators - will be replaced in create_app() after all tools are defined +app = FastMCP("Yargı MCP Server Placeholder") + +# --- Tool Documentation Resources --- +@app.resource("docs://tools/yargitay") +async def get_yargitay_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Yargıtay (Court of Cassation) Tools Documentation + +## Court Hierarchy and Position +Yargıtay is Turkey's highest civil and criminal court. It serves as the final appellate authority and establishes legal precedents for civil and criminal cases. + +**Dual API System:** +- **Primary API (search_yargitay_detailed)**: Official karararama.yargitay.gov.tr +- **Bedesten API (search_bedesten_unified)**: Unified access to bedesten.adalet.gov.tr (see docs://tools/bedesten_unified) + +## Chamber Filtering Options (52 Total) + +### Civil Chambers (Hukuk Daireleri) +- **Civil General Assembly** (Hukuk Genel Kurulu) +- **1st Civil Chamber** through **23rd Civil Chamber** (23 civil chambers) +- **Civil Chambers Presidents Board** (Hukuk Daireleri Başkanlar Kurulu) + +### Criminal Chambers (Ceza Daireleri) +- **Criminal General Assembly** (Ceza Genel Kurulu) +- **1st Criminal Chamber** through **23rd Criminal Chamber** (23 criminal chambers) +- **Criminal Chambers Presidents Board** (Ceza Daireleri Başkanlar Kurulu) + +### General Assemblies +- **Grand General Assembly** (Büyük Genel Kurulu) + +## Search Techniques + +### Primary API (search_yargitay_detailed) +``` +Simple search: "mülkiyet" +AND operator: "mülkiyet AND tapu" +OR operator: "mülkiyet OR tapu" +NOT operator: "mülkiyet NOT satış" +Wildcard: "mülk*" +Exact phrase: "\"mülkiyet hakkı\"" +``` + +### Bedesten API (search_bedesten_unified) +For detailed usage, see docs://tools/bedesten_unified +``` +Regular search: phrase="mülkiyet kararı", court_types=["YARGITAYKARARI"] +Exact phrase: phrase="\"mülkiyet kararı\"", court_types=["YARGITAYKARARI"] +Date filtering: kararTarihiStart="2024-01-01T00:00:00.000Z" +``` + +## Usage Scenarios +- **Precedent research**: Supreme court decisions on specific topics +- **Chamber-specific search**: Relevant chambers for specific legal areas +- **Historical analysis**: Decision trends in specific periods +- **Jurisprudence tracking**: Changes in legal opinions + +## Best Practices +1. **Use dual APIs**: Try both APIs for maximum coverage +2. **Chamber filtering**: Select chambers based on relevant legal area +3. **Exact phrases**: Use "\"term\"" for precise terms in Bedesten API +4. **Date range**: Focus on last 2-3 years for recent developments + +## Common Civil Chambers +- **1st Civil**: Property, land registry, liens +- **4th Civil**: Labor law, collective agreements +- **11th Civil**: Insurance, social security +- **15th Civil**: Compensation, tort +- **21st Civil**: Execution and bankruptcy + +## Common Criminal Chambers +- **1st Criminal**: General criminal offenses +- **8th Criminal**: Economic and commercial crimes +- **12th Criminal**: Official misconduct +""" + +@app.resource("docs://tools/danistay") +async def get_danistay_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Danıştay (Council of State) Tools Documentation + +## Court Hierarchy and Position +Danıştay is Turkey's highest administrative court. It makes final decisions on administrative acts and actions. + +**Triple API System:** +- **Keyword API (search_danistay_by_keyword)**: AND/OR/NOT logic +- **Detailed API (search_danistay_detailed)**: Comprehensive criteria +- **Bedesten API (search_bedesten_unified)**: Unified access (see docs://tools/bedesten_unified) + +## Chamber Filtering Options (27 Total) + +### Main Councils +- **Grand General Assembly** (Büyük Gen.Kur.) +- **Administrative Cases Council** (İdare Dava Daireleri Kurulu) +- **Tax Cases Council** (Vergi Dava Daireleri Kurulu) +- **Precedents Unification Council** (İçtihatları Birleştirme Kurulu) + +### Chambers (1-17) +- **1st Chamber** through **17th Chamber** (Administrative case chambers) + +### Military Courts +- **Military High Administrative Court** (Askeri Yüksek İdare Mahkemesi) +- **Military High Administrative Court 1st-3rd Chambers** + +## Search Techniques + +### Keyword API +``` +AND logic: andKelimeler=["imar", "plan"] +OR logic: orKelimeler=["iptal", "yürütmeyi durdurma"] +NOT logic: notKelimeler=["ceza"] +``` + +### Detailed API +``` +Chamber selection: daire="3. Daire" +Case year: esasYil="2024" +Decision date: kararTarihiBaslangic="01.01.2024" +Legislation: mevzuatId=123 +``` + +### Bedesten API +``` +Regular: phrase="idari işlem" +Exact: phrase="\"idari işlem\"" +Date: kararTarihiStart="2024-01-01T00:00:00.000Z" +``` + +## Usage Scenarios +- **Administrative law research**: Public administration decisions +- **Tax law**: Financial matters and tax disputes +- **Urban planning law**: City planning and building permits +- **Personnel law**: Civil servant rights + +## Common Chamber Specializations +- **1st Chamber**: Municipal, urban planning, environment +- **2nd Chamber**: Tax, customs, financial +- **3rd Chamber**: Personnel, personal rights +- **5th Chamber**: Administrative fines +- **8th Chamber**: Higher education, education +- **10th Chamber**: Health, social security + +## Best Practices +1. **Triple API**: Use all three APIs for maximum coverage +2. **Chamber selection**: Choose specialized chambers by subject area +3. **Mevzuat bağlantısı**: İlgili kanun/tüzükle filtreleme +4. **Kesin terim**: İdari hukuk terminolojisi için exact search +""" + +@app.resource("docs://tools/constitutional_court") +async def get_constitutional_court_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Anayasa Mahkemesi (Constitutional Court) Tools Documentation + +## Court Position +Constitutional Court is Turkey's highest judicial body. It has two main functions: + +### 1. Norm Control (Norm Control) +**Tool**: search_anayasa_norm_denetimi_decisions +- Reviews constitutional compliance of laws and regulations +- Abstract and concrete norm control + +### 2. Individual Application (Individual Application) +**Tool**: search_anayasa_bireysel_basvuru_report +- Citizens' fundamental rights violation applications +- Turkey's human rights protection mechanism + +## Norm Control Features + +### Comprehensive Filtering +- **Application type**: Annulment, Objection, Other +- **Applicant**: President, Parliament, Courts +- **Legislation type**: Law, Decree, Regulation, Rules of procedure +- **Result type**: Annulment, Rejection, Partial annulment + +### Advanced Search +- **Member names**: Full names of participating justices +- **Rapporteur**: Case rapporteur +- **Dissenting opinion**: Minority opinion, different view +- **Press release**: Important decisions + +## Bireysel Başvuru Özellikleri + +### Temel Haklar Kategorileri +- **Yaşam hakkı**: Ölüm olayları, güvenlik +- **Adil yargılanma**: Süre, tarafsızlık, duruşma hakkı +- **İfade özgürlüğü**: Basın, düşünce, akademik özgürlük +- **Din özgürlüğü**: İbadet, vicdan özgürlüğü +- **Mülkiyet hakkı**: Kamulaştırma, tapu +- **Özel hayat**: Gizlilik, aile hayatı + +### Başvuru Süreci +- **Yurtiçi yollar**: Önce mahkeme kararı gerekli +- **Süre sınırı**: 30 gün (60 gün istisnai) +- **Kabul edilebilirlik**: Ön inceleme kriterleri + +## Paginated Content (5,000 characters) +Her iki tool da sayfalanmış Markdown döndürür: +- **page_number**: Sayfa numarası (1'den başlar) +- **total_pages**: Toplam sayfa sayısı +- **current_page**: Mevcut sayfa + +## Usage Scenarios + +### Norm Denetimi +- **Kanun anayasaya uygunluk**: Yeni çıkan kanunların kontrolü +- **Mahkeme iptali**: Kanunun belirli maddeleri +- **Mevzuat uyum**: Anayasa değişikliği sonrası + +### Bireysel Başvuru +- **İnsan hakları araştırması**: AİHM öncesi iç hukuk +- **Temel hak ihlalleri**: Sistematik ihlal tespiti +- **Emsal karar**: Benzer davalar için içtihat + +## Parameter Details +### search_anayasa_norm_denetimi_decisions +- **keywords_all**: Keywords for AND logic (all must be present) +- **keywords_any**: Keywords for OR logic (any can be present) +- **keywords_exclude**: Keywords to exclude from results +- **period**: Constitutional period - "ALL", "1" (1961 Constitution), "2" (1982 Constitution) +- **case_number_esas**: Case registry number (e.g., '2023/123') +- **decision_number_karar**: Decision number (e.g., '2023/456') +- **first_review_date_start/end**: First review date range (DD/MM/YYYY) +- **decision_date_start/end**: Decision date range (DD/MM/YYYY) +- **application_type**: "ALL", "1" (İptal), "2" (İtiraz), "3" (Diğer) +- **applicant_general_name**: General applicant name +- **applicant_specific_name**: Specific applicant name +- **official_gazette_date_start/end**: Official Gazette date range +- **official_gazette_number_start/end**: Official Gazette number range +- **has_press_release**: "ALL", "0" (No), "1" (Yes) +- **has_dissenting_opinion**: "ALL", "0" (No), "1" (Yes) +- **has_different_reasoning**: "ALL", "0" (No), "1" (Yes) +- **attending_members_names**: List of attending members' exact names +- **rapporteur_name**: Rapporteur's exact name +- **norm_type**: Type of reviewed norm (law, decree, regulation, etc.) +- **norm_id_or_name**: Number or name of the norm +- **norm_article**: Article number of the norm +- **review_outcomes**: List of review outcomes +- **reason_for_final_outcome**: Main reason for decision outcome +- **basis_constitution_article_numbers**: Supporting Constitution article numbers +- **results_per_page**: Results per page (10, 20, 30, 40, 50) +- **page_to_fetch**: Page number to fetch +- **sort_by_criteria**: Sort criteria ('KararTarihi', 'YayinTarihi', 'Toplam') + +### search_anayasa_bireysel_basvuru_report +- **keywords**: Keywords for AND logic (all must be present) +- **page_to_fetch**: Page number for the report (default: 1) + +### Document Tools +- **document_url**: URL path or full URL of the decision +- **page_number**: Page number for paginated content (1-indexed, default: 1) + +## Best Practices +1. **Norm control önce**: Kanun iptal edilmiş mi kontrol +2. **Bireysel başvuru ikinci**: Kişisel hak ihlalleri için +3. **Tarih aralığı**: Anayasa değişiklikleri sonrası dönemler +4. **Anahtar kelime kombinasyonu**: Temel hak + konu alanı +5. **Sayfa yönetimi**: Uzun kararlarda sayfa sayfa okuyun +""" + +@app.resource("docs://tools/emsal") +async def get_emsal_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Emsal (UYAP Precedent System) Tools Documentation + +## System Position +Central precedent decision system providing access to all court decisions through the UYAP system. + +## Court Options +- **Yargıtay**: First and second instance courts +- **Danıştay**: Administrative court decisions +- **Other**: Regional courts of justice, civil courts + +## Advanced Filtering Features +- **Court type**: Civil, criminal, administrative +- **Case/Decision number**: File tracking system +- **Date range**: Flexible date selection +- **Content search**: Keyword search within decision text + +## Usage Scenarios +- **Kapsamlı emsal**: Tüm mahkeme seviyelerinden karar toplama +- **Güncel içtihat**: En son hukuki gelişmeler +- **Cross-reference**: Farklı mahkeme görüşlerini karşılaştırma + +## Best Practices +1. **Spesifik terimler**: Hukuki terminoloji kullanın +2. **Geniş arama**: Önce genel, sonra spesifik +3. **Tarih stratejisi**: Mevzuat değişiklikleri dikkate alın +4. **Cross-platform**: Aynı konuyu farklı mahkemelerde arayın +""" + +@app.resource("docs://tools/uyusmazlik") +async def get_uyusmazlik_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Uyuşmazlık Mahkemesi Tools Documentation + +## Court Position +Adli ve idari yargı arasındaki görev uyuşmazlıklarını çözen özel yetkili mahkeme. + +## Dispute Types +- **Görev uyuşmazlığı**: Hangi mahkeme bakacak konusunda anlaşmazlık +- **Hüküm uyuşmazlığı**: Çelişkili mahkeme kararları +- **Yetki uyuşmazlığı**: Yerel yetki sorunları + +## Form-Based Search Criteria +- **Karar türü**: Müspet, menfi, hüküm uyuşmazlığı +- **Taraf mahkemeler**: Adli-idari yargı organları +- **Konu alanı**: Hukuk dalı bazlı filtreleme +- **Tarih aralığı**: Karar tarihi seçimi + +## Usage Scenarios +- **Yargı türü belirleme**: Hangi mahkemenin yetkili olduğu +- **Çelişkili kararlar**: Farklı mahkeme kararları arasındaki uyuşmazlık +- **Yetki sorunları**: Mahkeme yetkisi tartışmaları + +## Best Practices +1. **Net kriterler**: Arama kriterlerini spesifik tutun +2. **Taraf bilgisi**: Uyuşmazlık taraflarını belirtin +3. **Konu odaklı**: İlgili hukuk dalını seçin +""" + +@app.resource("docs://tools/kik") +async def get_kik_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# KİK (Kamu İhale Kurumu) Tools Documentation + +## Kurum Konumu +Kamu ihale uyuşmazlıklarının ilk ve son merci çözüm organı. Kamu İhale Kanunu kapsamındaki tüm ihaleler için yetkili. + +## Decision Types +- **Uyuşmazlık**: İhale süreç itirazları +- **Düzenleyici**: Mevzuat ve uygulama kararları +- **Mahkeme**: Mahkeme kararlarının uygulanması + +## Filtreleme Seçenekleri +- **Karar numarası**: 2024/UH.II-1766 formatında +- **Tarih aralığı**: Karar tarihi filtreleme +- **İhaleyi yapan idare**: Bakanlık, belediye, hastane, üniversite +- **Başvuru sahibi**: Şirket, firma adı +- **İhale konusu**: Mal, hizmet, yapım işi + +## Sayfalanmış İçerik Özelliği +5.000 karakterlik sayfalar halinde Markdown formatında sunulur. + +## Usage Scenarios +- **İhale hukuku**: Kamu alımları, süreç kuralları +- **Başvuru hazırlığı**: Benzer davalar, emsal kararlar +- **Mevzuat yorumu**: Kamu İhale Kanunu uygulaması +- **İtiraz stratejisi**: Başarılı itiraz örnekleri + +## İhale Süreç Aşamaları +1. **İhale öncesi**: İlan, şartname hazırlığı +2. **İhale aşaması**: Teklif verme, değerlendirme +3. **İhale sonrası**: Sonuç bildirimi, itirazlar +4. **Sözleşme**: İmza, uygulama + +## Parameter Details +### search_kik_decisions +- **karar_tipi**: Decision type - "rbUyusmazlik" (disputes), "rbDuzenleyici" (regulatory), "rbMahkeme" (court) +- **karar_no**: Decision number (e.g., '2024/UH.II-1766') +- **karar_tarihi_baslangic**: Decision start date (DD.MM.YYYY format) +- **karar_tarihi_bitis**: Decision end date (DD.MM.YYYY format) +- **basvuru_sahibi**: Applicant name/company +- **ihaleyi_yapan_idare**: Procuring entity (ministry, municipality, etc.) +- **basvuru_konusu_ihale**: Tender subject/description +- **karar_metni**: Text search with operators: +word (AND), -word (exclude) +- **yil**: Decision year +- **resmi_gazete_tarihi**: Official Gazette date (DD.MM.YYYY) +- **resmi_gazete_sayisi**: Official Gazette number +- **page**: Results page number + +### get_kik_document_markdown +- **karar_id**: Base64 encoded decision identifier from search results +- **page_number**: Page number for paginated content (1-indexed, default: 1) + +## Best Practices +1. **İhale türü**: Açık, belli istekliler arası, pazarlık +2. **Süreç aşaması**: Hangi aşamada sorun olduğu +3. **Hukuki dayanak**: İlgili KİK kanun maddesi +4. **Sayfa yönetimi**: Uzun kararları bölümler halinde okuyun +""" + +@app.resource("docs://tools/rekabet") +async def get_rekabet_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Rekabet Kurumu (Competition Authority) Tools Documentation + +## Kurum Konumu +Rekabet hukuku ihlallerini inceleyen ve ceza veren idari otorite. Rekabet Kanunu kapsamında yetkili. + +## Decision Types +- **Birleşme ve Devralma**: Şirket satın almaları, füzyonlar +- **Rekabet İhlali**: Anlaşma, hakim durum kötüye kullanımı +- **Menfi Tespit ve Muafiyet**: İhlal yok kararları, muafiyetler +- **Özelleştirme**: Kamu şirketleri satışı onayları + +## Filtreleme Özellikleri +- **PDF metin arama**: Tam metin içinde kelime arama +- **Karar türü**: Spesifik kategori seçimi +- **Tarih aralığı**: 1997'den günümüze karar arşivi +- **Sektör**: Telekomünikasyon, bankacılık, enerji, perakende + +## Rekabet Hukuku Temel Kavramları +- **Hakim durum**: Pazar gücü +- **Kartel**: Fiyat anlaşması +- **Dikey anlaşmalar**: Tedarikci-bayi ilişkileri +- **Konsantrasyon**: Birleşme işlemleri + +## Usage Scenarios +- **Antitrust araştırması**: Tekelleşme, kartel soruşturmaları +- **Birleşme incelemesi**: M&A transaction değerlendirmesi +- **Sektör analizi**: Belirli pazarlardaki rekabet durumu +- **Ceza hesaplama**: İhlal cezası örnekleri + +## Sektörel Uzmanlık Alanları +1. **Telekomünikasyon**: Operatör rekabeti +2. **Enerji**: Elektrik, doğalgaz piyasası +3. **Finans**: Bankacılık, sigorta +4. **Perakende**: Zincir mağazalar +5. **İnşaat**: Müteahhitlik sektörü + +## Parameter Details +### search_rekabet_kurumu_decisions +- **sayfaAdi**: Search in decision title (Başlık) +- **YayinlanmaTarihi**: Publication date (DD.MM.YYYY format) +- **PdfText**: Search text. For exact phrases use double quotes: "vertical agreement" +- **KararTuru**: Decision type - "Birleşme ve Devralma", "Rekabet İhlali", etc. +- **KararSayisi**: Decision number (Karar Sayısı) +- **KararTarihi**: Decision date (DD.MM.YYYY format) +- **page**: Page number for results list + +### get_rekabet_kurumu_document +- **karar_id**: GUID from search results +- **page_number**: Requested page for Markdown content (1-indexed, default: 1) + +## Best Practices +1. **Sektör odaklı**: İlgili sektörde arama yapın +2. **Karar türü seçimi**: İhtiyacınıza uygun kategori +3. **Güncel mevzuat**: Mevzuat değişiklikleri takibi +4. **Sayfa yönetimi**: Uzun analizleri bölümler halinde +""" + +@app.resource("docs://tools/bedesten_unified") +async def get_bedesten_unified_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Bedesten API Mahkemeleri Tools Documentation + +## Bedesten API Sistemi +bedesten.adalet.gov.tr üzerinden Türk adalet sistemi hiyerarşisindeki mahkemelere erişim. + +## Mahkeme Hiyerarşisi Kapsamı + +### 1. Yerel Hukuk Mahkemeleri (Local Civil Courts) +**Tool**: search_yerel_hukuk_bedesten +- **Konum**: İlk derece mahkemeler +- **Yetki**: Hukuki uyuşmazlıklar (sözleşme, tazminat, mülkiyet) +- **Önem**: Toplumun günlük hukuki sorunları + +### 2. İstinaf Hukuk Mahkemeleri (Civil Courts of Appeals) +**Tool**: search_istinaf_hukuk_bedesten +- **Konum**: Orta derece (Yerel -> İstinaf -> Yargıtay) +- **Yetki**: Yerel mahkeme kararlarına itiraz +- **Önem**: Temyiz öncesi son kontrol + +### 3. Kanun Yararına Bozma (KYB) +**Tool**: search_kyb_bedesten +- **Konum**: Olağanüstü kanun yolu +- **Başvuru sahibi**: Cumhuriyet Başsavcılığı +- **Amaç**: Hukuka aykırı kararları düzeltme +- **Özellik**: Sanık aleyhine olsa bile hukuk yararına + +## Ortak Bedesten API Özellikleri + +### Tarih Filtreleme (ISO 8601) +``` +Başlangıç: kararTarihiStart="2024-01-01T00:00:00.000Z" +Bitiş: kararTarihiEnd="2024-12-31T23:59:59.999Z" +Tek gün: "2024-06-25T00:00:00.000Z" - "2024-06-25T23:59:59.999Z" +``` + +### Kesin Cümle Arama +``` +Normal: phrase="sözleşme ihlali" (kelimeler ayrı ayrı) +Kesin: phrase="\"sözleşme ihlali\"" (tam cümle) +``` + +### Sayfalama +- **pageSize**: 1-100 arası sonuç sayısı +- **pageNumber**: Sayfa numarası (1'den başlar) + +## Mahkeme Özellikleri + +### Yerel Hukuk Mahkemeleri +**Yaygın Dava Türleri**: +- Sözleşme ihlali davaları +- Tazminat talepleri +- Mülkiyet uyuşmazlıkları +- Aile hukuku (boşanma, nafaka) +- Ticari uyuşmazlıklar (küçük-orta ölçek) + +**Kullanım Senaryoları**: +- Günlük hukuki sorunlar +- Vatandaş hakları +- Ticaret hukuku temelleri +- İcra takipleri + +### İstinaf Hukuk Mahkemeleri +**İnceleme Kapsamı**: +- Yerel mahkeme kararlarının kontrolü +- Hukuki ve maddi hata arayışı +- Yeniden yargılama (sınırlı) + +**Kullanım Senaryoları**: +- Temyiz stratejisi gelişitirme +- İstinaf mahkemesi içtihatları +- Yerel-üst mahkeme uyumu analizi + +### Kanun Yararına Bozma (KYB) +**Başvuru Koşulları**: +- Kesinleşmiş mahkeme kararı +- Hukuka açık aykırılık +- Cumhuriyet Başsavcılığı başvurusu +- Sanık aleyhine sonuç doğurmama + +**Kullanım Senaryoları**: +- Sistematik hukuki hatalar +- İçtihat birliğini sağlama +- Hukuk güvenliği +- Nadir ve özel hukuki durumlar + +## Arama Stratejileri + +### Hiyerarşik Arama +``` +1. Yerel mahkeme -> Gündelik sorunlar +2. İstinaf -> Kompleks yorumlar +3. KYB -> İstisnai hukuki durumlar +``` + +### Kesin Terim Kullanımı +``` +Yerel: "\"sözleşme ihlali\"" +İstinaf: "\"temyiz incelemesi\"" +KYB: "\"kanun yararına bozma\"" +``` + +### Tarih Stratejisi +- **Son 2 yıl**: Güncel içtihat +- **5-10 yıl**: Yerleşik görüşler +- **Mevzuat değişikliği sonrası**: Yeni uygulamalar + +## Best Practices +1. **Hiyerarşi takibi**: Alt mahkemeden üst mahkemeye +2. **Kesin cümle**: Hukuki terimler için "\"terim\"" +3. **Tarih aralığı**: İlgili mevzuat dönemleri +4. **Cross-reference**: Aynı konuyu farklı seviyelerde +5. **Minimal sonuç**: KYB çok nadir, az sonuç beklenir + +## Document ID Formatı +Tüm Bedesten mahkemeleri documentId döndürür: +- **Format**: Alfanumerik string +- **Kullanım**: get_*_bedesten_document_markdown fonksiyonları +- **İçerik**: HTML/PDF -> Markdown conversion +""" + +@app.resource("docs://tools/sayistay") +async def get_sayistay_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# Sayıştay (Court of Accounts) Tools Documentation + +## Sayıştay'ın Konumu +Türkiye'nin en üst mali denetim organı. Kamu kaynaklarının kullanımını denetler ve mali disiplini sağlar. + +## Üç Tür Karar Sistemi + +### 1. Genel Kurul Kararları (Interpretive Rulings) +**Tool**: search_sayistay_genel_kurul +- **İşlev**: Mali mevzuat yorumlama +- **Kapsam**: 2006-2024 yılları arası +- **Özellik**: Bağlayıcı yorumlar + +**Filtreleme Seçenekleri**: +- **Karar numarası**: Spesifik karar arama +- **Tarih aralığı**: Başlangıç-bitiş tarihleri +- **Karar tamamı**: Tam metin arama (400 karakter) + +### 2. Temyiz Kurulu Kararları (Appeals Board) +**Tool**: search_sayistay_temyiz_kurulu +- **İşlev**: Daire kararlarına itiraz incelemesi +- **8 Daire Filtreleme**: Uzmanlık alanlarına göre + +**Daire Uzmanlaşmaları**: +- **1. Daire**: Genel bütçeli idareler +- **2. Daire**: Mahalli idareler +- **3. Daire**: Sosyal güvenlik kurumları +- **4. Daire**: KİT ve bağlı ortaklıklar +- **5. Daire**: Düzenleyici kuruluşlar +- **6. Daire**: Vakıflar, dernekler +- **7. Daire**: Üniversiteler, eğitim +- **8. Daire**: Yatırım projeleri + +**Filtreleme Seçenekleri**: +- **İdare türü**: Bakanlık, belediye, üniversite, KİT +- **Temyiz karar**: Tam metin arama +- **Konu sınıflandırması**: Harcama, gelir, taşınır-taşınmaz + +### 3. Daire Kararları (Chamber Decisions) +**Tool**: search_sayistay_daire +- **İşlev**: İlk derece denetim bulguları +- **8 Daire**: Aynı uzmanlaşma alanları + +**Filtreleme Seçenekleri**: +- **Yargılama dairesi**: 1-8 arası daire seçimi +- **Hesap yılı**: Mali yıl bazlı +- **Web karar metni**: İçerik arama + +## Ortak Özellikler + +### Sayfalanmış Markdown +Tüm Sayıştay belgeleri sayfalanmış format: +- **5.000 karakter** per sayfa +- **page_number**: Sayfa numarası +- **total_pages**: Toplam sayfa + +### Tarih Aralığı Desteği +- **Genel Kurul**: 2006-2024 (18 yıl) +- **Temyiz/Daire**: Mevcut veriler üzerinde + +## Usage Scenarios + +### Mali Mevzuat Araştırması +``` +Genel Kurul -> Hukuki yorum +Temyiz -> Uygulama detayları +Daire -> Spesifik örnekler +``` + +### Kamu Mali Yönetimi +- **Bütçe uygulama**: Harcama usulleri +- **İhale süreçleri**: Kamu alımları denetimi +- **Personel giderleri**: Özlük hakları mali boyutu +- **Yatırım projeleri**: Büyük ölçekli projeler + +### Kurumsal Denetim +- **KİT yönetimi**: Kamu iktisadi teşebbüsleri +- **Belediye maliyesi**: Yerel yönetim harcamaları +- **Üniversite bütçesi**: Yükseköğretim mali yönetimi +- **Sosyal güvenlik**: SGK, Bağ-Kur mali işlemleri + +## Arama Stratejileri + +### Hiyerarşik Yaklaşım +1. **Genel Kurul**: Konunun hukuki çerçevesi +2. **Temyiz**: Tartışmalı uygulamalar +3. **Daire**: Günlük uygulama örnekleri + +### Daire Bazlı Strateji +``` +Mali konu -> İlgili daire seçimi -> Derinlemesine arama +Örnek: KİT mali sorunları -> 4. Daire +``` + +### Tarih Odaklı Strateji +- **Son 2 yıl**: Güncel uygulamalar +- **5 yıl**: Yerleşik görüşler +- **2006-2024**: Tarihsel gelişim + +## Best Practices +1. **Daire uzmanlaşması**: İlgili kuruma uygun daire +2. **Hiyerarşik sıralama**: Genel Kurul -> Temyiz -> Daire +3. **Mali dönem**: Bütçe yılları bazında arama +4. **Teknik terimler**: Mali mevzuat terminolojisi +5. **Cross-reference**: Farklı seviyelerden görüş karşılaştırma + +## İdare Türü Kodları +- **1**: Genel bütçeli +- **2**: Özel bütçeli +- **3**: Düzenleyici kuruluşlar +- **4**: Mahalli idareler +- **5**: Sosyal güvenlik +- **6**: KİT +- **7**: Vakıf/dernek +- **8**: Diğer kamu kuruluşları +""" + +@app.resource("docs://tools/kvkk") +async def get_kvkk_tools_documentation() -> str: + """Get document content as Markdown.""" + return """ +# KVKK (Personal Data Protection Authority) Tools + +## Overview +KVKK (Kişisel Verilerin Korunması Kurulu) - Turkey's GDPR equivalent authority. + +## Search Features +- **Brave Search API**: Searches kvkk.gov.tr with Turkish terms +- **Site-targeted**: Auto `site:kvkk.gov.tr "karar özeti"` +- **Pagination**: page and pageSize parameters +- **5,000-char pages**: Paginated Markdown documents + +## Common Search Terms +**Violations**: "veri ihlali", "açık rıza", "idari para cezası" +**Compliance**: "GDPR uyum", "veri koruma", "güvenlik tedbirleri" +**Sectors**: "e-ticaret", "bankacılık", "sağlık", "mobil uygulama" + +## Key Decision Types +- **Fines**: Data breaches, consent violations +- **Compliance**: GDPR alignment, corporate policies +- **Breach notifications**: 24-hour rule violations + +## Usage Tips +1. Use Turkish legal terms for best results +2. Combine sector + violation type searches +3. Use page_number for long decisions +4. Focus on recent 2-3 years for current practices +""" + + +# --- API Client Instances --- +yargitay_client_instance = YargitayOfficialApiClient() +danistay_client_instance = DanistayApiClient() +emsal_client_instance = EmsalApiClient() +uyusmazlik_client_instance = UyusmazlikApiClient() +anayasa_norm_client_instance = AnayasaMahkemesiApiClient() +anayasa_bireysel_client_instance = AnayasaBireyselBasvuruApiClient() +anayasa_unified_client_instance = AnayasaUnifiedClient() +kik_client_instance = KikApiClient() +rekabet_client_instance = RekabetKurumuApiClient() +bedesten_client_instance = BedestenApiClient() +sayistay_client_instance = SayistayApiClient() +sayistay_unified_client_instance = SayistayUnifiedClient() +kvkk_client_instance = KvkkApiClient() +bddk_client_instance = BddkApiClient() + + +KARAR_TURU_ADI_TO_GUID_ENUM_MAP = { + "": RekabetKararTuruGuidEnum.TUMU, # Keep for backward compatibility + "ALL": RekabetKararTuruGuidEnum.TUMU, # Map "ALL" to TUMU + "Birleşme ve Devralma": RekabetKararTuruGuidEnum.BIRLESME_DEVRALMA, + "Diğer": RekabetKararTuruGuidEnum.DIGER, + "Menfi Tespit ve Muafiyet": RekabetKararTuruGuidEnum.MENFI_TESPIT_MUAFIYET, + "Özelleştirme": RekabetKararTuruGuidEnum.OZELLESTIRME, + "Rekabet İhlali": RekabetKararTuruGuidEnum.REKABET_IHLALI, +} + +# --- MCP Tools for Yargitay --- +""" +@app.tool( + description="Search Yargıtay decisions with 52 chamber filtering and advanced operators", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_yargitay_detailed( + arananKelime: str = Field("", description="Turkish search keyword. Supports +required -excluded \"exact phrase\" operators"), + birimYrgKurulDaire: str = Field("ALL", description="Chamber selection (52 options: Civil/Criminal chambers, General Assemblies)"), + esasYil: str = Field("", description="Case year for 'Esas No'."), + esasIlkSiraNo: str = Field("", description="Starting sequence number for 'Esas No'."), + esasSonSiraNo: str = Field("", description="Ending sequence number for 'Esas No'."), + kararYil: str = Field("", description="Decision year for 'Karar No'."), + kararIlkSiraNo: str = Field("", description="Starting sequence number for 'Karar No'."), + kararSonSiraNo: str = Field("", description="Ending sequence number for 'Karar No'."), + baslangicTarihi: str = Field("", description="Start date for decision search (DD.MM.YYYY)."), + bitisTarihi: str = Field("", description="End date for decision search (DD.MM.YYYY)."), + # pageSize: int = Field(10, ge=1, le=10, description="Number of results per page."), + pageNumber: int = Field(1, ge=1, description="Page number to retrieve.") +) -> CompactYargitaySearchResult: + # Search Yargıtay decisions using primary API with 52 chamber filtering and advanced operators. + + # Convert "ALL" to empty string for API compatibility + if birimYrgKurulDaire == "ALL": + birimYrgKurulDaire = "" + + pageSize = 10 # Default value + + search_query = YargitayDetailedSearchRequest( + arananKelime=arananKelime, + birimYrgKurulDaire=birimYrgKurulDaire, + esasYil=esasYil, + esasIlkSiraNo=esasIlkSiraNo, + esasSonSiraNo=esasSonSiraNo, + kararYil=kararYil, + kararIlkSiraNo=kararIlkSiraNo, + kararSonSiraNo=kararSonSiraNo, + baslangicTarihi=baslangicTarihi, + bitisTarihi=bitisTarihi, + siralama="3", + siralamaDirection="desc", + pageSize=pageSize, + pageNumber=pageNumber + ) + + logger.info(f"Tool 'search_yargitay_detailed' called: {search_query.model_dump_json(exclude_none=True, indent=2)}") + try: + api_response = await yargitay_client_instance.search_detailed_decisions(search_query) + if api_response and api_response.data and api_response.data.data: + # Convert to clean decision entries without arananKelime field + clean_decisions = [ + CleanYargitayDecisionEntry( + id=decision.id, + daire=decision.daire, + esasNo=decision.esasNo, + kararNo=decision.kararNo, + kararTarihi=decision.kararTarihi, + document_url=decision.document_url + ) + for decision in api_response.data.data + ] + return CompactYargitaySearchResult( + decisions=clean_decisions, + total_records=api_response.data.recordsTotal if api_response.data else 0, + requested_page=search_query.pageNumber, + page_size=search_query.pageSize) + logger.warning("API response for Yargitay search did not contain expected data structure.") + return CompactYargitaySearchResult(decisions=[], total_records=0, requested_page=search_query.pageNumber, page_size=search_query.pageSize) + except Exception as e: + logger.exception(f"Error in tool 'search_yargitay_detailed'.") + raise + +@app.tool( + description="Get Yargıtay decision text in Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_yargitay_document_markdown(id: str) -> YargitayDocumentMarkdown: + # Get Yargıtay decision text as Markdown. Use ID from search results. + logger.info(f"Tool 'get_yargitay_document_markdown' called for ID: {id}") + if not id or not id.strip(): raise ValueError("Document ID must be a non-empty string.") + try: + return await yargitay_client_instance.get_decision_document_as_markdown(id) + except Exception as e: + logger.exception(f"Error in tool 'get_yargitay_document_markdown'.") + raise +""" + +# --- MCP Tools for Danistay --- +""" +@app.tool( + description="Search Danıştay decisions with keyword logic (AND/OR/NOT operators)", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_danistay_by_keyword( + andKelimeler: List[str] = Field(default_factory=list, description="Keywords for AND logic, e.g., ['word1', 'word2']"), + orKelimeler: List[str] = Field(default_factory=list, description="Keywords for OR logic."), + notAndKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT AND logic."), + notOrKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT OR logic."), + pageNumber: int = Field(1, ge=1, description="Page number."), + # pageSize: int = Field(10, ge=1, le=10, description="Results per page.") +) -> CompactDanistaySearchResult: + # Search Danıştay decisions with keyword logic. + + pageSize = 10 # Default value + + search_query = DanistayKeywordSearchRequest( + andKelimeler=andKelimeler, + orKelimeler=orKelimeler, + notAndKelimeler=notAndKelimeler, + notOrKelimeler=notOrKelimeler, + pageNumber=pageNumber, + pageSize=pageSize + ) + + logger.info(f"Tool 'search_danistay_by_keyword' called.") + try: + api_response = await danistay_client_instance.search_keyword_decisions(search_query) + if api_response.data: + return CompactDanistaySearchResult( + decisions=api_response.data.data, + total_records=api_response.data.recordsTotal, + requested_page=search_query.pageNumber, + page_size=search_query.pageSize) + logger.warning("API response for Danistay keyword search did not contain expected data structure.") + return CompactDanistaySearchResult(decisions=[], total_records=0, requested_page=search_query.pageNumber, page_size=search_query.pageSize) + except Exception as e: + logger.exception(f"Error in tool 'search_danistay_by_keyword'.") + raise + +@app.tool( + description="Search Danıştay decisions with detailed criteria (chamber selection, case numbers)", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_danistay_detailed( + daire: str = Field("", description="Chamber/Department name (e.g., '1. Daire')."), + esasYil: str = Field("", description="Case year for 'Esas No'."), + esasIlkSiraNo: str = Field("", description="Starting sequence for 'Esas No'."), + esasSonSiraNo: str = Field("", description="Ending sequence for 'Esas No'."), + kararYil: str = Field("", description="Decision year for 'Karar No'."), + kararIlkSiraNo: str = Field("", description="Starting sequence for 'Karar No'."), + kararSonSiraNo: str = Field("", description="Ending sequence for 'Karar No'."), + baslangicTarihi: str = Field("", description="Start date for decision (DD.MM.YYYY)."), + bitisTarihi: str = Field("", description="End date for decision (DD.MM.YYYY)."), + mevzuatNumarasi: str = Field("", description="Legislation number."), + mevzuatAdi: str = Field("", description="Legislation name."), + madde: str = Field("", description="Article number."), + pageNumber: int = Field(1, ge=1, description="Page number."), + # pageSize: int = Field(10, ge=1, le=10, description="Results per page.") +) -> CompactDanistaySearchResult: + # Search Danıştay decisions with detailed filtering. + + pageSize = 10 # Default value + + search_query = DanistayDetailedSearchRequest( + daire=daire, + esasYil=esasYil, + esasIlkSiraNo=esasIlkSiraNo, + esasSonSiraNo=esasSonSiraNo, + kararYil=kararYil, + kararIlkSiraNo=kararIlkSiraNo, + kararSonSiraNo=kararSonSiraNo, + baslangicTarihi=baslangicTarihi, + bitisTarihi=bitisTarihi, + mevzuatNumarasi=mevzuatNumarasi, + mevzuatAdi=mevzuatAdi, + madde=madde, + siralama="3", + siralamaDirection="desc", + pageNumber=pageNumber, + pageSize=pageSize + ) + + logger.info(f"Tool 'search_danistay_detailed' called.") + try: + api_response = await danistay_client_instance.search_detailed_decisions(search_query) + if api_response.data: + return CompactDanistaySearchResult( + decisions=api_response.data.data, + total_records=api_response.data.recordsTotal, + requested_page=search_query.pageNumber, + page_size=search_query.pageSize) + logger.warning("API response for Danistay detailed search did not contain expected data structure.") + return CompactDanistaySearchResult(decisions=[], total_records=0, requested_page=search_query.pageNumber, page_size=search_query.pageSize) + except Exception as e: + logger.exception(f"Error in tool 'search_danistay_detailed'.") + raise + +@app.tool( + description="Get Danıştay decision text in Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_danistay_document_markdown(id: str) -> DanistayDocumentMarkdown: + # Get Danıştay decision text as Markdown. Use ID from search results. + logger.info(f"Tool 'get_danistay_document_markdown' called for ID: {id}") + if not id or not id.strip(): raise ValueError("Document ID must be a non-empty string for Danıştay.") + try: + return await danistay_client_instance.get_decision_document_as_markdown(id) + except Exception as e: + logger.exception(f"Error in tool 'get_danistay_document_markdown'.") + raise +""" + +# --- MCP Tools for Emsal --- +@app.tool( + description="Search Emsal precedent decisions with detailed criteria", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_emsal_detailed_decisions( + keyword: str = Field("", description="Keyword to search."), + selected_bam_civil_court: str = Field("", description="Selected BAM Civil Court."), + selected_civil_court: str = Field("", description="Selected Civil Court."), + selected_regional_civil_chambers: List[str] = Field(default_factory=list, description="Selected Regional Civil Chambers."), + case_year_esas: str = Field("", description="Case year for 'Esas No'."), + case_start_seq_esas: str = Field("", description="Starting sequence for 'Esas No'."), + case_end_seq_esas: str = Field("", description="Ending sequence for 'Esas No'."), + decision_year_karar: str = Field("", description="Decision year for 'Karar No'."), + decision_start_seq_karar: str = Field("", description="Starting sequence for 'Karar No'."), + decision_end_seq_karar: str = Field("", description="Ending sequence for 'Karar No'."), + start_date: str = Field("", description="Start date for decision (DD.MM.YYYY)."), + end_date: str = Field("", description="End date for decision (DD.MM.YYYY)."), + sort_criteria: str = Field("1", description="Sorting criteria (e.g., 1: Esas No)."), + sort_direction: str = Field("desc", description="Sorting direction ('asc' or 'desc')."), + page_number: int = Field(1, ge=1, description="Page number (accepts int)."), + # page_size: int = Field(10, ge=1, le=10, description="Results per page.") +) -> CompactEmsalSearchResult: + """Search Emsal precedent decisions with detailed criteria.""" + + page_size = 10 # Default value + + search_query = EmsalSearchRequest( + keyword=keyword, + selected_bam_civil_court=selected_bam_civil_court, + selected_civil_court=selected_civil_court, + selected_regional_civil_chambers=selected_regional_civil_chambers, + case_year_esas=case_year_esas, + case_start_seq_esas=case_start_seq_esas, + case_end_seq_esas=case_end_seq_esas, + decision_year_karar=decision_year_karar, + decision_start_seq_karar=decision_start_seq_karar, + decision_end_seq_karar=decision_end_seq_karar, + start_date=start_date, + end_date=end_date, + sort_criteria=sort_criteria, + sort_direction=sort_direction, + page_number=page_number, + page_size=page_size + ) + + logger.info(f"Tool 'search_emsal_detailed_decisions' called.") + try: + api_response = await emsal_client_instance.search_detailed_decisions(search_query) + if api_response.data: + return CompactEmsalSearchResult( + decisions=api_response.data.data, + total_records=api_response.data.recordsTotal if api_response.data.recordsTotal is not None else 0, + requested_page=search_query.page_number, + page_size=search_query.page_size + ) + logger.warning("API response for Emsal search did not contain expected data structure.") + return CompactEmsalSearchResult(decisions=[], total_records=0, requested_page=search_query.page_number, page_size=search_query.page_size) + except Exception as e: + logger.exception(f"Error in tool 'search_emsal_detailed_decisions'.") + raise + +@app.tool( + description="Get Emsal precedent decision text in Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_emsal_document_markdown(id: str) -> EmsalDocumentMarkdown: + """Get document as Markdown.""" + logger.info(f"Tool 'get_emsal_document_markdown' called for ID: {id}") + if not id or not id.strip(): raise ValueError("Document ID required for Emsal.") + try: + return await emsal_client_instance.get_decision_document_as_markdown(id) + except Exception as e: + logger.exception(f"Error in tool 'get_emsal_document_markdown'.") + raise + +# --- MCP Tools for Uyusmazlik --- +@app.tool( + description="Search Uyuşmazlık Mahkemesi decisions for jurisdictional disputes", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_uyusmazlik_decisions( + icerik: str = Field("", description="Keyword or content for main text search."), + bolum: Literal["ALL", "Ceza Bölümü", "Genel Kurul Kararları", "Hukuk Bölümü"] = Field("ALL", description="Select the department (Bölüm). Use 'ALL' for all departments."), + uyusmazlik_turu: Literal["ALL", "Görev Uyuşmazlığı", "Hüküm Uyuşmazlığı"] = Field("ALL", description="Select the type of dispute. Use 'ALL' for all types."), + karar_sonuclari: List[Literal["Hüküm Uyuşmazlığı Olmadığına Dair", "Hüküm Uyuşmazlığı Olduğuna Dair"]] = Field(default_factory=list, description="List of desired 'Karar Sonucu' types."), + esas_yil: str = Field("", description="Case year ('Esas Yılı')."), + esas_sayisi: str = Field("", description="Case number ('Esas Sayısı')."), + karar_yil: str = Field("", description="Decision year ('Karar Yılı')."), + karar_sayisi: str = Field("", description="Decision number ('Karar Sayısı')."), + kanun_no: str = Field("", description="Relevant Law Number."), + karar_date_begin: str = Field("", description="Decision start date (DD.MM.YYYY)."), + karar_date_end: str = Field("", description="Decision end date (DD.MM.YYYY)."), + resmi_gazete_sayi: str = Field("", description="Official Gazette number."), + resmi_gazete_date: str = Field("", description="Official Gazette date (DD.MM.YYYY)."), + tumce: str = Field("", description="Exact phrase search."), + wild_card: str = Field("", description="Search for phrase and its inflections."), + hepsi: str = Field("", description="Search for texts containing all specified words."), + herhangi_birisi: str = Field("", description="Search for texts containing any of the specified words."), + not_hepsi: str = Field("", description="Exclude texts containing these specified words.") +) -> UyusmazlikSearchResponse: + """Search Court of Jurisdictional Disputes decisions.""" + + # Convert string literals to enums + # Map "ALL" to TUMU for backward compatibility + if bolum == "ALL": + bolum_enum = UyusmazlikBolumEnum.TUMU + else: + bolum_enum = UyusmazlikBolumEnum(bolum) if bolum else UyusmazlikBolumEnum.TUMU + + if uyusmazlik_turu == "ALL": + uyusmazlik_turu_enum = UyusmazlikTuruEnum.TUMU + else: + uyusmazlik_turu_enum = UyusmazlikTuruEnum(uyusmazlik_turu) if uyusmazlik_turu else UyusmazlikTuruEnum.TUMU + karar_sonuclari_enums = [UyusmazlikKararSonucuEnum(ks) for ks in karar_sonuclari] + + search_params = UyusmazlikSearchRequest( + icerik=icerik, + bolum=bolum_enum, + uyusmazlik_turu=uyusmazlik_turu_enum, + karar_sonuclari=karar_sonuclari_enums, + esas_yil=esas_yil, + esas_sayisi=esas_sayisi, + karar_yil=karar_yil, + karar_sayisi=karar_sayisi, + kanun_no=kanun_no, + karar_date_begin=karar_date_begin, + karar_date_end=karar_date_end, + resmi_gazete_sayi=resmi_gazete_sayi, + resmi_gazete_date=resmi_gazete_date, + tumce=tumce, + wild_card=wild_card, + hepsi=hepsi, + herhangi_birisi=herhangi_birisi, + not_hepsi=not_hepsi + ) + + logger.info(f"Tool 'search_uyusmazlik_decisions' called.") + try: + return await uyusmazlik_client_instance.search_decisions(search_params) + except Exception as e: + logger.exception(f"Error in tool 'search_uyusmazlik_decisions'.") + raise + +@app.tool( + description="Get Uyuşmazlık Mahkemesi decision text from URL in Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_uyusmazlik_document_markdown_from_url( + document_url: str = Field(..., description="Full URL to the Uyuşmazlık Mahkemesi decision document from search results") +) -> UyusmazlikDocumentMarkdown: + """Get Uyuşmazlık Mahkemesi decision as Markdown.""" + logger.info(f"Tool 'get_uyusmazlik_document_markdown_from_url' called for URL: {str(document_url)}") + if not document_url: + raise ValueError("Document URL (document_url) is required for Uyuşmazlık document retrieval.") + try: + return await uyusmazlik_client_instance.get_decision_document_as_markdown(str(document_url)) + except Exception as e: + logger.exception(f"Error in tool 'get_uyusmazlik_document_markdown_from_url'.") + raise + +# --- DEACTIVATED: MCP Tools for Anayasa Mahkemesi (Individual Tools) --- +# Use search_anayasa_unified and get_anayasa_document_unified instead + +""" +@app.tool( + description="Search Constitutional Court norm control decisions with comprehensive filtering", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +# DEACTIVATED TOOL - Use search_anayasa_unified instead +# @app.tool( +# description="DEACTIVATED - Use search_anayasa_unified instead", +# annotations={"readOnlyHint": True, "openWorldHint": False, "idempotentHint": True} +# ) +# async def search_anayasa_norm_denetimi_decisions(...) -> AnayasaSearchResult: +# raise ValueError("This tool is deactivated. Use search_anayasa_unified instead.") + +# DEACTIVATED TOOL - Use get_anayasa_document_unified instead +# @app.tool(...) +# async def get_anayasa_norm_denetimi_document_markdown(...) -> AnayasaDocumentMarkdown: +# raise ValueError("This tool is deactivated. Use get_anayasa_document_unified instead.") + +# DEACTIVATED TOOL - Use search_anayasa_unified instead +# @app.tool(...) +# async def search_anayasa_bireysel_basvuru_report(...) -> AnayasaBireyselReportSearchResult: +# raise ValueError("This tool is deactivated. Use search_anayasa_unified instead.") + +# DEACTIVATED TOOL - Use get_anayasa_document_unified instead +# @app.tool(...) +# async def get_anayasa_bireysel_basvuru_document_markdown(...) -> AnayasaBireyselBasvuruDocumentMarkdown: +# raise ValueError("This tool is deactivated. Use get_anayasa_document_unified instead.") +""" + +# --- Unified MCP Tools for Anayasa Mahkemesi --- +@app.tool( + description="Unified search for Constitutional Court decisions: both norm control (normkararlarbilgibankasi) and individual applications (kararlarbilgibankasi) in one tool", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_anayasa_unified( + decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Decision type: norm_denetimi (norm control) or bireysel_basvuru (individual applications)"), + keywords: List[str] = Field(default_factory=list, description="Keywords to search for (common parameter)"), + page_to_fetch: int = Field(1, ge=1, le=100, description="Page number to fetch (1-100)"), + # results_per_page: int = Field(10, ge=1, le=100, description="Results per page (1-100)"), + + # Norm Denetimi specific parameters (ignored for bireysel_basvuru) + keywords_all: List[str] = Field(default_factory=list, description="All keywords must be present (norm_denetimi only)"), + keywords_any: List[str] = Field(default_factory=list, description="Any of these keywords (norm_denetimi only)"), + decision_type_norm: Literal["ALL", "1", "2", "3"] = Field("ALL", description="Decision type for norm denetimi"), + application_date_start: str = Field("", description="Application start date (norm_denetimi only)"), + application_date_end: str = Field("", description="Application end date (norm_denetimi only)"), + + # Bireysel Başvuru specific parameters (ignored for norm_denetimi) + decision_start_date: str = Field("", description="Decision start date (bireysel_basvuru only)"), + decision_end_date: str = Field("", description="Decision end date (bireysel_basvuru only)"), + norm_type: Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"] = Field("ALL", description="Norm type (bireysel_basvuru only)"), + subject_category: str = Field("", description="Subject category (bireysel_basvuru only)") +) -> str: + logger.info(f"Tool 'search_anayasa_unified' called for decision_type: {decision_type}") + + results_per_page = 10 # Default value + + try: + request = AnayasaUnifiedSearchRequest( + decision_type=decision_type, + keywords=keywords, + page_to_fetch=page_to_fetch, + results_per_page=results_per_page, + keywords_all=keywords_all, + keywords_any=keywords_any, + decision_type_norm=decision_type_norm, + application_date_start=application_date_start, + application_date_end=application_date_end, + decision_start_date=decision_start_date, + decision_end_date=decision_end_date, + norm_type=norm_type, + subject_category=subject_category + ) + + result = await anayasa_unified_client_instance.search_unified(request) + return json.dumps(result.model_dump(), ensure_ascii=False, indent=2) + + except Exception as e: + logger.exception(f"Error in tool 'search_anayasa_unified'.") + raise + +@app.tool( + description="Unified document retrieval for Constitutional Court decisions: auto-detects norm control vs individual applications based on URL", + annotations={ + "readOnlyHint": True, + "openWorldHint": False, + "idempotentHint": True + } +) +async def get_anayasa_document_unified( + document_url: str = Field(..., description="Document URL from search results"), + page_number: int = Field(1, ge=1, description="Page number for paginated content (1-indexed)") +) -> str: + logger.info(f"Tool 'get_anayasa_document_unified' called for URL: {document_url}, Page: {page_number}") + + try: + result = await anayasa_unified_client_instance.get_document_unified(document_url, page_number) + return json.dumps(result.model_dump(mode='json'), ensure_ascii=False, indent=2) + + except Exception as e: + logger.exception(f"Error in tool 'get_anayasa_document_unified'.") + raise + +# --- MCP Tools for KIK (Kamu İhale Kurulu) --- +@app.tool( + description="Search Public Procurement Authority (KİK) decisions for procurement law disputes", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_kik_decisions( + karar_tipi: Literal["rbUyusmazlik", "rbDuzenleyici", "rbMahkeme"] = Field("rbUyusmazlik", description="Type of KIK Decision."), + karar_no: str = Field("", description="Decision Number (e.g., '2024/UH.II-1766')."), + karar_tarihi_baslangic: str = Field("", description="Decision Date Start (DD.MM.YYYY)."), + karar_tarihi_bitis: str = Field("", description="Decision Date End (DD.MM.YYYY)."), + basvuru_sahibi: str = Field("", description="Applicant."), + ihaleyi_yapan_idare: str = Field("", description="Procuring Entity."), + basvuru_konusu_ihale: str = Field("", description="Tender subject of the application."), + karar_metni: str = Field("", description="Decision text search. Supports: +word, -word, \"exact phrase\", OR/AND"), + yil: str = Field("", description="Year of the decision."), + resmi_gazete_tarihi: str = Field("", description="Official Gazette Date (DD.MM.YYYY)."), + resmi_gazete_sayisi: str = Field("", description="Official Gazette Number."), + page: int = Field(1, ge=1, description="Results page number.") +) -> KikSearchResult: + """Search Public Procurement Authority (KIK) decisions.""" + + # Convert string literal to enum + karar_tipi_enum = KikKararTipi(karar_tipi) + + search_query = KikSearchRequest( + karar_tipi=karar_tipi_enum, + karar_no=karar_no, + karar_tarihi_baslangic=karar_tarihi_baslangic, + karar_tarihi_bitis=karar_tarihi_bitis, + basvuru_sahibi=basvuru_sahibi, + ihaleyi_yapan_idare=ihaleyi_yapan_idare, + basvuru_konusu_ihale=basvuru_konusu_ihale, + karar_metni=karar_metni, + yil=yil, + resmi_gazete_tarihi=resmi_gazete_tarihi, + resmi_gazete_sayisi=resmi_gazete_sayisi, + page=page + ) + + logger.info(f"Tool 'search_kik_decisions' called.") + try: + api_response = await kik_client_instance.search_decisions(search_query) + page_param_for_log = search_query.page if hasattr(search_query, 'page') else 1 + if not api_response.decisions and api_response.total_records == 0 and page_param_for_log == 1: + logger.warning(f"KIK search returned no decisions for query.") + return api_response + except Exception as e: + logger.exception(f"Error in KIK search tool 'search_kik_decisions'.") + current_page_val = search_query.page if hasattr(search_query, 'page') else 1 + return KikSearchResult(decisions=[], total_records=0, current_page=current_page_val) + +@app.tool( + description="Get Public Procurement Authority (KİK) decision text in paginated Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_kik_document_markdown( + karar_id: str = Field(..., description="The Base64 encoded KIK decision identifier."), + page_number: int = Field(1, ge=1, description="Page number for paginated Markdown content (1-indexed). Default is 1.") +) -> KikDocumentMarkdown: + """Get KIK decision as paginated Markdown.""" + logger.info(f"Tool 'get_kik_document_markdown' called for KIK karar_id: {karar_id}, Markdown Page: {page_number}") + + if not karar_id or not karar_id.strip(): + logger.error("KIK Document retrieval: karar_id cannot be empty.") + return KikDocumentMarkdown( + retrieved_with_karar_id=karar_id, + error_message="karar_id is required and must be a non-empty string.", + current_page=page_number or 1, + total_pages=1, + is_paginated=False + ) + + current_page_to_fetch = page_number if page_number is not None and page_number >= 1 else 1 + + try: + return await kik_client_instance.get_decision_document_as_markdown( + karar_id_b64=karar_id, + page_number=current_page_to_fetch + ) + except Exception as e: + logger.exception(f"Error in KIK document retrieval tool 'get_kik_document_markdown' for karar_id: {karar_id}") + return KikDocumentMarkdown( + retrieved_with_karar_id=karar_id, + error_message=f"Tool-level error during KIK document retrieval: {str(e)}", + current_page=current_page_to_fetch, + total_pages=1, + is_paginated=False + ) +@app.tool( + description="Search Competition Authority (Rekabet Kurumu) decisions for competition law and antitrust", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_rekabet_kurumu_decisions( + sayfaAdi: str = Field("", description="Search in decision title (Başlık)."), + YayinlanmaTarihi: str = Field("", description="Publication date (Yayım Tarihi), e.g., DD.MM.YYYY."), + PdfText: str = Field( + "", + description='Search in decision text. Use "\\"kesin cümle\\"" for precise matching.' + ), + KararTuru: Literal[ + "ALL", + "Birleşme ve Devralma", + "Diğer", + "Menfi Tespit ve Muafiyet", + "Özelleştirme", + "Rekabet İhlali" + ] = Field("ALL", description="Parameter description"), + KararSayisi: str = Field("", description="Decision number (Karar Sayısı)."), + KararTarihi: str = Field("", description="Decision date (Karar Tarihi), e.g., DD.MM.YYYY."), + page: int = Field(1, ge=1, description="Page number to fetch for the results list.") +) -> RekabetSearchResult: + """Search Competition Authority decisions.""" + + karar_turu_guid_enum = KARAR_TURU_ADI_TO_GUID_ENUM_MAP.get(KararTuru) + + try: + if karar_turu_guid_enum is None: + logger.warning(f"Invalid user-provided KararTuru: '{KararTuru}'. Defaulting to TUMU (all).") + karar_turu_guid_enum = RekabetKararTuruGuidEnum.TUMU + except Exception as e_map: + logger.error(f"Error mapping KararTuru '{KararTuru}': {e_map}. Defaulting to TUMU.") + karar_turu_guid_enum = RekabetKararTuruGuidEnum.TUMU + + search_query = RekabetKurumuSearchRequest( + sayfaAdi=sayfaAdi, + YayinlanmaTarihi=YayinlanmaTarihi, + PdfText=PdfText, + KararTuruID=karar_turu_guid_enum, + KararSayisi=KararSayisi, + KararTarihi=KararTarihi, + page=page + ) + logger.info(f"Tool 'search_rekabet_kurumu_decisions' called. Query: {search_query.model_dump_json(exclude_none=True, indent=2)}") + try: + + return await rekabet_client_instance.search_decisions(search_query) + except Exception as e: + logger.exception("Error in tool 'search_rekabet_kurumu_decisions'.") + return RekabetSearchResult(decisions=[], retrieved_page_number=page, total_records_found=0, total_pages=0) + +@app.tool( + description="Get Competition Authority decision text in paginated Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_rekabet_kurumu_document( + karar_id: str = Field(..., description="GUID (kararId) of the Rekabet Kurumu decision. This ID is obtained from search results."), + page_number: int = Field(1, ge=1, description="Requested page number for the Markdown content converted from PDF (1-indexed, accepts int). Default is 1.") +) -> RekabetDocument: + """Get Competition Authority decision as paginated Markdown.""" + logger.info(f"Tool 'get_rekabet_kurumu_document' called. Karar ID: {karar_id}, Markdown Page: {page_number}") + + current_page_to_fetch = page_number if page_number >= 1 else 1 + + try: + + return await rekabet_client_instance.get_decision_document(karar_id, page_number=current_page_to_fetch) + except Exception as e: + logger.exception(f"Error in tool 'get_rekabet_kurumu_document'. Karar ID: {karar_id}") + raise + +# --- MCP Tools for Bedesten (Unified Search Across All Courts) --- +@app.tool( + description="Search multiple Turkish courts (Yargıtay, Danıştay, Local Courts, Appeals Courts, KYB)", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_bedesten_unified( + ctx: Context, + phrase: str = Field(..., description="""Search query in Turkish. SUPPORTED OPERATORS: +• Simple: "mülkiyet hakkı" (finds both words) +• Exact phrase: "\"mülkiyet hakkı\"" (finds exact phrase) +• Required term: "+mülkiyet hakkı" (must contain mülkiyet) +• Exclude term: "mülkiyet -kira" (contains mülkiyet but not kira) +• Boolean AND: "mülkiyet AND hak" (both terms required) +• Boolean OR: "mülkiyet OR tapu" (either term acceptable) +• Boolean NOT: "mülkiyet NOT satış" (contains mülkiyet but not satış) +NOTE: Wildcards (*,?), regex patterns (/regex/), fuzzy search (~), and proximity search are NOT supported. +For best results, use exact phrases with quotes for legal terms."""), + court_types: List[BedestenCourtTypeEnum] = Field( + default=["YARGITAYKARARI", "DANISTAYKARAR"], + description="Court types: YARGITAYKARARI, DANISTAYKARAR, YERELHUKUK, ISTINAFHUKUK, KYB" + ), + # pageSize: int = Field(10, ge=1, le=10, description="Results per page (1-10)"), + pageNumber: int = Field(1, ge=1, description="Page number"), + birimAdi: BirimAdiEnum = Field("ALL", description=""" + Chamber filter (optional). Abbreviated values with Turkish names: + • Yargıtay: H1-H23 (1-23. Hukuk Dairesi), C1-C23 (1-23. Ceza Dairesi), HGK (Hukuk Genel Kurulu), CGK (Ceza Genel Kurulu), BGK (Büyük Genel Kurulu), HBK (Hukuk Daireleri Başkanlar Kurulu), CBK (Ceza Daireleri Başkanlar Kurulu) + • Danıştay: D1-D17 (1-17. Daire), DBGK (Büyük Gen.Kur.), IDDK (İdare Dava Daireleri Kurulu), VDDK (Vergi Dava Daireleri Kurulu), IBK (İçtihatları Birleştirme Kurulu), IIK (İdari İşler Kurulu), DBK (Başkanlar Kurulu), AYIM (Askeri Yüksek İdare Mahkemesi), AYIM1-3 (Askeri Yüksek İdare Mahkemesi 1-3. Daire) + """), + kararTarihiStart: str = Field("", description="Start date (ISO 8601 format)"), + kararTarihiEnd: str = Field("", description="End date (ISO 8601 format)") +) -> dict: + """Search Turkish legal databases via unified Bedesten API.""" + + # Get Bearer token information for access control and logging + try: + access_token: AccessToken = get_access_token() + user_id = access_token.client_id + user_scopes = access_token.scopes + + # Check for required scopes + if "yargi.read" not in user_scopes and "yargi.search" not in user_scopes: + raise ToolError(f"Insufficient permissions: 'yargi.read' or 'yargi.search' scope required. Current scopes: {user_scopes}") + + logger.info(f"Tool 'search_bedesten_unified' called by user '{user_id}' with scopes {user_scopes}") + + except Exception as e: + # Development mode fallback - allow access without strict token validation + logger.warning(f"Bearer token validation failed, using development mode: {str(e)}") + user_id = "dev-user" + user_scopes = ["yargi.read", "yargi.search"] + + pageSize = 10 # Default value + + search_data = BedestenSearchData( + pageSize=pageSize, + pageNumber=pageNumber, + itemTypeList=court_types, + phrase=phrase, + birimAdi=birimAdi, + kararTarihiStart=kararTarihiStart, + kararTarihiEnd=kararTarihiEnd + ) + + search_request = BedestenSearchRequest(data=search_data) + + logger.info(f"User '{user_id}' searching bedesten: phrase='{phrase}', court_types={court_types}, birimAdi='{birimAdi}', page={pageNumber}") + + try: + response = await bedesten_client_instance.search_documents(search_request) + + if response.data is None: + return { + "decisions": [], + "total_records": 0, + "requested_page": pageNumber, + "page_size": pageSize, + "searched_courts": court_types, + "error": "No data returned from Bedesten API" + } + + return { + "decisions": [d.model_dump() for d in response.data.emsalKararList], + "total_records": response.data.total, + "requested_page": pageNumber, + "page_size": pageSize, + "searched_courts": court_types + } + except Exception as e: + logger.exception("Error in tool 'search_bedesten_unified'") + raise + +@app.tool( + description="Get legal decision document from Bedesten API in Markdown format", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def get_bedesten_document_markdown( + documentId: str = Field(..., description="Document ID from Bedesten search results") +) -> BedestenDocumentMarkdown: + """Get legal decision document as Markdown from Bedesten API.""" + logger.info(f"Tool 'get_bedesten_document_markdown' called for ID: {documentId}") + + if not documentId or not documentId.strip(): + raise ValueError("Document ID must be a non-empty string.") + + try: + return await bedesten_client_instance.get_document_as_markdown(documentId) + except Exception as e: + logger.exception("Error in tool 'get_kyb_bedesten_document_markdown'") + raise + +# --- MCP Tools for Sayıştay (Turkish Court of Accounts) --- + +# DEACTIVATED TOOL - Use search_sayistay_unified instead +# @app.tool( +# description="Search Sayıştay Genel Kurul decisions for audit and accountability regulations", +# annotations={ +# "readOnlyHint": True, +# "openWorldHint": True, +# "idempotentHint": True +# } +# ) +# async def search_sayistay_genel_kurul( +# karar_no: str = Field("", description="Decision number to search for (e.g., '5415')"), +# karar_ek: str = Field("", description="Decision appendix number (max 99, e.g., '1')"), +# karar_tarih_baslangic: str = Field("", description="Start date (DD.MM.YYYY)"), +# karar_tarih_bitis: str = Field("", description="End date (DD.MM.YYYY)"), +# karar_tamami: str = Field("", description="Full text search"), +# start: int = Field(0, description="Starting record for pagination (0-based)"), +# length: int = Field(10, description="Number of records per page (1-100)") +# ) -> GenelKurulSearchResponse: +# """Search Sayıştay General Assembly decisions.""" +# raise ValueError("This tool is deactivated. Use search_sayistay_unified instead.") + +# DEACTIVATED TOOL - Use search_sayistay_unified instead +# @app.tool( +# description="Search Sayıştay Temyiz Kurulu decisions with chamber filtering and comprehensive criteria", +# annotations={ +# "readOnlyHint": True, +# "openWorldHint": True, +# "idempotentHint": True +# } +# ) +# async def search_sayistay_temyiz_kurulu( +# ilam_dairesi: DaireEnum = Field("ALL", description="Audit chamber selection"), +# yili: str = Field("", description="Year (YYYY)"), +# karar_tarih_baslangic: str = Field("", description="Start date (DD.MM.YYYY)"), +# karar_tarih_bitis: str = Field("", description="End date (DD.MM.YYYY)"), +# kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Public admin type"), +# ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)"), +# dosya_no: str = Field("", description="File number for the case"), +# temyiz_tutanak_no: str = Field("", description="Appeals board meeting minutes number"), +# temyiz_karar: str = Field("", description="Appeals decision text"), +# web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Decision subject"), +# start: int = Field(0, description="Starting record for pagination (0-based)"), +# length: int = Field(10, description="Number of records per page (1-100)") +# ) -> TemyizKuruluSearchResponse: +# """Search Sayıştay Appeals Board decisions.""" +# raise ValueError("This tool is deactivated. Use search_sayistay_unified instead.") + +# DEACTIVATED TOOL - Use search_sayistay_unified instead +# @app.tool( +# description="Search Sayıştay Daire decisions with chamber filtering and subject categorization", +# annotations={ +# "readOnlyHint": True, +# "openWorldHint": True, +# "idempotentHint": True +# } +# ) +# async def search_sayistay_daire( +# yargilama_dairesi: DaireEnum = Field("ALL", description="Chamber selection"), +# karar_tarih_baslangic: str = Field("", description="Start date (DD.MM.YYYY)"), +# karar_tarih_bitis: str = Field("", description="End date (DD.MM.YYYY)"), +# ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)"), +# kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Public admin type"), +# hesap_yili: str = Field("", description="Fiscal year"), +# web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Decision subject"), +# web_karar_metni: str = Field("", description="Decision text search"), +# start: int = Field(0, description="Starting record for pagination (0-based)"), +# length: int = Field(10, description="Number of records per page (1-100)") +# ) -> DaireSearchResponse: +# """Search Sayıştay Chamber decisions.""" +# raise ValueError("This tool is deactivated. Use search_sayistay_unified instead.") + +# DEACTIVATED TOOL - Use get_sayistay_document_unified instead +# @app.tool( +# description="Get Sayıştay Genel Kurul decision document in Markdown format", +# annotations={ +# "readOnlyHint": True, +# "openWorldHint": False, +# "idempotentHint": True +# } +# ) +# async def get_sayistay_genel_kurul_document_markdown( +# decision_id: str = Field(..., description="Decision ID from search_sayistay_genel_kurul results") +# ) -> SayistayDocumentMarkdown: +# """Get Sayıştay General Assembly decision as Markdown.""" +# raise ValueError("This tool is deactivated. Use get_sayistay_document_unified instead.") + +# DEACTIVATED TOOL - Use get_sayistay_document_unified instead +# @app.tool( +# description="Get Sayıştay Temyiz Kurulu decision document in Markdown format", +# annotations={ +# "readOnlyHint": True, +# "openWorldHint": False, +# "idempotentHint": True +# } +# ) +# async def get_sayistay_temyiz_kurulu_document_markdown( +# decision_id: str = Field(..., description="Decision ID from search_sayistay_temyiz_kurulu results") +# ) -> SayistayDocumentMarkdown: +# """Get Sayıştay Appeals Board decision as Markdown.""" +# raise ValueError("This tool is deactivated. Use get_sayistay_document_unified instead.") + +# DEACTIVATED TOOL - Use get_sayistay_document_unified instead +# @app.tool( +# description="Get Sayıştay Daire decision document in Markdown format", +# annotations={ +# "readOnlyHint": True, +# "openWorldHint": False, +# "idempotentHint": True +# } +# ) +# async def get_sayistay_daire_document_markdown( +# decision_id: str = Field(..., description="Decision ID from search_sayistay_daire results") +# ) -> SayistayDocumentMarkdown: +# """Get Sayıştay Chamber decision as Markdown.""" +# raise ValueError("This tool is deactivated. Use get_sayistay_document_unified instead.") + +# --- UNIFIED MCP Tools for Sayıştay (Turkish Court of Accounts) --- + +@app.tool( + description="Search Sayıştay decisions unified across all three decision types (Genel Kurul, Temyiz Kurulu, Daire) with comprehensive filtering", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_sayistay_unified( + decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Decision type: genel_kurul, temyiz_kurulu, or daire"), + + # Common pagination parameters + start: int = Field(0, ge=0, description="Starting record for pagination (0-based)"), + length: int = Field(10, ge=1, le=100, description="Number of records per page (1-100)"), + + # Common search parameters + karar_tarih_baslangic: str = Field("", description="Start date (DD.MM.YYYY format)"), + karar_tarih_bitis: str = Field("", description="End date (DD.MM.YYYY format)"), + kamu_idaresi_turu: Literal["ALL", "Genel Bütçe Kapsamındaki İdareler", "Yüksek Öğretim Kurumları", "Diğer Özel Bütçeli İdareler", "Düzenleyici ve Denetleyici Kurumlar", "Sosyal Güvenlik Kurumları", "Özel İdareler", "Belediyeler ve Bağlı İdareler", "Diğer"] = Field("ALL", description="Public administration type filter"), + ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)"), + web_karar_konusu: Literal["ALL", "Harcırah Mevzuatı", "İhale Mevzuatı", "İş Mevzuatı", "Personel Mevzuatı", "Sorumluluk ve Yargılama Usulleri", "Vergi Resmi Harç ve Diğer Gelirler", "Çeşitli Konular"] = Field("ALL", description="Decision subject category filter"), + + # Genel Kurul specific parameters (ignored for other types) + karar_no: str = Field("", description="Decision number (genel_kurul only)"), + karar_ek: str = Field("", description="Decision appendix number (genel_kurul only)"), + karar_tamami: str = Field("", description="Full text search (genel_kurul only)"), + + # Temyiz Kurulu specific parameters (ignored for other types) + ilam_dairesi: Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8"] = Field("ALL", description="Audit chamber selection (temyiz_kurulu only)"), + yili: str = Field("", description="Year (YYYY format, temyiz_kurulu only)"), + dosya_no: str = Field("", description="File number (temyiz_kurulu only)"), + temyiz_tutanak_no: str = Field("", description="Appeals board meeting minutes number (temyiz_kurulu only)"), + temyiz_karar: str = Field("", description="Appeals decision text search (temyiz_kurulu only)"), + + # Daire specific parameters (ignored for other types) + yargilama_dairesi: Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8"] = Field("ALL", description="Chamber selection (daire only)"), + hesap_yili: str = Field("", description="Account year (daire only)"), + web_karar_metni: str = Field("", description="Decision text search (daire only)") +) -> SayistayUnifiedSearchResult: + """Search Sayıştay decisions across all three decision types with unified interface.""" + logger.info(f"Tool 'search_sayistay_unified' called with decision_type={decision_type}") + + try: + search_request = SayistayUnifiedSearchRequest( + decision_type=decision_type, + start=start, + length=length, + karar_tarih_baslangic=karar_tarih_baslangic, + karar_tarih_bitis=karar_tarih_bitis, + kamu_idaresi_turu=kamu_idaresi_turu, + ilam_no=ilam_no, + web_karar_konusu=web_karar_konusu, + karar_no=karar_no, + karar_ek=karar_ek, + karar_tamami=karar_tamami, + ilam_dairesi=ilam_dairesi, + yili=yili, + dosya_no=dosya_no, + temyiz_tutanak_no=temyiz_tutanak_no, + temyiz_karar=temyiz_karar, + yargilama_dairesi=yargilama_dairesi, + hesap_yili=hesap_yili, + web_karar_metni=web_karar_metni + ) + return await sayistay_unified_client_instance.search_unified(search_request) + except Exception as e: + logger.exception("Error in tool 'search_sayistay_unified'") + raise + +@app.tool( + description="Get Sayıştay decision document in Markdown format for any decision type", + annotations={ + "readOnlyHint": True, + "openWorldHint": False, + "idempotentHint": True + } +) +async def get_sayistay_document_unified( + decision_id: str = Field(..., description="Decision ID from search_sayistay_unified results"), + decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Decision type: genel_kurul, temyiz_kurulu, or daire") +) -> SayistayUnifiedDocumentMarkdown: + """Get Sayıştay decision document as Markdown for any decision type.""" + logger.info(f"Tool 'get_sayistay_document_unified' called for ID: {decision_id}, type: {decision_type}") + + if not decision_id or not decision_id.strip(): + raise ValueError("Decision ID must be a non-empty string.") + + try: + return await sayistay_unified_client_instance.get_document_unified(decision_id, decision_type) + except Exception as e: + logger.exception("Error in tool 'get_sayistay_document_unified'") + raise + +# --- Application Shutdown Handling --- +def perform_cleanup(): + logger.info("MCP Server performing cleanup...") + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + clients_to_close = [ + globals().get('yargitay_client_instance'), + globals().get('danistay_client_instance'), + globals().get('emsal_client_instance'), + globals().get('uyusmazlik_client_instance'), + globals().get('anayasa_norm_client_instance'), + globals().get('anayasa_bireysel_client_instance'), + globals().get('anayasa_unified_client_instance'), + globals().get('kik_client_instance'), + globals().get('rekabet_client_instance'), + globals().get('bedesten_client_instance'), + globals().get('sayistay_client_instance'), + globals().get('sayistay_unified_client_instance'), + globals().get('kvkk_client_instance'), + globals().get('bddk_client_instance') + ] + async def close_all_clients_async(): + tasks = [] + for client_instance in clients_to_close: + if client_instance and hasattr(client_instance, 'close_client_session') and callable(client_instance.close_client_session): + logger.info(f"Scheduling close for client session: {client_instance.__class__.__name__}") + tasks.append(client_instance.close_client_session()) + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for i, result in enumerate(results): + if isinstance(result, Exception): + client_name = "Unknown Client" + if i < len(clients_to_close) and clients_to_close[i] is not None: + client_name = clients_to_close[i].__class__.__name__ + logger.error(f"Error closing client {client_name}: {result}") + try: + if loop.is_running(): + asyncio.ensure_future(close_all_clients_async(), loop=loop) + logger.info("Client cleanup tasks scheduled on running event loop.") + else: + loop.run_until_complete(close_all_clients_async()) + logger.info("Client cleanup tasks completed via run_until_complete.") + except Exception as e: + logger.error(f"Error during atexit cleanup execution: {e}", exc_info=True) + logger.info("MCP Server atexit cleanup process finished.") + +atexit.register(perform_cleanup) + +# --- Health Check Tools --- +@app.tool( + description="Check if Turkish government legal database servers are operational", + annotations={ + "readOnlyHint": True, + "idempotentHint": True + } +) +async def check_government_servers_health() -> Dict[str, Any]: + """Check health status of Turkish government legal database servers.""" + logger.info("Health check tool called for government servers") + + health_results = {} + + # Check Yargıtay server + try: + yargitay_payload = { + "data": { + "aranan": "karar", + "arananKelime": "karar", + "pageSize": 10, + "pageNumber": 1 + } + } + + async with httpx.AsyncClient( + headers={ + "Accept": "*/*", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "Connection": "keep-alive", + "Content-Type": "application/json; charset=UTF-8", + "Origin": "https://karararama.yargitay.gov.tr", + "Referer": "https://karararama.yargitay.gov.tr/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + "X-Requested-With": "XMLHttpRequest" + }, + timeout=30.0, + verify=False + ) as client: + response = await client.post( + "https://karararama.yargitay.gov.tr/aramalist", + json=yargitay_payload + ) + + if response.status_code == 200: + response_data = response.json() + records_total = response_data.get("data", {}).get("recordsTotal", 0) + + if records_total > 0: + health_results["yargitay"] = { + "status": "healthy", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + else: + health_results["yargitay"] = { + "status": "unhealthy", + "reason": "recordsTotal is 0 or missing", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + else: + health_results["yargitay"] = { + "status": "unhealthy", + "reason": f"HTTP {response.status_code}", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + + except Exception as e: + health_results["yargitay"] = { + "status": "unhealthy", + "reason": f"Connection error: {str(e)}" + } + + # Check Bedesten API server + try: + bedesten_payload = { + "data": { + "pageSize": 5, + "pageNumber": 1, + "itemTypeList": ["YARGITAYKARARI"], + "phrase": "karar", + "sortFields": ["KARAR_TARIHI"], + "sortDirection": "desc" + }, + "applicationName": "UyapMevzuat", + "paging": True + } + + async with httpx.AsyncClient( + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 Health Check" + }, + timeout=30.0, + verify=False + ) as client: + response = await client.post( + "https://bedesten.adalet.gov.tr/emsal-karar/searchDocuments", + json=bedesten_payload + ) + + if response.status_code == 200: + response_data = response.json() + logger.debug(f"Bedesten API response: {response_data}") + if response_data and isinstance(response_data, dict): + data_section = response_data.get("data") + if data_section and isinstance(data_section, dict): + total_found = data_section.get("total", 0) + else: + total_found = 0 + else: + total_found = 0 + + if total_found > 0: + health_results["bedesten"] = { + "status": "healthy", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + else: + health_results["bedesten"] = { + "status": "unhealthy", + "reason": "total is 0 or missing in data field", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + else: + health_results["bedesten"] = { + "status": "unhealthy", + "reason": f"HTTP {response.status_code}", + "response_time_ms": response.elapsed.total_seconds() * 1000 + } + + except Exception as e: + health_results["bedesten"] = { + "status": "unhealthy", + "reason": f"Connection error: {str(e)}" + } + + # Overall health assessment + healthy_servers = sum(1 for server in health_results.values() if server["status"] == "healthy") + total_servers = len(health_results) + + overall_status = "healthy" if healthy_servers == total_servers else "degraded" if healthy_servers > 0 else "unhealthy" + + return { + "overall_status": overall_status, + "healthy_servers": healthy_servers, + "total_servers": total_servers, + "servers": health_results, + "check_timestamp": f"{__import__('datetime').datetime.now().isoformat()}" + } + +# --- MCP Tools for KVKK --- +@app.tool( + description="Search KVKK data protection authority decisions", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_kvkk_decisions( + keywords: str = Field(..., description="Turkish keywords. Supports +required -excluded \"exact phrase\" operators"), + page: int = Field(1, ge=1, le=50, description="Page number for results (1-50)."), + # pageSize: int = Field(10, ge=1, le=20, description="Number of results per page (1-20).") +) -> KvkkSearchResult: + """Search function for legal decisions.""" + logger.info(f"KVKK search tool called with keywords: {keywords}") + + pageSize = 10 # Default value + + search_request = KvkkSearchRequest( + keywords=keywords, + page=page, + pageSize=pageSize + ) + + try: + result = await kvkk_client_instance.search_decisions(search_request) + logger.info(f"KVKK search completed. Found {len(result.decisions)} decisions on page {page}") + return result + except Exception as e: + logger.exception(f"Error in KVKK search: {e}") + # Return empty result on error + return KvkkSearchResult( + decisions=[], + total_results=0, + page=page, + pageSize=pageSize, + query=keywords + ) + +@app.tool( + description="Get KVKK decision document in Markdown format with metadata extraction", + annotations={ + "readOnlyHint": True, + "openWorldHint": False, + "idempotentHint": True + } +) +async def get_kvkk_document_markdown( + decision_url: str = Field(..., description="KVKK decision URL from search results"), + page_number: int = Field(1, ge=1, description="Page number for paginated Markdown content (1-indexed, accepts int). Default is 1 (first 5,000 characters).") +) -> KvkkDocumentMarkdown: + """Get KVKK decision as paginated Markdown.""" + logger.info(f"KVKK document retrieval tool called for URL: {decision_url}") + + if not decision_url or not decision_url.strip(): + return KvkkDocumentMarkdown( + source_url=HttpUrl("https://www.kvkk.gov.tr"), + title=None, + decision_date=None, + decision_number=None, + subject_summary=None, + markdown_chunk=None, + current_page=page_number or 1, + total_pages=0, + is_paginated=False, + error_message="Decision URL is required and cannot be empty." + ) + + try: + # Validate URL format + if not decision_url.startswith("https://www.kvkk.gov.tr/"): + return KvkkDocumentMarkdown( + source_url=HttpUrl(decision_url), + title=None, + decision_date=None, + decision_number=None, + subject_summary=None, + markdown_chunk=None, + current_page=page_number or 1, + total_pages=0, + is_paginated=False, + error_message="Invalid KVKK decision URL format. URL must start with https://www.kvkk.gov.tr/" + ) + + result = await kvkk_client_instance.get_decision_document(decision_url, page_number or 1) + logger.info(f"KVKK document retrieved successfully. Page {result.current_page}/{result.total_pages}, Content length: {len(result.markdown_chunk) if result.markdown_chunk else 0}") + return result + + except Exception as e: + logger.exception(f"Error retrieving KVKK document: {e}") + return KvkkDocumentMarkdown( + source_url=HttpUrl(decision_url), + title=None, + decision_date=None, + decision_number=None, + subject_summary=None, + markdown_chunk=None, + current_page=page_number or 1, + total_pages=0, + is_paginated=False, + error_message=f"Error retrieving KVKK document: {str(e)}" + ) + +# --- MCP Tools for BDDK (Banking Regulation Authority) --- +@app.tool( + description="Search BDDK banking regulation decisions", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search_bddk_decisions( + keywords: str = Field(..., description="Search keywords in Turkish"), + page: int = Field(1, ge=1, description="Page number") + # pageSize: int = Field(10, ge=1, le=50, description="Results per page") +) -> dict: + """Search BDDK banking regulation and supervision decisions.""" + logger.info(f"BDDK search tool called with keywords: {keywords}, page: {page}") + + pageSize = 10 # Default value + + try: + search_request = BddkSearchRequest( + keywords=keywords, + page=page, + pageSize=pageSize + ) + + result = await bddk_client_instance.search_decisions(search_request) + logger.info(f"BDDK search completed. Found {len(result.decisions)} decisions on page {page}") + + return { + "decisions": [ + { + "title": dec.title, + "document_id": dec.document_id, + "content": dec.content + } + for dec in result.decisions + ], + "total_results": result.total_results, + "page": result.page, + "pageSize": result.pageSize + } + + except Exception as e: + logger.exception(f"Error searching BDDK decisions: {e}") + return { + "decisions": [], + "total_results": 0, + "page": page, + "pageSize": pageSize, + "error": str(e) + } + +@app.tool( + description="Get BDDK decision document as Markdown", + annotations={ + "readOnlyHint": True, + "openWorldHint": False, + "idempotentHint": True + } +) +async def get_bddk_document_markdown( + document_id: str = Field(..., description="BDDK document ID (e.g., '310')"), + page_number: int = Field(1, ge=1, description="Page number") +) -> dict: + """Retrieve BDDK decision document in Markdown format.""" + logger.info(f"BDDK document retrieval tool called for ID: {document_id}, page: {page_number}") + + if not document_id or not document_id.strip(): + return { + "document_id": document_id, + "markdown_content": "", + "page_number": page_number, + "total_pages": 0, + "error": "Document ID is required" + } + + try: + result = await bddk_client_instance.get_document_markdown(document_id, page_number) + logger.info(f"BDDK document retrieved successfully. Page {result.page_number}/{result.total_pages}") + + return { + "document_id": result.document_id, + "markdown_content": result.markdown_content, + "page_number": result.page_number, + "total_pages": result.total_pages + } + + except Exception as e: + logger.exception(f"Error retrieving BDDK document: {e}") + return { + "document_id": document_id, + "markdown_content": "", + "page_number": page_number, + "total_pages": 0, + "error": str(e) + } + +# --- ChatGPT Deep Research Compatible Tools --- + +def get_preview_text(markdown_content: str, skip_chars: int = 100, preview_chars: int = 200) -> str: + """ + Extract a preview of document text by skipping headers and showing meaningful content. + + Args: + markdown_content: Full document content in markdown format + skip_chars: Number of characters to skip from the beginning (default: 100) + preview_chars: Number of characters to show in preview (default: 200) + + Returns: + Preview text suitable for ChatGPT Deep Research + """ + if not markdown_content: + return "" + + # Remove common markdown artifacts and clean up + cleaned_content = markdown_content.strip() + + # Skip the first N characters (usually headers, metadata) + if len(cleaned_content) > skip_chars: + content_start = cleaned_content[skip_chars:] + else: + content_start = cleaned_content + + # Get the next N characters for preview + if len(content_start) > preview_chars: + preview = content_start[:preview_chars] + else: + preview = content_start + + # Clean up the preview - remove incomplete sentences at the end + preview = preview.strip() + + # If preview ends mid-sentence, try to end at last complete sentence + if preview and not preview.endswith('.'): + last_period = preview.rfind('.') + if last_period > 50: # Only if there's a reasonable sentence + preview = preview[:last_period + 1] + + # Add ellipsis if content was truncated + if len(content_start) > preview_chars: + preview += "..." + + return preview.strip() + +@app.tool( + description="DO NOT USE unless you are ChatGPT Deep Research. Search Turkish courts (Turkish keywords only). Supports: +term (must have), -term (exclude), \"exact phrase\", term1 OR term2", + annotations={ + "readOnlyHint": True, + "openWorldHint": True, + "idempotentHint": True + } +) +async def search( + query: str = Field(..., description="Turkish search query") +) -> Dict[str, List[Dict[str, str]]]: + """ + Bedesten API search tool for ChatGPT Deep Research compatibility. + + This tool searches Turkish legal databases via the unified Bedesten API. + It supports advanced search operators and covers all major court types. + + USAGE RESTRICTION: Only for ChatGPT Deep Research workflows. + For regular legal research, use search_bedesten_unified with specific court types. + + Returns: + Object with "results" field containing a list of documents with id, title, text preview, and url + as required by ChatGPT Deep Research specification. + """ + logger.info(f"ChatGPT Deep Research search tool called with query: {query}") + + results = [] + + try: + # Search all court types via unified Bedesten API + court_types = [ + ("YARGITAYKARARI", "Yargıtay", "yargitay_bedesten"), + ("DANISTAYKARAR", "Danıştay", "danistay_bedesten"), + ("YERELHUKUK", "Yerel Hukuk Mahkemesi", "yerel_hukuk_bedesten"), + ("ISTINAFHUKUK", "İstinaf Hukuk Mahkemesi", "istinaf_hukuk_bedesten"), + ("KYB", "Kanun Yararına Bozma", "kyb_bedesten") + ] + + for item_type, court_name, id_prefix in court_types: + try: + search_results = await bedesten_client_instance.search_documents( + BedestenSearchRequest( + data=BedestenSearchData( + phrase=query, # Use query as-is to support both regular and exact phrase searches + itemTypeList=[item_type], + pageSize=10, + pageNumber=1 + ) + ) + ) + + # Handle potential None data + if search_results.data is None: + logger.warning(f"No data returned from Bedesten API for {court_name}") + continue + + # Add results from this court type (limit to top 5 per court) + for decision in search_results.data.emsalKararList[:5]: + # For ChatGPT Deep Research, fetch document content for preview + try: + # Fetch document content for preview + doc = await bedesten_client_instance.get_document_as_markdown(decision.documentId) + + # Generate preview text (skip first 100 chars, show next 200) + preview_text = get_preview_text(doc.markdown_content, skip_chars=100, preview_chars=200) + + # Build title from metadata + title_parts = [] + if decision.birimAdi: + title_parts.append(decision.birimAdi) + if decision.esasNo: + title_parts.append(f"Esas: {decision.esasNo}") + if decision.kararNo: + title_parts.append(f"Karar: {decision.kararNo}") + if decision.kararTarihiStr: + title_parts.append(f"Tarih: {decision.kararTarihiStr}") + + if title_parts: + title = " - ".join(title_parts) + else: + title = f"{court_name} - Document {decision.documentId}" + + # Add to results in OpenAI format + results.append({ + "id": decision.documentId, + "title": title, + "text": preview_text, + "url": f"https://mevzuat.adalet.gov.tr/ictihat/{decision.documentId}" + }) + + except Exception as e: + logger.warning(f"Could not fetch preview for document {decision.documentId}: {e}") + # Add minimal result without preview + results.append({ + "id": decision.documentId, + "title": f"{court_name} - Document {decision.documentId}", + "text": "Document preview not available", + "url": f"https://mevzuat.adalet.gov.tr/ictihat/{decision.documentId}" + }) + + if search_results.data: + logger.info(f"Found {len(search_results.data.emsalKararList)} results from {court_name}") + else: + logger.info(f"Found 0 results from {court_name} (no data returned)") + + except Exception as e: + logger.warning(f"Bedesten API search error for {court_name}: {e}") + + # Comment out other API implementations for ChatGPT Deep Research + """ + # Other API implementations disabled for ChatGPT Deep Research + # These are available through specific court tools: + + # Yargıtay Official API - use search_yargitay_detailed instead + # Danıştay Official API - use search_danistay_by_keyword instead + # Constitutional Court - use search_anayasa_norm_denetimi_decisions instead + # Competition Authority - use search_rekabet_kurumu_decisions instead + # Public Procurement Authority - use search_kik_decisions instead + # Court of Accounts - use search_sayistay_* tools instead + # UYAP Emsal - use search_emsal_detailed_decisions instead + # Jurisdictional Disputes Court - use search_uyusmazlik_decisions instead + """ + + logger.info(f"ChatGPT Deep Research search completed. Found {len(results)} results via Bedesten API.") + return {"results": results} + + except Exception as e: + logger.exception("Error in ChatGPT Deep Research search tool") + # Return partial results if any were found + if results: + return {"results": results} + raise + +@app.tool( + description="DO NOT USE unless you are ChatGPT Deep Research. Fetch document by ID. See docs for details", + annotations={ + "readOnlyHint": True, + "openWorldHint": False, # Retrieves specific documents, not exploring + "idempotentHint": True + } +) +async def fetch( + id: str = Field(..., description="Document identifier from search results (numeric only)") +) -> Dict[str, Any]: + """ + Bedesten API fetch tool for ChatGPT Deep Research compatibility. + + Retrieves the full text content of Turkish legal documents via unified Bedesten API. + Converts documents from HTML/PDF to clean Markdown format. + + USAGE RESTRICTION: Only for ChatGPT Deep Research workflows. + For regular legal research, use specific court document tools. + + Input Format: + - id: Numeric document identifier from search results (e.g., "730113500", "71370900") + + Returns: + Single object with numeric id, title, text (full Markdown content), mevzuat.adalet.gov.tr url, and metadata fields + as required by ChatGPT Deep Research specification. + """ + logger.info(f"ChatGPT Deep Research fetch tool called for document ID: {id}") + + if not id or not id.strip(): + raise ValueError("Document ID must be a non-empty string") + + try: + # Use the numeric ID directly with Bedesten API + doc = await bedesten_client_instance.get_document_as_markdown(id) + + # Try to get additional metadata by searching for this specific document + title = f"Turkish Legal Document {id}" + try: + # Quick search to get metadata for better title + search_results = await bedesten_client_instance.search_documents( + BedestenSearchRequest( + data=BedestenSearchData( + phrase=id, # Search by document ID + pageSize=1, + pageNumber=1 + ) + ) + ) + + if search_results.data and search_results.data.emsalKararList: + decision = search_results.data.emsalKararList[0] + if decision.documentId == id: + # Build a proper title from metadata + title_parts = [] + if decision.birimAdi: + title_parts.append(decision.birimAdi) + if decision.esasNo: + title_parts.append(f"Esas: {decision.esasNo}") + if decision.kararNo: + title_parts.append(f"Karar: {decision.kararNo}") + if decision.kararTarihiStr: + title_parts.append(f"Tarih: {decision.kararTarihiStr}") + + if title_parts: + title = " - ".join(title_parts) + else: + title = f"Turkish Legal Decision {id}" + except Exception as e: + logger.warning(f"Could not fetch metadata for document {id}: {e}") + + return { + "id": id, + "title": title, + "text": doc.markdown_content, + "url": f"https://mevzuat.adalet.gov.tr/ictihat/{id}", + "metadata": { + "database": "Turkish Legal Database via Bedesten API", + "document_id": id, + "source_url": doc.source_url, + "mime_type": doc.mime_type, + "api_source": "Bedesten Unified API", + "chatgpt_deep_research": True + } + } + + # Comment out other API implementations for ChatGPT Deep Research + """ + # Other API implementations disabled for ChatGPT Deep Research + # These are available through specific court document tools: + + elif id.startswith("yargitay_"): + # Yargıtay Official API - use get_yargitay_document_markdown instead + doc_id = id.replace("yargitay_", "") + doc = await yargitay_client_instance.get_decision_document_as_markdown(doc_id) + + elif id.startswith("danistay_"): + # Danıştay Official API - use get_danistay_document_markdown instead + doc_id = id.replace("danistay_", "") + doc = await danistay_client_instance.get_decision_document_as_markdown(doc_id) + + elif id.startswith("anayasa_"): + # Constitutional Court - use get_anayasa_norm_denetimi_document_markdown instead + doc_id = id.replace("anayasa_", "") + doc = await anayasa_norm_client_instance.get_decision_document_as_markdown(...) + + elif id.startswith("rekabet_"): + # Competition Authority - use get_rekabet_kurumu_document instead + doc_id = id.replace("rekabet_", "") + doc = await rekabet_client_instance.get_decision_document(...) + + elif id.startswith("kik_"): + # Public Procurement Authority - use get_kik_decision_document_as_markdown instead + doc_id = id.replace("kik_", "") + doc = await kik_client_instance.get_decision_document_as_markdown(doc_id) + + elif id.startswith("local_"): + # This was already using Bedesten API, but deprecated for ChatGPT Deep Research + doc_id = id.replace("local_", "") + doc = await bedesten_client_instance.get_document_as_markdown(doc_id) + """ + + except Exception as e: + logger.exception(f"Error fetching ChatGPT Deep Research document {id}") + raise + +# --- Token Metrics Tool Removed for Optimization --- + +def ensure_playwright_browsers(): + """Ensure Playwright browsers are installed for KIK tool functionality.""" + try: + import subprocess + import os + + # Check if chromium is already installed + chromium_path = os.path.expanduser("~/Library/Caches/ms-playwright/chromium-1179") + if os.path.exists(chromium_path): + logger.info("Playwright Chromium browser already installed.") + return + + logger.info("Installing Playwright Chromium browser for KIK tool...") + result = subprocess.run( + ["python", "-m", "playwright", "install", "chromium"], + capture_output=True, + text=True, + timeout=300 # 5 minutes timeout + ) + + if result.returncode == 0: + logger.info("Playwright Chromium browser installed successfully.") + else: + logger.warning(f"Failed to install Playwright browser: {result.stderr}") + logger.warning("KIK tool may not work properly without Playwright browsers.") + + except Exception as e: + logger.warning(f"Could not auto-install Playwright browsers: {e}") + logger.warning("KIK tool may not work properly. Manual installation: 'playwright install chromium'") + +def main(): + logger.info(f"Starting {app.name} server via main() function...") + logger.info(f"Logs will be written to: {LOG_FILE_PATH}") + + # Ensure Playwright browsers are installed + ensure_playwright_browsers() + + try: + app.run() + except KeyboardInterrupt: + logger.info("Server shut down by user (KeyboardInterrupt).") + except Exception as e: + logger.exception("Server failed to start or crashed.") + finally: + logger.info(f"{app.name} server has shut down.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/nginx.conf b/saidsurucu-yargi-mcp-f5fa007/nginx.conf new file mode 100644 index 0000000..5cb39b2 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/nginx.conf @@ -0,0 +1,94 @@ +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/saidsurucu-yargi-mcp-f5fa007/ornek.png b/saidsurucu-yargi-mcp-f5fa007/ornek.png new file mode 100644 index 0000000..012d2ef Binary files /dev/null and b/saidsurucu-yargi-mcp-f5fa007/ornek.png differ diff --git a/saidsurucu-yargi-mcp-f5fa007/pyproject.toml b/saidsurucu-yargi-mcp-f5fa007/pyproject.toml new file mode 100644 index 0000000..e90bea6 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "yargi-mcp" +version = "0.1.6" +description = "MCP Server For Turkish Legal Databases" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [{name = "Said Surucu", email = "saidsrc@gmail.com"}] +keywords = ["mcp", "turkish-law", "legal", "yargitay", "danistay", "bddk", "kvkk", "turkish", "law", "court", "decisions"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Legal Industry", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Text Processing :: Markup :: Markdown", + "Operating System :: OS Independent", +] +urls = {Homepage = "https://github.com/saidsurucu/yargi-mcp", Issues = "https://github.com/saidsurucu/yargi-mcp/issues"} +dependencies = [ + "beautifulsoup4>=4.13.4", + "httpx>=0.28.1", + "markitdown[pdf]>=0.1.1", + "pydantic>=2.11.4", + "aiohttp>=3.11.18", + "playwright>=1.52.0", + "fastmcp>=2.10.5", + "pypdf>=5.5.0", + "fastapi>=0.115.14", + "PyJWT>=2.8.0", + "tiktoken>=0.5.0", +] + +[project.optional-dependencies] +asgi = [ + "uvicorn[standard]>=0.30.0", + "starlette>=0.37.0", +] +api = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", +] +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", +] + +[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"] + +[tool.setuptools.packages.find] +include = ["*_mcp_module", "mcp_auth"] + +[build-system] +requires = ["setuptools>=65.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/saidsurucu-yargi-mcp-f5fa007/railway.json b/saidsurucu-yargi-mcp-f5fa007/railway.json new file mode 100644 index 0000000..0c54927 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/railway.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS", + "buildCommand": "pip install -e .[asgi]" + }, + "deploy": { + "startCommand": "uvicorn asgi_app:app --host 0.0.0.0 --port $PORT", + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + }, + "variables": { + "ALLOWED_ORIGINS": "*", + "LOG_LEVEL": "info" + } +} \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/redis_session_store.py b/saidsurucu-yargi-mcp-f5fa007/redis_session_store.py new file mode 100644 index 0000000..0eb1142 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/redis_session_store.py @@ -0,0 +1,464 @@ +""" +Redis Session Store for OAuth Authorization Codes and User Sessions + +This module provides Redis-based storage for OAuth authorization codes and user sessions, +enabling multi-machine deployment support by replacing in-memory storage. + +Uses Upstash Redis via REST API for serverless-friendly operation. +""" + +import os +import json +import time +import logging +from typing import Optional, Dict, Any, Union +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +try: + from upstash_redis import Redis + UPSTASH_AVAILABLE = True +except ImportError: + UPSTASH_AVAILABLE = False + Redis = None + +# Use standard Python exceptions for Redis connection errors +import socket +from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout as RequestsTimeout + +class RedisSessionStore: + """ + Redis-based session store for OAuth flows and user sessions. + + Uses Upstash Redis REST API for connection-free operation suitable for + multi-instance deployments on platforms like Fly.io. + """ + + def __init__(self): + """Initialize Redis connection using environment variables.""" + if not UPSTASH_AVAILABLE: + raise ImportError("upstash-redis package is required. Install with: pip install upstash-redis") + + # Initialize Upstash Redis client from environment with optimized connection settings + try: + # Get Upstash Redis configuration + redis_url = os.getenv("UPSTASH_REDIS_REST_URL") + redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN") + + if not redis_url or not redis_token: + raise ValueError("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be set") + + logger.info(f"Connecting to Upstash Redis at {redis_url[:30]}...") + + # Initialize with explicit configuration for better SSL handling + self.redis = Redis( + url=redis_url, + token=redis_token + ) + + logger.info("Upstash Redis client created") + + # Skip connection test during initialization to prevent server hang + # Connection will be tested during first actual operation + logger.info("Redis client initialized - connection will be tested on first use") + + except Exception as e: + logger.error(f"Failed to initialize Upstash Redis: {e}") + raise + + # TTL values (in seconds) + self.oauth_code_ttl = int(os.getenv("OAUTH_CODE_TTL", "600")) # 10 minutes + self.session_ttl = int(os.getenv("SESSION_TTL", "3600")) # 1 hour + + def _serialize_data(self, data: Dict[str, Any]) -> Dict[str, str]: + """Convert data to Redis-compatible string format.""" + serialized = {} + for key, value in data.items(): + if isinstance(value, (dict, list)): + serialized[key] = json.dumps(value) + elif isinstance(value, (int, float)): + serialized[key] = str(value) + elif isinstance(value, bool): + serialized[key] = "true" if value else "false" + else: + serialized[key] = str(value) + return serialized + + def _deserialize_data(self, data: Dict[str, str]) -> Dict[str, Any]: + """Convert Redis string data back to original types.""" + if not data: + return {} + + deserialized = {} + for key, value in data.items(): + if not isinstance(value, str): + deserialized[key] = value + continue + + # Try to deserialize JSON + if value.startswith(('[', '{')): + try: + deserialized[key] = json.loads(value) + continue + except json.JSONDecodeError: + pass + + # Try to convert numbers + if value.isdigit(): + deserialized[key] = int(value) + continue + + if value.replace('.', '').isdigit(): + try: + deserialized[key] = float(value) + continue + except ValueError: + pass + + # Handle booleans + if value in ("true", "false"): + deserialized[key] = value == "true" + continue + + # Keep as string + deserialized[key] = value + + return deserialized + + # OAuth Authorization Code Methods + + def set_oauth_code(self, code: str, data: Dict[str, Any]) -> bool: + """ + Store OAuth authorization code with automatic expiration. + + Args: + code: Authorization code string + data: Code data including user_id, client_id, etc. + + Returns: + True if stored successfully, False otherwise + """ + try: + key = f"oauth:code:{code}" + + # Add timestamp for debugging + data_with_timestamp = data.copy() + data_with_timestamp.update({ + "created_at": time.time(), + "expires_at": time.time() + self.oauth_code_ttl + }) + + # Serialize and store - Upstash Redis doesn't support mapping parameter + serialized_data = self._serialize_data(data_with_timestamp) + + # Use individual hset calls for each field with retry logic + max_retries = 3 + for attempt in range(max_retries): + try: + # Clear any existing data first + self.redis.delete(key) + + # Set all fields in a pipeline-like manner + for field, value in serialized_data.items(): + self.redis.hset(key, field, value) + + # Set expiration + self.redis.expire(key, self.oauth_code_ttl) + + logger.info(f"Stored OAuth code {code[:10]}... with TTL {self.oauth_code_ttl}s (attempt {attempt + 1})") + return True + + except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e: + logger.warning(f"Redis connection error on attempt {attempt + 1}: {e}") + if attempt == max_retries - 1: + raise # Re-raise on final attempt + time.sleep(0.5 * (attempt + 1)) # Exponential backoff + + except Exception as e: + logger.error(f"Failed to store OAuth code {code[:10]}... after {max_retries} attempts: {e}") + return False + + def get_oauth_code(self, code: str, delete_after_use: bool = True) -> Optional[Dict[str, Any]]: + """ + Retrieve OAuth authorization code data. + + Args: + code: Authorization code string + delete_after_use: If True, delete the code after retrieval (one-time use) + + Returns: + Code data dictionary or None if not found/expired + """ + max_retries = 3 + for attempt in range(max_retries): + try: + key = f"oauth:code:{code}" + + # Get all hash fields with retry + data = self.redis.hgetall(key) + + if not data: + logger.warning(f"OAuth code {code[:10]}... not found or expired (attempt {attempt + 1})") + return None + + # Deserialize data + deserialized_data = self._deserialize_data(data) + + # Check manual expiration (in case Redis TTL failed) + expires_at = deserialized_data.get("expires_at", 0) + if expires_at and time.time() > expires_at: + logger.warning(f"OAuth code {code[:10]}... manually expired") + try: + self.redis.delete(key) + except Exception as del_error: + logger.warning(f"Failed to delete expired code: {del_error}") + return None + + # Delete after use for security (one-time use) + if delete_after_use: + try: + self.redis.delete(key) + logger.info(f"Retrieved and deleted OAuth code {code[:10]}... (attempt {attempt + 1})") + except Exception as del_error: + logger.warning(f"Failed to delete code after use: {del_error}") + # Continue anyway since we got the data + else: + logger.info(f"Retrieved OAuth code {code[:10]}... (not deleted, attempt {attempt + 1})") + + return deserialized_data + + except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e: + logger.warning(f"Redis connection error on retrieval attempt {attempt + 1}: {e}") + if attempt == max_retries - 1: + logger.error(f"Failed to retrieve OAuth code {code[:10]}... after {max_retries} attempts: {e}") + return None + time.sleep(0.5 * (attempt + 1)) # Exponential backoff + + except Exception as e: + logger.error(f"Failed to retrieve OAuth code {code[:10]}... on attempt {attempt + 1}: {e}") + if attempt == max_retries - 1: + return None + time.sleep(0.5 * (attempt + 1)) + + return None + + # User Session Methods + + def set_session(self, session_id: str, user_data: Dict[str, Any]) -> bool: + """ + Store user session data with sliding expiration. + + Args: + session_id: Unique session identifier + user_data: User session data (user_id, email, scopes, etc.) + + Returns: + True if stored successfully, False otherwise + """ + try: + key = f"session:{session_id}" + + # Add session metadata + session_data = user_data.copy() + session_data.update({ + "session_id": session_id, + "created_at": time.time(), + "last_accessed": time.time() + }) + + # Serialize and store - Upstash Redis doesn't support mapping parameter + serialized_data = self._serialize_data(session_data) + + # Use individual hset calls for each field (Upstash compatibility) + for field, value in serialized_data.items(): + self.redis.hset(key, field, value) + self.redis.expire(key, self.session_ttl) + + logger.info(f"Stored session {session_id[:10]}... with TTL {self.session_ttl}s") + return True + + except Exception as e: + logger.error(f"Failed to store session {session_id[:10]}...: {e}") + return False + + def get_session(self, session_id: str, refresh_ttl: bool = True) -> Optional[Dict[str, Any]]: + """ + Retrieve user session data. + + Args: + session_id: Session identifier + refresh_ttl: If True, extend session TTL on access + + Returns: + Session data dictionary or None if not found/expired + """ + try: + key = f"session:{session_id}" + + # Get session data + data = self.redis.hgetall(key) + + if not data: + logger.warning(f"Session {session_id[:10]}... not found or expired") + return None + + # Deserialize data + session_data = self._deserialize_data(data) + + # Update last accessed time and refresh TTL + if refresh_ttl: + session_data["last_accessed"] = time.time() + self.redis.hset(key, "last_accessed", str(time.time())) + self.redis.expire(key, self.session_ttl) + logger.debug(f"Refreshed session {session_id[:10]}... TTL") + + return session_data + + except Exception as e: + logger.error(f"Failed to retrieve session {session_id[:10]}...: {e}") + return None + + def delete_session(self, session_id: str) -> bool: + """ + Delete user session (logout). + + Args: + session_id: Session identifier + + Returns: + True if deleted successfully, False otherwise + """ + try: + key = f"session:{session_id}" + result = self.redis.delete(key) + + if result: + logger.info(f"Deleted session {session_id[:10]}...") + return True + else: + logger.warning(f"Session {session_id[:10]}... not found for deletion") + return False + + except Exception as e: + logger.error(f"Failed to delete session {session_id[:10]}...: {e}") + return False + + # Health Check Methods + + def health_check(self) -> Dict[str, Any]: + """ + Perform Redis health check. + + Returns: + Health status dictionary + """ + try: + # Test basic operations + test_key = f"health:check:{int(time.time())}" + test_value = {"timestamp": time.time(), "test": True} + + # Test set - Use individual hset calls for Upstash compatibility + serialized_test = self._serialize_data(test_value) + for field, value in serialized_test.items(): + self.redis.hset(test_key, field, value) + + # Test get + retrieved = self.redis.hgetall(test_key) + + # Test delete + self.redis.delete(test_key) + + return { + "status": "healthy", + "redis_connected": True, + "operations_working": bool(retrieved), + "timestamp": datetime.utcnow().isoformat() + } + + except Exception as e: + logger.error(f"Redis health check failed: {e}") + return { + "status": "unhealthy", + "redis_connected": False, + "error": str(e), + "timestamp": datetime.utcnow().isoformat() + } + + def get_stats(self) -> Dict[str, Any]: + """ + Get Redis usage statistics. + + Returns: + Statistics dictionary + """ + try: + # Get basic info (not all Upstash plans support INFO command) + stats = { + "oauth_codes_pattern": "oauth:code:*", + "sessions_pattern": "session:*", + "timestamp": datetime.utcnow().isoformat() + } + + try: + # Try to get counts (may fail on some Upstash plans) + oauth_keys = self.redis.keys("oauth:code:*") + session_keys = self.redis.keys("session:*") + + stats.update({ + "active_oauth_codes": len(oauth_keys) if oauth_keys else 0, + "active_sessions": len(session_keys) if session_keys else 0 + }) + except Exception as e: + logger.warning(f"Could not get detailed stats: {e}") + stats["warning"] = "Detailed stats not available on this Redis plan" + + return stats + + except Exception as e: + logger.error(f"Failed to get Redis stats: {e}") + return {"error": str(e), "timestamp": datetime.utcnow().isoformat()} + +# Global instance for easy importing +redis_store = None + +def get_redis_store() -> Optional[RedisSessionStore]: + """ + Get global Redis store instance (singleton pattern). + + Returns: + RedisSessionStore instance or None if initialization fails + """ + global redis_store + + if redis_store is None: + try: + logger.info("Initializing Redis store...") + redis_store = RedisSessionStore() + logger.info("Redis store initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize Redis store: {e}") + redis_store = None + + return redis_store + +def init_redis_store() -> RedisSessionStore: + """ + Initialize Redis store and perform health check. + + Returns: + RedisSessionStore instance + + Raises: + Exception if Redis is not available or unhealthy + """ + store = get_redis_store() + + # Perform health check + health = store.health_check() + + if health["status"] != "healthy": + raise Exception(f"Redis health check failed: {health}") + + logger.info("Redis session store initialized and healthy") + return store \ No newline at end of file diff --git a/saidsurucu-yargi-mcp-f5fa007/rekabet_mcp_module/__init__.py b/saidsurucu-yargi-mcp-f5fa007/rekabet_mcp_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saidsurucu-yargi-mcp-f5fa007/rekabet_mcp_module/client.py b/saidsurucu-yargi-mcp-f5fa007/rekabet_mcp_module/client.py new file mode 100644 index 0000000..1b6e782 --- /dev/null +++ b/saidsurucu-yargi-mcp-f5fa007/rekabet_mcp_module/client.py @@ -0,0 +1,407 @@ +# rekabet_mcp_module/client.py + +import httpx +from bs4 import BeautifulSoup +from typing import List, Optional, Tuple, Dict, Any +import logging +import html +import re +import io # For io.BytesIO +from urllib.parse import urlencode, urljoin, quote, parse_qs, urlparse +from markitdown import MarkItDown +import math + +# pypdf for PDF processing (lighter alternative to PyMuPDF) +from pypdf import PdfReader, PdfWriter # PyPDF2'nin devamı niteliğindeki pypdf + +from .models import ( + RekabetKurumuSearchRequest, + RekabetDecisionSummary, + RekabetSearchResult, + RekabetDocument, + RekabetKararTuruGuidEnum +) +from pydantic import HttpUrl # Ensure HttpUrl is imported from pydantic + +logger = logging.getLogger(__name__) +if not logger.hasHandlers(): # Pragma: no cover + logging.basicConfig( + level=logging.INFO, # Varsayılan log seviyesi + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + # Debug betiğinde daha detaylı loglama için seviye ayrıca ayarlanabilir. + +class RekabetKurumuApiClient: + BASE_URL = "https://www.rekabet.gov.tr" + SEARCH_PATH = "/tr/Kararlar" + DECISION_LANDING_PATH_TEMPLATE = "/Karar" + # PDF sayfa bazlı Markdown döndürüldüğü için bu sabit artık doğrudan kullanılmıyor. + # DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 + + def __init__(self, request_timeout: float = 60.0): + self.http_client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=request_timeout, + verify=True, + follow_redirects=True + ) + + def _build_search_query_params(self, params: RekabetKurumuSearchRequest) -> List[Tuple[str, str]]: + query_params: List[Tuple[str, str]] = [] + query_params.append(("sayfaAdi", params.sayfaAdi if params.sayfaAdi is not None else "")) + query_params.append(("YayinlanmaTarihi", params.YayinlanmaTarihi if params.YayinlanmaTarihi is not None else "")) + query_params.append(("PdfText", params.PdfText if params.PdfText is not None else "")) + + karar_turu_id_value = "" + if params.KararTuruID is not None: + karar_turu_id_value = params.KararTuruID.value if params.KararTuruID.value != "ALL" else "" + query_params.append(("KararTuruID", karar_turu_id_value)) + + query_params.append(("KararSayisi", params.KararSayisi if params.KararSayisi is not None else "")) + query_params.append(("KararTarihi", params.KararTarihi if params.KararTarihi is not None else "")) + + if params.page and params.page > 1: + query_params.append(("page", str(params.page))) + + return query_params + + async def search_decisions(self, params: RekabetKurumuSearchRequest) -> RekabetSearchResult: + request_path = self.SEARCH_PATH + final_query_params = self._build_search_query_params(params) + logger.info(f"RekabetKurumuApiClient: Performing search. Path: {request_path}, Parameters: {final_query_params}") + + try: + response = await self.http_client.get(request_path, params=final_query_params) + response.raise_for_status() + html_content = response.text + except httpx.RequestError as e: + logger.error(f"RekabetKurumuApiClient: HTTP request error during search: {e}") + raise + + soup = BeautifulSoup(html_content, 'html.parser') + processed_decisions: List[RekabetDecisionSummary] = [] + total_records: Optional[int] = None + total_pages: Optional[int] = None + + pagination_div = soup.find("div", class_="yazi01") + if pagination_div: + text_content = pagination_div.get_text(separator=" ", strip=True) + total_match = re.search(r"Toplam\s*:\s*(\d+)", text_content) + if total_match: + try: + total_records = int(total_match.group(1)) + logger.debug(f"Total records found from pagination: {total_records}") + except ValueError: + logger.warning(f"Could not convert 'Toplam' value to int: {total_match.group(1)}") + else: + logger.warning("'Toplam :' string not found in pagination section.") + + results_per_page_assumed = 10 + if total_records is not None: + calculated_total_pages = math.ceil(total_records / results_per_page_assumed) + total_pages = calculated_total_pages if calculated_total_pages > 0 else (1 if total_records > 0 else 0) + logger.debug(f"Calculated total pages: {total_pages}") + + if total_pages is None: # Fallback if total_records couldn't be parsed + last_page_link = pagination_div.select_one("li.PagedList-skipToLast a") + if last_page_link and last_page_link.has_attr('href'): + qs = parse_qs(urlparse(last_page_link['href']).query) + if 'page' in qs and qs['page']: + try: + total_pages = int(qs['page'][0]) + logger.debug(f"Total pages found from 'Last >>' link: {total_pages}") + except ValueError: + logger.warning(f"Could not convert page value from 'Last >>' link to int: {qs['page'][0]}") + elif total_records == 0 : total_pages = 0 # If no records, 0 pages + elif total_records is not None and total_records > 0 : total_pages = 1 # If records exist but no last page link (e.g. single page) + else: logger.warning("'Last >>' link not found in pagination section.") + + decision_tables_container = soup.find("div", id="kararList") + if not decision_tables_container: + logger.warning("`div#kararList` (decision list container) not found. HTML structure might have changed or no decisions on this page.") + else: + decision_tables = decision_tables_container.find_all("table", class_="equalDivide") + logger.info(f"Found {len(decision_tables)} 'table' elements with class='equalDivide' for parsing.") + + if not decision_tables and total_records is not None and total_records > 0 : + logger.warning(f"Page indicates {total_records} records but no decision tables found with class='equalDivide'.") + + for idx, table in enumerate(decision_tables): + logger.debug(f"Processing table {idx + 1}...") + try: + rows = table.find_all("tr") + if len(rows) != 3: + logger.warning(f"Table {idx + 1} has an unexpected number of rows ({len(rows)} instead of 3). Skipping. HTML snippet:\n{table.prettify()[:500]}") + continue + + # Row 1: Publication Date, Decision Number, Related Cases Link + td_elements_r1 = rows[0].find_all("td") + pub_date = td_elements_r1[0].get_text(strip=True) if len(td_elements_r1) > 0 else None + dec_num = td_elements_r1[1].get_text(strip=True) if len(td_elements_r1) > 1 else None + + related_cases_link_tag = td_elements_r1[2].find("a", href=True) if len(td_elements_r1) > 2 else None + related_cases_url_str: Optional[str] = None + karar_id_from_related: Optional[str] = None + if related_cases_link_tag and related_cases_link_tag.has_attr('href'): + related_cases_url_str = urljoin(self.BASE_URL, related_cases_link_tag['href']) + qs_related = parse_qs(urlparse(related_cases_link_tag['href']).query) + if 'kararId' in qs_related and qs_related['kararId']: + karar_id_from_related = qs_related['kararId'][0] + + # Row 2: Decision Date, Decision Type + td_elements_r2 = rows[1].find_all("td") + dec_date = td_elements_r2[0].get_text(strip=True) if len(td_elements_r2) > 0 else None + dec_type_text = td_elements_r2[1].get_text(strip=True) if len(td_elements_r2) > 1 else None + + # Row 3: Title and Main Decision Link + title_cell = rows[2].find("td", colspan="5") + decision_link_tag = title_cell.find("a", href=True) if title_cell else None + + title_text: Optional[str] = None + decision_landing_url_str: Optional[str] = None + karar_id_from_main_link: Optional[str] = None + + if decision_link_tag and decision_link_tag.has_attr('href'): + title_text = decision_link_tag.get_text(strip=True) + href_val = decision_link_tag['href'] + if href_val.startswith(self.DECISION_LANDING_PATH_TEMPLATE + "?kararId="): # Ensure it's a decision link + decision_landing_url_str = urljoin(self.BASE_URL, href_val) + qs_main = parse_qs(urlparse(href_val).query) + if 'kararId' in qs_main and qs_main['kararId']: + karar_id_from_main_link = qs_main['kararId'][0] + else: + logger.warning(f"Table {idx+1} decision link has unexpected format: {href_val}") + else: + logger.warning(f"Table {idx+1} could not find title/decision link tag.") + + current_karar_id = karar_id_from_main_link or karar_id_from_related + + if not current_karar_id: + logger.warning(f"Table {idx+1} Karar ID not found. Skipping. Title (if any): {title_text}") + continue + + # Convert string URLs to HttpUrl for the model + final_decision_url = HttpUrl(decision_landing_url_str) if decision_landing_url_str else None + final_related_cases_url = HttpUrl(related_cases_url_str) if related_cases_url_str else None + + processed_decisions.append(RekabetDecisionSummary( + publication_date=pub_date, decision_number=dec_num, decision_date=dec_date, + decision_type_text=dec_type_text, title=title_text, + decision_url=final_decision_url, + karar_id=current_karar_id, + related_cases_url=final_related_cases_url + )) + logger.debug(f"Table {idx+1} parsed successfully: Karar ID '{current_karar_id}', Title '{title_text[:50] if title_text else 'N/A'}...'") + + except Exception as e: + logger.warning(f"RekabetKurumuApiClient: Error parsing decision summary {idx+1}: {e}. Problematic Table HTML:\n{table.prettify()}", exc_info=True) + continue + + return RekabetSearchResult( + decisions=processed_decisions, total_records_found=total_records, + retrieved_page_number=params.page, total_pages=total_pages if total_pages is not None else 0 + ) + + async def _extract_pdf_url_and_landing_page_metadata(self, karar_id: str, landing_page_html: str, landing_page_url: str) -> Dict[str, Any]: + soup = BeautifulSoup(landing_page_html, 'html.parser') + data: Dict[str, Any] = { + "pdf_url": None, + "title_on_landing_page": soup.title.string.strip() if soup.title and soup.title.string else f"Rekabet Kurumu Kararı {karar_id}", + } + # This part needs to be robust and specific to Rekabet Kurumu's landing page structure. + # Look for common patterns: direct links, download buttons, embedded viewers. + pdf_anchor = soup.find("a", href=re.compile(r"\.pdf(\?|$)", re.IGNORECASE)) # Basic PDF link + if not pdf_anchor: # Try other common patterns if the basic one fails + # Example: Look for links with specific text or class + pdf_anchor = soup.find("a", string=re.compile(r"karar metni|pdf indir", re.IGNORECASE)) + + if pdf_anchor and pdf_anchor.has_attr('href'): + pdf_path = pdf_anchor['href'] + data["pdf_url"] = urljoin(landing_page_url, pdf_path) + logger.info(f"PDF link found on landing page (): {data['pdf_url']}") + else: + iframe_pdf = soup.find("iframe", src=re.compile(r"\.pdf(\?|$)", re.IGNORECASE)) + if iframe_pdf and iframe_pdf.has_attr('src'): + pdf_path = iframe_pdf['src'] + data["pdf_url"] = urljoin(landing_page_url, pdf_path) + logger.info(f"PDF link found on landing page (