Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb86d0a9a | ||
|
|
0e51ca432a | ||
|
|
08a19fb83c | ||
|
|
15402b4423 | ||
|
|
1b483a6fcf | ||
|
|
cc055103fe | ||
|
|
2c3347643d | ||
|
|
fadc3b0bc0 | ||
|
|
6bbc656dc6 | ||
|
|
a062237474 | ||
|
|
b69eda77af | ||
|
|
3927dcee8f | ||
|
|
3768104679 | ||
|
|
aa580ffafc | ||
|
|
931eb3ca8f | ||
|
|
d258ad2375 | ||
|
|
061887f870 | ||
|
|
ac611f840c | ||
|
|
c938f10ba2 | ||
|
|
1356c4d020 | ||
|
|
5392435c7a | ||
|
|
96a5a538b2 | ||
|
|
26aa3dacc6 | ||
|
|
58457b076f | ||
|
|
4521e1de85 | ||
|
|
8f04010c57 | ||
|
|
7ed9c25687 | ||
|
|
4fdc7a3689 | ||
|
|
1538a4c145 | ||
|
|
a24def2e66 | ||
|
|
6b781b61d2 | ||
|
|
fb29146755 | ||
|
|
42731a2c03 | ||
|
|
ae5d590cca | ||
|
|
ee544dc603 | ||
|
|
355f505da9 | ||
|
|
4c06a5926b | ||
|
|
2cec4dccd6 | ||
|
|
5c2e9cc92b | ||
|
|
a66a3f2053 | ||
|
|
a4d9e2e53d | ||
|
|
036e49a928 | ||
|
|
7f78f87508 | ||
|
|
d8805cb93b | ||
|
|
28ff2e39a5 | ||
|
|
fd08637ca2 | ||
|
|
a5e6baeec8 | ||
|
|
8818a7809a | ||
|
|
12d51e3735 | ||
|
|
efe962abf1 | ||
|
|
1d73265f10 | ||
|
|
f1d3b60efb | ||
|
|
93e64bc1fc | ||
|
|
77e2748ade | ||
|
|
da146cf3ec | ||
|
|
e771c5b3c5 | ||
|
|
1223b37adb | ||
|
|
e26f09aced | ||
|
|
ae5bae2f4a | ||
|
|
a2b50951e9 | ||
|
|
91ad04cf09 | ||
|
|
5cec0df785 | ||
|
|
d51f11c7ba | ||
|
|
b207b16ef7 | ||
|
|
f47147ba44 |
@@ -181,6 +181,3 @@ site
|
||||
|
||||
# Production logs
|
||||
**/logs/*.log.*
|
||||
**/Dockerfile
|
||||
**/Dockerfile
|
||||
fly.toml
|
||||
|
||||
@@ -70,6 +70,50 @@ JWT_SECRET_KEY=your_jwt_secret_key_here
|
||||
# MAX_REQUESTS_PER_MINUTE=60
|
||||
# BURST_CAPACITY=20
|
||||
|
||||
# =============================================================================
|
||||
# SEMANTIC SEARCH SETTINGS (Optional)
|
||||
# =============================================================================
|
||||
|
||||
# Embedding provider for the semantic_search tool.
|
||||
# Pick exactly one of: OpenRouter (hosted) or Local (your own server).
|
||||
|
||||
# --- Option A: OpenRouter (hosted, default) -----------------------------------
|
||||
# Get your API key from: https://openrouter.ai/keys
|
||||
# If neither this nor EMBEDDING_PROVIDER=local is set, semantic search is off.
|
||||
OPENROUTER_API_KEY=sk-or-v1-your_openrouter_api_key_here
|
||||
|
||||
# Optional: override the OpenRouter embedding model and dimension.
|
||||
# Defaults: google/gemini-embedding-001 at 3072 dims (paid on OpenRouter).
|
||||
# Pick any model from https://openrouter.ai/models?modality=embedding
|
||||
# and set the dimension to that model's output size — they must match.
|
||||
# OPENROUTER_EMBEDDING_MODEL=google/gemini-embedding-001
|
||||
# OPENROUTER_EMBEDDING_DIMENSION=3072
|
||||
|
||||
# --- Option B: Local OpenAI-compatible server (no API key required) ----------
|
||||
# Recommended for Turkish: intfloat/multilingual-e5-large served by HuggingFace
|
||||
# Text Embeddings Inference (TEI). One-line setup:
|
||||
#
|
||||
# docker run -p 8080:80 ghcr.io/huggingface/text-embeddings-inference:latest \
|
||||
# --model-id intfloat/multilingual-e5-large
|
||||
#
|
||||
# Then uncomment the block below. Other model families work too — set
|
||||
# EMBEDDING_PROMPT_STYLE to match: e5 / gemini / raw.
|
||||
#
|
||||
# EMBEDDING_PROVIDER=local
|
||||
# LOCAL_EMBEDDING_BASE_URL=http://localhost:8080/v1
|
||||
# LOCAL_EMBEDDING_MODEL=intfloat/multilingual-e5-large
|
||||
# LOCAL_EMBEDDING_DIMENSION=1024
|
||||
# EMBEDDING_PROMPT_STYLE=e5
|
||||
# LOCAL_EMBEDDING_API_KEY= # most local servers ignore this
|
||||
#
|
||||
# Ollama fallback (if you prefer Ollama and don't need top Turkish quality):
|
||||
# ollama serve && ollama pull nomic-embed-text
|
||||
# EMBEDDING_PROVIDER=local
|
||||
# LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
# LOCAL_EMBEDDING_MODEL=nomic-embed-text
|
||||
# LOCAL_EMBEDDING_DIMENSION=768
|
||||
# EMBEDDING_PROMPT_STYLE=raw # nomic uses its own search_query/search_document
|
||||
|
||||
# =============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Serena
|
||||
.serena/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,84 +0,0 @@
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran go
|
||||
# haskell java julia kotlin lua markdown
|
||||
# nix perl php python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala swift
|
||||
# terraform typescript typescript_vts yaml zig
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- python
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "yargi-mcp"
|
||||
included_optional_tools: []
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a FastMCP server that provides programmatic access to Turkish legal databases through the Model Context Protocol (MCP). It integrates with 11 different Turkish legal institutions' databases including Yargıtay (Court of Cassation), Danıştay (Council of State), Constitutional Court, Competition Authority, Court of Accounts (Sayıştay), KVKK (Personal Data Protection Authority), BDDK (Banking Regulation and Supervision Agency), and others.
|
||||
This is a FastMCP server that provides programmatic access to Turkish legal databases through the Model Context Protocol (MCP). It integrates with 11 different Turkish legal institutions' databases including Yargıtay (Court of Cassation), Danıştay (Council of State), Constitutional Court, Competition Authority, Court of Accounts (Sayıştay), KVKK (Personal Data Protection Authority), BDDK (Banking Regulation and Supervision Agency), BTK (Information and Communication Technologies Authority), and others.
|
||||
|
||||
**🎯 HIGHLY OPTIMIZED**: This MCP server has been extensively optimized for token efficiency, achieving a **56.8% reduction** in MCP overhead (from 14,061 to 6,073 tokens) while maintaining full functionality.
|
||||
|
||||
@@ -190,6 +190,7 @@ This MCP server has undergone comprehensive optimization to minimize token overh
|
||||
9. **kvkk_mcp_module**: KVKK (Personal Data Protection Authority) decisions - Brave API integration
|
||||
10. **bddk_mcp_module**: BDDK (Banking Regulation and Supervision Agency) decisions - Tavily API integration
|
||||
11. **sayistay_mcp_module**: Sayıştay (Court of Accounts) decisions - Audit findings and appeals
|
||||
12. **btk_mcp_module**: BTK (Information and Communication Technologies Authority) Board decisions - Official BTK JSON API + PDF-to-Markdown
|
||||
|
||||
### Key Design Patterns
|
||||
- **FastMCP Integration**: Uses FastMCP framework for MCP server implementation
|
||||
@@ -469,6 +470,28 @@ doc8 = await get_kvkk_document_markdown(decision_url="https://www.kvkk.gov.tr/Ic
|
||||
- **Fallback Token**: If not set, uses a limited free token automatically
|
||||
- KVKK search tools will work without configuration (with rate limits)
|
||||
|
||||
### Rate Limits
|
||||
|
||||
| API | Rate Limit | Notes |
|
||||
|-----|------------|-------|
|
||||
| Bedesten Unified | ~10 req / 30s window per source IP (measured 2026-05-08); 11th req → HTTP 429 with `Retry-After: 30`. Client uses an internal token bucket (default 1 token, refill 1/3.5s) plus 429 back-pressure (whole bucket pauses for the Retry-After window). Override via `BEDESTEN_RATE_CAPACITY` / `BEDESTEN_RATE_REFILL_S`. |
|
||||
| Yargıtay Primary | Unknown | Official government API |
|
||||
| Danıştay | Unknown | Official government API |
|
||||
| Anayasa Mahkemesi | Unknown | Constitutional Court API |
|
||||
| KİK v2 | Unknown | Public Procurement Authority API |
|
||||
| Rekabet Kurumu | Unknown | Competition Authority API |
|
||||
| Sayıştay | Unknown | Court of Accounts API |
|
||||
| Uyuşmazlık | Unknown | Jurisdictional Disputes Court API |
|
||||
| Emsal | Unknown | UYAP Precedent Database API |
|
||||
| KVKK (Brave) | 1,000/month | Brave Search API free tier limit |
|
||||
| BDDK | Unknown | Banking Regulation API |
|
||||
|
||||
**Recommendations:**
|
||||
- Implement client-side caching for repeated queries
|
||||
- Use pagination parameters to limit result sizes
|
||||
- Space out requests during bulk operations
|
||||
- Consider implementing retry logic with exponential backoff
|
||||
|
||||
### OAuth Authentication Configuration
|
||||
|
||||
The server uses **Clerk JWT tokens** for all authentication. **Cross-origin authentication** is implemented using Bearer JWT tokens as per Clerk's best practices.
|
||||
@@ -971,9 +994,10 @@ curl -s -X POST http://127.0.0.1:8000/mcp/ \
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "search_anayasa_norm_denetimi_decisions",
|
||||
"name": "search_anayasa_unified",
|
||||
"arguments": {
|
||||
"keywords_all": ["eğitim hakkı"],
|
||||
"decision_type": "norm_denetimi",
|
||||
"keywords": ["eğitim hakkı"],
|
||||
"results_per_page": 3
|
||||
}
|
||||
},
|
||||
@@ -2054,7 +2078,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
### Current Tool Architecture (Updated)
|
||||
|
||||
**Total Tools**: 21 MCP tools across 9 legal institutions (Production Verified ✅)
|
||||
**Total Tools**: 28 active MCP tools + 1 optional semantic search tool (`search_bedesten_semantic`), plus Deep Research helpers (`search`, `fetch`) and `check_government_servers_health` (Production Verified ✅)
|
||||
|
||||
**Legal Database Coverage**:
|
||||
1. **Yargıtay**: ❌ ~~2 tools~~ → Use Bedesten unified instead (DEACTIVATED)
|
||||
@@ -2066,10 +2090,24 @@ build-backend = "setuptools.build_meta"
|
||||
7. **KİK**: 2 tools (search + document) - **v2 API with three decision types: uyusmazlik, duzenleyici, mahkeme** ✅
|
||||
8. **Competition Authority**: 2 tools (search + document)
|
||||
9. **KVKK**: 2 tools (search + document)
|
||||
10. **Sayıştay**: 4 tools (3 search types + document)
|
||||
10. **Sayıştay**: 2 tools (unified search + unified document)
|
||||
11. **BDDK**: 2 tools (search + document) - Tavily API integration
|
||||
12. **BTK**: 2 tools (search + document) - **NEW** Official BTK JSON API + PDF-to-Markdown
|
||||
13. **GİB Özelge**: 2 tools (search + document) - Official GİB JSON API
|
||||
14. **Sigorta Tahkim**: 3 tools (search + document + within-issue search)
|
||||
|
||||
### Recent Updates
|
||||
|
||||
#### ✅ BTK Module Added (Completed - Jul 4, 2026)
|
||||
- **PR**: #31 (ab-ihsanoglu) merged into `main` - adds `btk_mcp_module/` (client + models + `__init__`)
|
||||
- **Institution**: BTK (Bilgi Teknolojileri ve İletişim Kurumu / Information and Communication Technologies Authority) Board decisions
|
||||
- **Data source**: Official BTK JSON API — `GET https://www.btk.tr/api/content/board-decisions` (list); decision PDFs served from `https://www.btk.gov.tr/...` and converted to Markdown via MarkItDown (5,000-char pagination)
|
||||
- **New tools**:
|
||||
- `search_btk_decisions(keywords, decision_no, decision_date, publication_date, relevant_unit, page, pageSize)` — filters + pagination (`pageSize` 1-50)
|
||||
- `get_btk_document_markdown(pdf_url, page_number)` — paginated Markdown; `pdf_url` comes from the search result's `pdf_url` field; URL validated to start with `https://www.btk.gov.tr/` or `https://www.btk.tr/`
|
||||
- **Integration**: Follows existing module patterns (async httpx client, empty-string defaults, null-safe parsing, `close_client_session()` wired into `perform_cleanup()`); registered in `mcp_server_main.py`, `asgi_app.py`, `Dockerfile`, `pyproject.toml`
|
||||
- **Verification**: Both tools verified live via the BTK client — search returned real results (118 hits / 40 pages for "numara"), document retrieval converted a real decision PDF (`2023/TK-YED/40`) to Markdown
|
||||
|
||||
#### ✅ Bedesten API Unification (Completed)
|
||||
- **Before**: 10 separate tools for different court types
|
||||
- **After**: 2 unified tools supporting all court types
|
||||
@@ -2082,6 +2120,21 @@ build-backend = "setuptools.build_meta"
|
||||
- **Benefits**: Single interface, auto-detection, simplified usage
|
||||
- **Tools**: search_anayasa_unified + get_anayasa_document_unified
|
||||
|
||||
#### ✅ AYM & Uyuşmazlık API Rewrites (Completed - Jun 30, 2026)
|
||||
- **Issue**: Both sites were rebuilt; their old endpoints now return HTTP 404, breaking `search_anayasa_unified`, `get_anayasa_document_unified`, `search_uyusmazlik_decisions`, and `get_uyusmazlik_document_markdown_from_url`.
|
||||
- **AYM (Anayasa Mahkemesi)** — migrated to a single-page app (`/kbb/`) backed by a **JSON API** shared by both hosts:
|
||||
- Endpoint: `POST /api/core/public/search`
|
||||
- List: `{kararTipi, query, page, size}` → `{total, page, data:[...], page_size}` (page is 1-indexed)
|
||||
- Document: `{kararTipi, id, page:1, size:1}` → `data[0].icerik` (full decision HTML)
|
||||
- `kararTipi`: `NormDenetimi`, `BireyselBasvuru` (others: SiyasiParti, YasamaDokunulmazligi, YuceDivan, Tumu)
|
||||
- Old `/Ara` (HTML scrape) and `/ND/`, `/BB/` document pages are **gone**.
|
||||
- New shared low-level client: `anayasa_mcp_module/api_client.py`. Document URLs are SPA links carrying `?type=<kararTipi>&id=<base64url("kbb:"+uuid)>`.
|
||||
- **Uyuşmazlık Mahkemesi** — migrated to **ASP.NET WebForms**:
|
||||
- Flow: GET `/` → scrape `__VIEWSTATE`/`__VIEWSTATEGENERATOR`/`__EVENTVALIDATION`; POST `/` with `txtSearch`, `rblSearchScope` (`All`/`EsasNo`/`KararNo`), optional `chkCaseSensitive`, `btnSearch=Ara`; results in `<table id="GridView1">`; pager via `__doPostBack('GridView1','Page$N')`.
|
||||
- Documents are PDFs at `/Uploads/{EsasNo}.pdf` (slash → dash), converted with MarkItDown.
|
||||
- The old `/Arama/Search` AJAX endpoint and the rich Bölüm/Uyuşmazlık Türü/Esas-Karar/Karar Sonucu filters are **gone**; the tool now exposes `icerik`, `search_scope`, `case_sensitive`, `page_number`.
|
||||
- **Verification**: All four tools verified live via FastMCP client (search + document retrieval for AYM norm/bireysel and Uyuşmazlık).
|
||||
|
||||
#### 🔄 Sayıştay Module (Available, Active)
|
||||
- **Module**: `sayistay_mcp_module/` - Complete implementation
|
||||
- **Status**: 4 tools active and operational
|
||||
|
||||
+44
-24
@@ -1,34 +1,54 @@
|
||||
# -------- BASE IMAGE (includes Chromium & deps) ----------------------------
|
||||
FROM mcr.microsoft.com/playwright/python:v1.53.0-noble
|
||||
# Use Python 3.12 slim image
|
||||
FROM python:3.12-slim
|
||||
|
||||
# -------- Runtime setup ----------------------------------------------------
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency manifests first for layer-cache
|
||||
COPY pyproject.toml poetry.lock* requirements*.txt* ./
|
||||
# Install system dependencies (gcc/g++ kept in case any wheel falls back to source build)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Fast, deterministic install with `uv`
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system --no-cache-dir . && \
|
||||
uv pip install --system --no-cache-dir .[asgi,saas]
|
||||
# Copy project metadata first for better Docker layer caching
|
||||
COPY pyproject.toml ./
|
||||
COPY README.md ./
|
||||
|
||||
# Cache buster - force rebuild
|
||||
ARG CACHE_BUST=202510061202
|
||||
RUN echo "Cache bust: $CACHE_BUST"
|
||||
# Copy entry points
|
||||
COPY app.py ./
|
||||
COPY asgi_app.py ./
|
||||
COPY mcp_server_main.py ./
|
||||
|
||||
# Copy application source
|
||||
COPY . .
|
||||
# Copy MCP modules and shared packages
|
||||
COPY anayasa_mcp_module ./anayasa_mcp_module
|
||||
COPY bddk_mcp_module ./bddk_mcp_module
|
||||
COPY bedesten_mcp_module ./bedesten_mcp_module
|
||||
COPY btk_mcp_module ./btk_mcp_module
|
||||
COPY danistay_mcp_module ./danistay_mcp_module
|
||||
COPY emsal_mcp_module ./emsal_mcp_module
|
||||
COPY gib_mcp_module ./gib_mcp_module
|
||||
COPY kik_mcp_module ./kik_mcp_module
|
||||
COPY kvkk_mcp_module ./kvkk_mcp_module
|
||||
COPY rekabet_mcp_module ./rekabet_mcp_module
|
||||
COPY sayistay_mcp_module ./sayistay_mcp_module
|
||||
COPY sigorta_tahkim_mcp_module ./sigorta_tahkim_mcp_module
|
||||
COPY uyusmazlik_mcp_module ./uyusmazlik_mcp_module
|
||||
COPY yargitay_mcp_module ./yargitay_mcp_module
|
||||
COPY semantic_search ./semantic_search
|
||||
|
||||
# -------- 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)"
|
||||
# Install the package with ASGI extras (uvicorn + starlette)
|
||||
RUN pip install --no-cache-dir -e ".[asgi]"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# -------- Entrypoint -------------------------------------------------------
|
||||
CMD ["uvicorn", "asgi_app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||
# Set environment variables
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5)" || exit 1
|
||||
|
||||
# Run the ASGI application
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
# Yargı MCP: Türk Hukuk Kaynakları için MCP Sunucusu
|
||||
|
||||
> ## ✨ Profesyonel Sürüm Hazır: Yargı MCP Pro
|
||||
>
|
||||
> **Mevzuat ve içtihatı tek bir MCP sunucusunda birleştiren** profesyonel sürüm yayında:
|
||||
>
|
||||
> 👉 **https://yargi.betaspacestudio.com**
|
||||
|
||||
> ## 🚨 SUNUCU YENİ ADRESE TAŞINDI
|
||||
>
|
||||
> **Yeni Remote MCP adresi:** `https://yargimcp.surucu.dev/mcp`
|
||||
>
|
||||
> **Eski adres** (`https://yargimcp.fastmcp.app/mcp`) **artık kullanım dışıdır** — yalnızca taşındığını bildiren bir uyarı tool'u döner.
|
||||
>
|
||||
> **Yapmanız gereken:** MCP istemcinizdeki (Claude Desktop, 5ire, Google Antigravity, ChatGPT vb.) sunucu URL'sini yukarıdaki yeni adresle güncelleyin.
|
||||
|
||||
## Word'den UDF'ye profesyonel dönüşüm için yeni uygulamam [udfcevir.com](https://udfcevir.com) adresinde!
|
||||
|
||||
[](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.
|
||||
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ı, BDDK Kararları, BTK Kararları, GİB Özelgeleri ve Sigorta Tahkim Komisyonu 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,19 +26,76 @@ Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kara
|
||||
|
||||
### ✅ Kurulum Gerektirmez! Hemen Kullan!
|
||||
|
||||
🔗 **Remote MCP Adresi:** `https://yargimcp.fastmcp.app/mcp`
|
||||
🔗 **Remote MCP Adresi:** `https://yargimcp.surucu.dev/mcp`
|
||||
|
||||
### Claude Desktop ile Kullanım
|
||||
> ⚠️ **Eski adres** `https://yargimcp.fastmcp.app/mcp` **artık kullanım dışıdır** — yalnızca taşındığını bildiren bir uyarı tool'u döner. Lütfen yukarıdaki yeni adresi kullanın.
|
||||
|
||||
### Claude Desktop ile Kullanım (Ücretli abonelik gerekir)
|
||||
|
||||
1. **Claude Desktop'ı açın**
|
||||
2. **Settings → Connectors → Add Custom Connector**
|
||||
3. **Bilgileri girin:**
|
||||
- **Name:** `Yargı MCP`
|
||||
- **URL:** `https://yargimcp.fastmcp.app/mcp`
|
||||
- **URL:** `https://yargimcp.surucu.dev/mcp`
|
||||
4. **Add** butonuna tıklayın
|
||||
5. **Hemen kullanmaya başlayın!** 🎉
|
||||
|
||||
> 💡 **İpucu:** Remote MCP sayesinde Python, uv veya herhangi bir kurulum yapmadan doğrudan Claude Desktop üzerinden Türk hukuk kaynaklarına erişebilirsiniz!
|
||||
### Google Antigravity ile Kullanım (Lokal `uv` Kurulumu — Kopyala-Yapıştır)
|
||||
|
||||
> **Ön Gereksinimler:** Bilgisayarınızda **Python**, **`uv`** ([kurulum](https://docs.astral.sh/uv/getting-started/installation/)) ve **Node.js** ([indir](https://nodejs.org/en/download)) kurulu olmalı. (Node.js yalnızca aşağıdaki kurulum komutunu çalıştırmak için gerekir; MCP'yi `uvx` çalıştırır.)
|
||||
|
||||
Aşağıdaki **bloğun tamamını** terminale yapıştırın. Komut, Antigravity'nin okuduğu `~/.gemini/config/mcp_config.json` dosyasını sizin yerinize oluşturur/günceller (varsa diğer sunucularınız korunur):
|
||||
|
||||
**macOS / Linux** (Terminal):
|
||||
|
||||
```bash
|
||||
node - <<'YARGI'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=path.join(os.homedir(),".gemini","config"),file=path.join(dir,"mcp_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
YARGI
|
||||
```
|
||||
|
||||
**Windows** (PowerShell):
|
||||
|
||||
```powershell
|
||||
@'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=path.join(os.homedir(),".gemini","config"),file=path.join(dir,"mcp_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
'@ | node -
|
||||
```
|
||||
|
||||
Komut `yargi-mcp eklendi -> ...` çıktısını verdiğinde kurulum tamamlanmıştır. Antigravity'yi (açıksa kapatıp) yeniden başlatın; `yargi-mcp` araçları otomatik yüklenir.
|
||||
|
||||
> 💡 **İpucu:** Lokal kurulumda hukuk kaynaklarına erişim doğrudan bilgisayarınızda `uvx yargi-mcp` ile çalışır; uzaktan sunucuya ihtiyaç duymaz.
|
||||
|
||||
### Remote MCP Sorun Giderme
|
||||
|
||||
`https://yargimcp.surucu.dev/mcp` bir web sayfası değil, Streamable HTTP MCP uç noktasıdır. Tarayıcıda açınca veya düz `curl` ile GET isteği atınca `406 Not Acceptable` ve `Client must accept text/event-stream` benzeri bir yanıt görmek normaldir; bu, sunucunun kapalı olduğu anlamına gelmez. MCP istemcisi `Accept: application/json, text/event-stream` başlığıyla JSON-RPC isteği göndermelidir.
|
||||
|
||||
Hızlı sağlık kontrolü için tarayıcıda şu adresleri açabilirsiniz:
|
||||
|
||||
- `https://yargimcp.surucu.dev/health` — servis sağlık durumu
|
||||
|
||||
Claude.ai veya başka bir istemci "araç yok" gibi davranırsa:
|
||||
|
||||
1. Connector'ı kaldırıp yeniden ekleyin.
|
||||
2. URL olarak önce `https://yargimcp.surucu.dev/mcp` deneyin; istemciniz yönlendirmeleri takip etmiyorsa `https://yargimcp.surucu.dev/mcp/` deneyin.
|
||||
3. Eski `https://yargimcp.fastmcp.app/mcp` adresinin istemci ayarlarında veya önbellekte kalmadığından emin olun.
|
||||
4. İstemcinin remote/Streamable HTTP MCP desteklediğini ve `text/event-stream` kabul ettiğini kontrol edin.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,6 +125,9 @@ Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kara
|
||||
* **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)
|
||||
* **BTK (Bilgi Teknolojileri ve İletişim Kurumu):** Kurul Kararlarını arama (anahtar kelime + karar no + karar tarihi + yayın tarihi + ilgili birim filtreleri); karar PDF'lerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme (btk.gov.tr)
|
||||
* **GİB (Gelir İdaresi Başkanlığı) Özelgeleri:** Resmi vergi özelgelerini arama (18.000+ özelge: KDV, Kurumlar, Gelir, ÖTV, Damga vb.); tam metni sayfalanmış Markdown formatında getirme + **Keyword + Özelge No + Kanun No + Tarih Aralığı** + **Otomatik ISO 8601 Dönüşümü** + **Metadata Başlık Bloğu**
|
||||
* **Sigorta Tahkim Komisyonu:** Hakem Karar Dergisi (64 sayı, 2010-2025) içindeki sigorta tahkim kararlarını arama; dergi PDF'lerini Markdown formatında getirme + **Sayı İçi Karar Arama** + **Türkçe Büyük/Küçük Harf Desteği** + **Relevance Scoring**
|
||||
|
||||
* Karar metinlerinin daha kolay işlenebilmesi için Markdown formatına çevrilmesi.
|
||||
* Claude Desktop uygulaması ile `fastmcp install` komutu kullanılarak kolay entegrasyon.
|
||||
@@ -86,26 +162,64 @@ Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istem
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>⚙️ <strong>Claude Desktop Manuel Kurulumu</strong></summary>
|
||||
<summary>⚙️ <strong>Claude Desktop Lokal Kurulumu (Kopyala-Yapıştır)</strong></summary>
|
||||
|
||||
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:
|
||||
> **Ön Gereksinimler:** Bilgisayarınızda **Python**, **`uv`** ([kurulum](https://docs.astral.sh/uv/getting-started/installation/)), **Node.js** ([indir](https://nodejs.org/en/download)) ve (Windows için) Microsoft Visual C++ Redistributable kurulu olmalı. (Node.js yalnızca aşağıdaki kurulum komutunu çalıştırmak için gerekir; MCP'yi `uvx` çalıştırır.)
|
||||
|
||||
Aşağıdaki **bloğun tamamını** terminale yapıştırın. Komut, Claude Desktop'ın `claude_desktop_config.json` dosyasını sizin yerinize oluşturur/günceller (varsa diğer sunucularınız korunur):
|
||||
|
||||
**macOS / Linux** (Terminal):
|
||||
|
||||
```bash
|
||||
node - <<'YARGI'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=process.platform==="darwin"
|
||||
? path.join(os.homedir(),"Library","Application Support","Claude")
|
||||
: path.join(os.homedir(),".config","Claude");
|
||||
const file=path.join(dir,"claude_desktop_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
YARGI
|
||||
```
|
||||
|
||||
**Windows** (PowerShell):
|
||||
|
||||
```powershell
|
||||
@'
|
||||
const fs=require("fs"),os=require("os"),path=require("path");
|
||||
const dir=path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Claude");
|
||||
const file=path.join(dir,"claude_desktop_config.json");
|
||||
fs.mkdirSync(dir,{recursive:true});
|
||||
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
|
||||
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
|
||||
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
|
||||
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
|
||||
console.log("yargi-mcp eklendi -> "+file);
|
||||
'@ | node -
|
||||
```
|
||||
|
||||
Komut `yargi-mcp eklendi -> ...` çıktısını verdiğinde kurulum tamamlanmıştır. **Claude Desktop'ı tamamen kapatıp yeniden başlatın**; `yargi-mcp` araçları otomatik yüklenir.
|
||||
|
||||
---
|
||||
|
||||
**Manuel alternatif:** Claude Desktop **Settings → Developer → Edit Config** menüsünden `claude_desktop_config.json` dosyasını açıp `mcpServers` altına ekleyebilirsiniz:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
// ... (varsa diğer sunucularınız) ...
|
||||
"Yargı MCP": {
|
||||
"yargi-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"yargi-mcp"
|
||||
]
|
||||
"args": ["yargi-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Claude Desktop'ı kapatıp yeniden başlatın.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -154,10 +268,110 @@ Yargı MCP'yi Gemini CLI ile kullanmak için:
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>🧠 <strong>Semantik Arama (Opsiyonel)</strong></summary>
|
||||
|
||||
Yargı MCP, **semantik arama** özelliği ile kararları anlamsal olarak sıralayabilir. Opsiyoneldir; iki yoldan biri yapılandırıldığında otomatik etkinleşir:
|
||||
|
||||
- **Yerel** (önerilen, ücretsiz): kendi makinenizdeki OpenAI-uyumlu embedding sunucusu (HuggingFace TEI, llama.cpp, Ollama, vLLM, LM Studio…)
|
||||
- **Hosted**: OpenRouter API anahtarı
|
||||
|
||||
### Semantik Arama Nasıl Çalışır?
|
||||
1. `initial_keyword` ile Bedesten API'den 100 karar çekilir
|
||||
2. `query` ile bu kararlar embedding modeli kullanılarak anlamsal olarak sıralanır
|
||||
3. En alakalı kararlar döndürülür
|
||||
|
||||
### Önerilen Türkçe Kurulumu (Yerel — `multilingual-e5-large`)
|
||||
|
||||
`intfloat/multilingual-e5-large` Türkçe için kıyas ettiğimiz açık kaynak modeller arasında en iyilerinden. HuggingFace'in **Text Embeddings Inference (TEI)** sunucusuyla tek komutta ayağa kalkar ve OpenAI-uyumlu API sunar:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 ghcr.io/huggingface/text-embeddings-inference:latest \
|
||||
--model-id intfloat/multilingual-e5-large
|
||||
```
|
||||
|
||||
Sonra Yargı MCP'ye şu env vars'ları geçirin:
|
||||
|
||||
```bash
|
||||
EMBEDDING_PROVIDER=local
|
||||
LOCAL_EMBEDDING_BASE_URL=http://localhost:8080/v1
|
||||
LOCAL_EMBEDDING_MODEL=intfloat/multilingual-e5-large
|
||||
LOCAL_EMBEDDING_DIMENSION=1024
|
||||
EMBEDDING_PROMPT_STYLE=e5
|
||||
```
|
||||
|
||||
> ⚠️ **Önemli:** `EMBEDDING_PROMPT_STYLE=e5` şart — e5 modelleri `query:` / `passage:` öneki bekleyecek şekilde eğitilmiştir; yanlış önek sessizce kaliteyi düşürür.
|
||||
|
||||
#### Claude Desktop örneği (yerel TEI)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Yargı MCP": {
|
||||
"command": "uvx",
|
||||
"args": ["yargi-mcp"],
|
||||
"env": {
|
||||
"EMBEDDING_PROVIDER": "local",
|
||||
"LOCAL_EMBEDDING_BASE_URL": "http://localhost:8080/v1",
|
||||
"LOCAL_EMBEDDING_MODEL": "intfloat/multilingual-e5-large",
|
||||
"LOCAL_EMBEDDING_DIMENSION": "1024",
|
||||
"EMBEDDING_PROMPT_STYLE": "e5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alternatif 1: Ollama (yerel, daha hafif kurulum)
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
ollama pull nomic-embed-text # 768 dim, İngilizce ağırlıklı
|
||||
```
|
||||
|
||||
```bash
|
||||
EMBEDDING_PROVIDER=local
|
||||
LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
LOCAL_EMBEDDING_MODEL=nomic-embed-text
|
||||
LOCAL_EMBEDDING_DIMENSION=768
|
||||
EMBEDDING_PROMPT_STYLE=raw
|
||||
```
|
||||
|
||||
> Ollama kütüphanesinde `multilingual-e5-large` doğrudan yok; Türkçe için TEI yolu daha doğru sonuç verir.
|
||||
|
||||
### Alternatif 2: OpenRouter (hosted)
|
||||
|
||||
```bash
|
||||
OPENROUTER_API_KEY=sk-or-v1-xxx...
|
||||
# İsteğe bağlı — varsayılan google/gemini-embedding-001 (3072 dim, ÜCRETLİ)
|
||||
# OPENROUTER_EMBEDDING_MODEL=...
|
||||
# OPENROUTER_EMBEDDING_DIMENSION=...
|
||||
# EMBEDDING_PROMPT_STYLE=gemini # varsayılan
|
||||
```
|
||||
|
||||
API anahtarınızı [openrouter.ai/keys](https://openrouter.ai/keys) adresinden alın. Varsayılan model `google/gemini-embedding-001` artık ücretli — ücretsiz bir model seçerseniz `OPENROUTER_EMBEDDING_MODEL`, `OPENROUTER_EMBEDDING_DIMENSION` ve uygun `EMBEDDING_PROMPT_STYLE` değerlerini birlikte ayarlayın.
|
||||
|
||||
### Yapılandırma Referansı
|
||||
|
||||
| Env Var | Açıklama | Örnek |
|
||||
|---|---|---|
|
||||
| `EMBEDDING_PROVIDER` | `local` ise yerel sunucu, boş ise OpenRouter | `local` |
|
||||
| `EMBEDDING_PROMPT_STYLE` | `gemini` / `e5` / `raw` — modelin beklediği önek | `e5` |
|
||||
| `LOCAL_EMBEDDING_BASE_URL` | Yerel sunucunun OpenAI-uyumlu URL'i | `http://localhost:8080/v1` |
|
||||
| `LOCAL_EMBEDDING_MODEL` | Model adı | `intfloat/multilingual-e5-large` |
|
||||
| `LOCAL_EMBEDDING_DIMENSION` | Modelin çıktı boyutu (mutlaka eşleşmeli) | `1024` |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter anahtarı (sadece hosted için) | `sk-or-v1-…` |
|
||||
| `OPENROUTER_EMBEDDING_MODEL` | OpenRouter model id'si | `google/gemini-embedding-001` |
|
||||
| `OPENROUTER_EMBEDDING_DIMENSION` | OpenRouter modelinin çıktı boyutu | `3072` |
|
||||
|
||||
> 💡 **Not:** Hiçbir embedding sağlayıcı yapılandırılmazsa semantik arama aracı görünmez, diğer 28 araç normal şekilde çalışır.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ <strong>Kullanılabilir Araçlar (MCP Tools)</strong></summary>
|
||||
|
||||
Bu FastMCP sunucusu **19 optimize edilmiş MCP aracı** sunar (token verimliliği için optimize edilmiş):
|
||||
Bu FastMCP sunucusu **26 aktif MCP aracı** + **1 opsiyonel semantik arama 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*
|
||||
@@ -182,8 +396,8 @@ Bu FastMCP sunucusu **19 optimize edilmiş MCP aracı** sunar (token verimliliğ
|
||||
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.
|
||||
9. `search_kik_v2_decisions(decision_type, karar_metni, karar_no, basvuran, idare_adi, baslangic_tarihi, bitis_tarihi)`: KİK v2 API ile uyuşmazlık, düzenleyici ve mahkeme kararlarını arar.
|
||||
10. `get_kik_v2_document_markdown(gundemMaddesiId)`: Arama sonucundaki `gundemMaddesiId` ile KİK karar metnini Markdown formatında 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.
|
||||
@@ -191,22 +405,36 @@ Bu FastMCP sunucusu **19 optimize edilmiş MCP aracı** sunar (token verimliliğ
|
||||
|
||||
---
|
||||
|
||||
* **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
|
||||
* **Sayıştay Araçları (Birleşik API, 3 Karar Türü + 8 Daire Filtreleme):**
|
||||
* `search_sayistay_unified(decision_type, start, length, ...)`: `genel_kurul`, `temyiz_kurulu` veya `daire` kararlarını tek araçla arar. `length` 1-100 aralığındadır.
|
||||
* `get_sayistay_document_unified(decision_id, decision_type)`: Birleşik arama sonucundaki karar ID'si ve karar türüyle tam metni 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**
|
||||
* `search_kvkk_decisions(keywords, page)`: 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**. Sonuç sayısı sunucuda 10 olarak sabitlenmiştir.
|
||||
* `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)
|
||||
|
||||
### BTK (Bilgi Teknolojileri ve İletişim Kurumu) Araçları (Resmi BTK JSON API)
|
||||
* `search_btk_decisions(keywords, decision_no, decision_date, publication_date, relevant_unit, page, pageSize)`: BTK Kurul Kararlarını arar. **Anahtar kelime + Karar No** (ör. `2026/DK-THD/91`) **+ Karar Tarihi + Yayın Tarihi + İlgili Birim** filtreleri + **Sayfalama** (`pageSize` 1-50)
|
||||
* `get_btk_document_markdown(pdf_url: str, page_number: int = 1)`: BTK kararının PDF'ini indirip **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa). `pdf_url`, `search_btk_decisions` sonucundaki `pdf_url` alanından alınır (`btk.gov.tr`)
|
||||
|
||||
### GİB (Gelir İdaresi Başkanlığı) Özelge Araçları (Resmi GİB JSON API)
|
||||
* `search_gib_ozelge(keywords, ozelgeNo, kanunNo, ozelgeStartDate, ozelgeEndDate, page, pageSize)`: GİB özelgelerini (Türk Gelir İdaresi Başkanlığı vergi özelgeleri) arar — **18.000+ özelge** (KDV, Kurumlar, Gelir, ÖTV, Damga, VUK vb.). **Keyword + Özelge No + Kanun No + Tarih Aralığı** + **Otomatik ISO 8601 Dönüşümü** (`YYYY-MM-DD` girdileri otomatik olarak full ISO 8601'e çevrilir)
|
||||
* `get_gib_ozelge_document_markdown(ozelge_id: int, page_number: int = 1)`: Belirli bir özelgenin tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa) + **Metadata başlık bloğu** (Başlık, Sayı, Tarih, Kanun, Kaynak URL)
|
||||
|
||||
### Sigorta Tahkim Komisyonu Araçları (Tavily Search API + PDF)
|
||||
* `search_sigorta_tahkim_decisions(keywords, page)`: Sigorta Tahkim Komisyonu kararlarını Tavily Search API ile arar. **Site hedeflemeli** (`sigortatahkim.org`) + **Sayfalama desteği**. Sonuç sayısı sunucuda 10 olarak sabitlenmiştir.
|
||||
* `get_sigorta_tahkim_document_markdown(issue_number: str, page_number: int)`: Hakem Karar Dergisi sayısının PDF'ini indirip **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa). 64 sayı (2010-2025)
|
||||
* `search_within_sigorta_tahkim_issue(issue_number: str, keyword: str, max_results: int)`: Belirli bir dergi sayısı içindeki kararları anahtar kelime ile arar. **Türkçe İ/I desteği** + **Relevance scoring** + **Excerpt** ile sonuç
|
||||
|
||||
### Yardımcı ve Uyumluluk Araçları
|
||||
* `check_government_servers_health()`: Yargı kaynaklarının erişilebilirliğini kontrol eder.
|
||||
* `search(query)`: ChatGPT Deep Research uyumluluğu için Bedesten destekli kaynaklarda arama yapar.
|
||||
* `fetch(id)`: ChatGPT Deep Research uyumluluğu için tek bir Bedesten belge ID'sinin tam metnini getirir.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -221,8 +449,8 @@ Bu FastMCP sunucusu **19 optimize edilmiş MCP aracı** sunar (token verimliliğ
|
||||
- **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ı
|
||||
- **Toplam Mahkeme/Kurum:** 16 farklı hukuki kurum (BTK, GİB Özelgeleri ve Sigorta Tahkim Komisyonu dahil)
|
||||
- **Toplam MCP Tool:** 28 aktif araç + 1 opsiyonel semantik arama 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ı)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# anayasa_mcp_module/api_client.py
|
||||
# Low-level client for the new Anayasa Mahkemesi "Kararlar Bilgi Bankası" (KBB) JSON API.
|
||||
#
|
||||
# Both the Norm Denetimi host (normkararlarbilgibankasi.anayasa.gov.tr) and the
|
||||
# Bireysel Başvuru host (kararlarbilgibankasi.anayasa.gov.tr) share the SAME
|
||||
# backend, exposed at POST /api/core/public/search. The request differs only by
|
||||
# the "kararTipi" discriminator:
|
||||
#
|
||||
# {"kararTipi": "NormDenetimi", "query": "mülkiyet", "page": 1, "size": 10}
|
||||
# -> {"total": N, "page": 1, "data": [...summary records...], "page_size": 10}
|
||||
#
|
||||
# {"kararTipi": "NormDenetimi", "id": "<uuid>", "page": 1, "size": 1}
|
||||
# -> data[0] additionally includes "icerik" = full decision HTML
|
||||
#
|
||||
# The previous HTML-scraping endpoints (/Ara, /ND/.., /BB/..) were retired when
|
||||
# the sites were rebuilt as a single-page app; they now return HTTP 404.
|
||||
|
||||
import base64
|
||||
import html as html_module
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, quote
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from markitdown import MarkItDown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Markdown pagination chunk size (characters), shared across AYM document tools.
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
|
||||
def strip_html_text(value: Optional[str]) -> str:
|
||||
"""Return plain text from a possibly-HTML field (e.g. kararKonusu)."""
|
||||
if not value:
|
||||
return ""
|
||||
text = BeautifulSoup(html_module.unescape(value), "html.parser").get_text(" ", strip=True)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def convert_icerik_to_markdown(icerik_html: Optional[str]) -> Optional[str]:
|
||||
"""Convert the "icerik" decision HTML returned by the KBB API to Markdown.
|
||||
|
||||
The icerik field is a self-contained HTML fragment (the rendered decision
|
||||
body). Scripts/styles are stripped before handing it to MarkItDown.
|
||||
"""
|
||||
if not icerik_html:
|
||||
return None
|
||||
|
||||
processed_html = html_module.unescape(icerik_html)
|
||||
soup = BeautifulSoup(processed_html, "html.parser")
|
||||
for tag in soup.find_all(["script", "style"]):
|
||||
tag.decompose()
|
||||
|
||||
body = soup.find("body")
|
||||
html_fragment = str(body) if body else str(soup)
|
||||
if not html_fragment.strip().lower().startswith(("<html", "<!doctype")):
|
||||
html_fragment = f'<html><head><meta charset="UTF-8"></head><body>{html_fragment}</body></html>'
|
||||
|
||||
try:
|
||||
html_stream = io.BytesIO(html_fragment.encode("utf-8"))
|
||||
conversion_result = MarkItDown().convert(html_stream)
|
||||
return conversion_result.text_content
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.error("AnayasaApiClient: MarkItDown conversion error: %s", e)
|
||||
return None
|
||||
|
||||
# kararTipi discriminator values accepted by the API.
|
||||
KARAR_TIPI_NORM = "NormDenetimi"
|
||||
KARAR_TIPI_BIREYSEL = "BireyselBasvuru"
|
||||
|
||||
NORM_HOST = "https://normkararlarbilgibankasi.anayasa.gov.tr"
|
||||
BIREYSEL_HOST = "https://kararlarbilgibankasi.anayasa.gov.tr"
|
||||
SEARCH_PATH = "/api/core/public/search"
|
||||
|
||||
# Map kararTipi -> the host whose SPA can display the decision (cosmetic only;
|
||||
# either host's API answers for any kararTipi).
|
||||
_HOST_FOR_TIPI = {
|
||||
KARAR_TIPI_NORM: NORM_HOST,
|
||||
KARAR_TIPI_BIREYSEL: BIREYSEL_HOST,
|
||||
}
|
||||
|
||||
|
||||
def encode_document_token(uuid: str) -> str:
|
||||
"""Encode a raw decision UUID into the base64url token the SPA uses in its URLs.
|
||||
|
||||
The SPA addresses decisions as base64url("kbb:" + uuid) (no padding).
|
||||
"""
|
||||
raw = f"kbb:{uuid}".encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_document_token(token: str) -> Optional[str]:
|
||||
"""Decode a base64url SPA token back into the raw decision UUID.
|
||||
|
||||
Returns None if the token is not a valid "kbb:<uuid>" token.
|
||||
"""
|
||||
try:
|
||||
padded = token + "=" * (-len(token) % 4)
|
||||
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
if decoded.startswith("kbb:"):
|
||||
return decoded[len("kbb:"):]
|
||||
return None
|
||||
|
||||
|
||||
def build_document_url(karar_tipi: str, uuid: str) -> str:
|
||||
"""Build a clickable SPA URL for a decision, used as its document_url."""
|
||||
host = _HOST_FOR_TIPI.get(karar_tipi, BIREYSEL_HOST)
|
||||
token = encode_document_token(uuid)
|
||||
return f"{host}/kbb/pages/search/{karar_tipi}?id={quote(token)}&type={karar_tipi}"
|
||||
|
||||
|
||||
def parse_document_url(document_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Extract (karar_tipi, uuid) from a document URL.
|
||||
|
||||
Handles the new SPA URLs (?id=<token>&type=<kararTipi>) and is lenient about
|
||||
older /ND/ and /BB/ style paths so historical references still resolve.
|
||||
Returns (None, None) if neither the type nor id can be determined.
|
||||
"""
|
||||
parsed = urlparse(document_url)
|
||||
qs = parse_qs(parsed.query)
|
||||
|
||||
karar_tipi = None
|
||||
type_param = qs.get("type", [None])[0]
|
||||
path = parsed.path or ""
|
||||
if type_param in (KARAR_TIPI_NORM, KARAR_TIPI_BIREYSEL):
|
||||
karar_tipi = type_param
|
||||
elif "/ND/" in path or "NormDenetimi" in path:
|
||||
karar_tipi = KARAR_TIPI_NORM
|
||||
elif "/BB/" in path or "BireyselBasvuru" in path:
|
||||
karar_tipi = KARAR_TIPI_BIREYSEL
|
||||
|
||||
uuid = None
|
||||
id_param = qs.get("id", [None])[0]
|
||||
if id_param:
|
||||
# The id may be the raw uuid or the base64url SPA token.
|
||||
uuid = decode_document_token(id_param) or id_param
|
||||
|
||||
return karar_tipi, uuid
|
||||
|
||||
|
||||
class AnayasaApiClient:
|
||||
"""Thin async wrapper around the KBB /api/core/public/search endpoint."""
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"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 _search_url(self, karar_tipi: str) -> str:
|
||||
host = _HOST_FOR_TIPI.get(karar_tipi, BIREYSEL_HOST)
|
||||
return f"{host}{SEARCH_PATH}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
karar_tipi: str,
|
||||
query: str = "",
|
||||
page: int = 1,
|
||||
size: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a list search and return the parsed JSON envelope.
|
||||
|
||||
Envelope shape: {"total": int, "page": int, "data": [..], "page_size": int}.
|
||||
"""
|
||||
body: Dict[str, Any] = {"kararTipi": karar_tipi, "page": page, "size": size}
|
||||
if query:
|
||||
body["query"] = query
|
||||
logger.info("AnayasaApiClient: search kararTipi=%s query=%r page=%s size=%s",
|
||||
karar_tipi, query, page, size)
|
||||
response = await self.http_client.post(self._search_url(karar_tipi), json=body)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_decision(self, karar_tipi: str, uuid: str) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a single decision record (including the "icerik" HTML) by UUID."""
|
||||
body = {"kararTipi": karar_tipi, "id": uuid, "page": 1, "size": 1}
|
||||
logger.info("AnayasaApiClient: get_decision kararTipi=%s id=%s", karar_tipi, uuid)
|
||||
response = await self.http_client.post(self._search_url(karar_tipi), json=body)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
data = payload.get("data") or []
|
||||
return data[0] if data else None
|
||||
|
||||
async def close(self):
|
||||
if self.http_client and not self.http_client.is_closed:
|
||||
await self.http_client.aclose()
|
||||
logger.info("AnayasaApiClient: HTTP client session closed.")
|
||||
@@ -1,23 +1,27 @@
|
||||
# anayasa_mcp_module/bireysel_client.py
|
||||
# This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
|
||||
# Bireysel Başvuru client backed by the new KBB JSON API (see api_client.py).
|
||||
#
|
||||
# Same backend as Norm Denetimi, distinguished by kararTipi="BireyselBasvuru".
|
||||
# The legacy /Ara report-scraping endpoint was retired and now returns HTTP 404.
|
||||
|
||||
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
|
||||
import math
|
||||
from typing import List, Optional
|
||||
|
||||
from .api_client import (
|
||||
AnayasaApiClient,
|
||||
KARAR_TIPI_BIREYSEL,
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE,
|
||||
build_document_url,
|
||||
parse_document_url,
|
||||
convert_icerik_to_markdown,
|
||||
strip_html_text,
|
||||
)
|
||||
from .models import (
|
||||
AnayasaBireyselReportSearchRequest,
|
||||
AnayasaBireyselReportDecisionDetail,
|
||||
AnayasaBireyselReportDecisionSummary,
|
||||
AnayasaBireyselReportSearchResult,
|
||||
AnayasaBireyselBasvuruDocumentMarkdown, # Model for Bireysel Başvuru document
|
||||
AnayasaBireyselBasvuruDocumentMarkdown,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,330 +30,93 @@ if not logger.hasHandlers():
|
||||
|
||||
|
||||
class AnayasaBireyselBasvuruApiClient:
|
||||
BASE_URL = "https://kararlarbilgibankasi.anayasa.gov.tr"
|
||||
SEARCH_PATH = "/Ara"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
|
||||
"""Bireysel Başvuru search/document client over the KBB JSON API."""
|
||||
|
||||
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
|
||||
self.api = AnayasaApiClient(request_timeout)
|
||||
|
||||
async def search_bireysel_basvuru_report(
|
||||
self,
|
||||
params: AnayasaBireyselReportSearchRequest
|
||||
params: AnayasaBireyselReportSearchRequest,
|
||||
) -> AnayasaBireyselReportSearchResult:
|
||||
final_query_params = self._build_query_params_for_bireysel_report(params)
|
||||
request_url = self.SEARCH_PATH
|
||||
query = " ".join(t for t in (params.keywords or []) if t).strip()
|
||||
payload = await self.api.search(
|
||||
karar_tipi=KARAR_TIPI_BIREYSEL,
|
||||
query=query,
|
||||
page=params.page_to_fetch,
|
||||
size=getattr(params, "results_per_page", 10),
|
||||
)
|
||||
|
||||
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
|
||||
total_records = int(payload.get("total") or 0)
|
||||
decisions: List[AnayasaBireyselReportDecisionSummary] = []
|
||||
for item in payload.get("data") or []:
|
||||
decisions.append(AnayasaBireyselReportDecisionSummary(
|
||||
title=item.get("basvuruAdi") or "",
|
||||
decision_reference_no=item.get("basvuruNo") or "",
|
||||
decision_page_url=build_document_url(KARAR_TIPI_BIREYSEL, item.get("id", "")),
|
||||
decision_type_summary=item.get("kararTuruBasvuruSonucuLabel") or "",
|
||||
decision_making_body=item.get("kararVerenBirimLabel") or "",
|
||||
application_date_summary=item.get("basvuruTarihi") or "",
|
||||
decision_date_summary=item.get("kararTarihi") or "",
|
||||
application_subject_summary=strip_html_text(item.get("kararKonusu")),
|
||||
details=[],
|
||||
))
|
||||
|
||||
return AnayasaBireyselReportSearchResult(
|
||||
decisions=processed_decisions,
|
||||
decisions=decisions,
|
||||
total_records_found=total_records,
|
||||
retrieved_page_number=params.page_to_fetch
|
||||
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", "<!doctype")):
|
||||
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
|
||||
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
|
||||
document_url_path: str,
|
||||
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}")
|
||||
karar_tipi, uuid = parse_document_url(document_url_path)
|
||||
if karar_tipi is None:
|
||||
karar_tipi = KARAR_TIPI_BIREYSEL
|
||||
|
||||
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
|
||||
record = await self.api.get_decision(karar_tipi, uuid) if uuid else 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}.")
|
||||
if not record:
|
||||
logger.warning("AnayasaBireyselBasvuruApiClient: No record for %s", document_url_path)
|
||||
return AnayasaBireyselBasvuruDocumentMarkdown(
|
||||
source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
|
||||
source_url=document_url_path, markdown_chunk=None,
|
||||
current_page=page_number, total_pages=0, is_paginated=False,
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(html_content_from_api, 'html.parser')
|
||||
rg_tarihi = record.get("resmiGazeteTarihi") or ""
|
||||
rg_sayisi = record.get("resmiGazeteSayisi")
|
||||
official_gazette = f"{rg_tarihi} / {rg_sayisi}".strip(" /") if (rg_tarihi or rg_sayisi) else None
|
||||
|
||||
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
|
||||
full_markdown = convert_icerik_to_markdown(record.get("icerik"))
|
||||
common = dict(
|
||||
source_url=document_url_path,
|
||||
basvuru_no_from_page=record.get("basvuruNo"),
|
||||
karar_tarihi_from_page=record.get("kararTarihi"),
|
||||
basvuru_tarihi_from_page=record.get("basvuruTarihi"),
|
||||
karari_veren_birim_from_page=record.get("kararVerenBirimLabel"),
|
||||
karar_turu_from_page=record.get("kararTuruBasvuruSonucuLabel"),
|
||||
resmi_gazete_info_from_page=official_gazette,
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
if not full_markdown:
|
||||
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)
|
||||
**common, markdown_chunk=None, current_page=page_number,
|
||||
total_pages=0, is_paginated=False,
|
||||
)
|
||||
|
||||
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
|
||||
total_pages = max(1, math.ceil(len(full_markdown) / DOCUMENT_MARKDOWN_CHUNK_SIZE))
|
||||
current_page = max(1, min(page_number, total_pages))
|
||||
start = (current_page - 1) * DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
chunk = full_markdown[start:start + DOCUMENT_MARKDOWN_CHUNK_SIZE]
|
||||
|
||||
return AnayasaBireyselBasvuruDocumentMarkdown(
|
||||
**common, markdown_chunk=chunk, current_page=current_page,
|
||||
total_pages=total_pages, is_paginated=(total_pages > 1),
|
||||
)
|
||||
|
||||
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()
|
||||
await self.api.close()
|
||||
logger.info("AnayasaBireyselBasvuruApiClient: HTTP client session closed.")
|
||||
+102
-308
@@ -1,356 +1,150 @@
|
||||
# anayasa_mcp_module/client.py
|
||||
# This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
|
||||
# Norm Denetimi client backed by the new KBB JSON API (see api_client.py).
|
||||
#
|
||||
# The Anayasa Mahkemesi sites were rebuilt as a single-page app; the old
|
||||
# HTML-scraping endpoints on normkararlarbilgibankasi.anayasa.gov.tr/Ara now
|
||||
# return HTTP 404. This client maps the rich legacy request model onto the new
|
||||
# free-text "query" search and rebuilds the legacy response models from the JSON
|
||||
# payload so existing tooling keeps working.
|
||||
|
||||
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
|
||||
import math
|
||||
from typing import List, Optional
|
||||
|
||||
from .api_client import (
|
||||
AnayasaApiClient,
|
||||
KARAR_TIPI_NORM,
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE,
|
||||
build_document_url,
|
||||
parse_document_url,
|
||||
convert_icerik_to_markdown,
|
||||
strip_html_text,
|
||||
)
|
||||
from .models import (
|
||||
AnayasaNormDenetimiSearchRequest,
|
||||
AnayasaDecisionSummary,
|
||||
AnayasaReviewedNormInfo,
|
||||
AnayasaSearchResult,
|
||||
AnayasaDocumentMarkdown, # Model for Norm Denetimi document
|
||||
AnayasaDocumentMarkdown,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
def _build_query(params: AnayasaNormDenetimiSearchRequest) -> str:
|
||||
"""Derive the free-text query string the new API expects from the legacy model.
|
||||
|
||||
The new endpoint only supports a single full-text "query" field, so the
|
||||
keyword lists are flattened. Esas/Karar numbers are appended when no keyword
|
||||
is provided so number-based lookups still return results.
|
||||
"""
|
||||
terms: List[str] = []
|
||||
for bucket in (params.keywords_all, params.keywords_any):
|
||||
if bucket:
|
||||
terms.extend(t for t in bucket if t)
|
||||
if not terms:
|
||||
for value in (params.case_number_esas, params.decision_number_karar):
|
||||
if value:
|
||||
terms.append(value)
|
||||
return " ".join(terms).strip()
|
||||
|
||||
|
||||
class AnayasaMahkemesiApiClient:
|
||||
BASE_URL = "https://normkararlarbilgibankasi.anayasa.gov.tr"
|
||||
SEARCH_PATH_SEGMENT = "Ara"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
|
||||
"""Norm Denetimi search/document client over the KBB JSON API."""
|
||||
|
||||
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
|
||||
self.api = AnayasaApiClient(request_timeout)
|
||||
|
||||
async def search_norm_denetimi_decisions(
|
||||
self,
|
||||
params: AnayasaNormDenetimiSearchRequest
|
||||
params: AnayasaNormDenetimiSearchRequest,
|
||||
) -> AnayasaSearchResult:
|
||||
# Use simple /Ara endpoint - the complex path structure seems to cause 404s
|
||||
request_path = f"/{self.SEARCH_PATH_SEGMENT}"
|
||||
query = _build_query(params)
|
||||
payload = await self.api.search(
|
||||
karar_tipi=KARAR_TIPI_NORM,
|
||||
query=query,
|
||||
page=params.page_to_fetch,
|
||||
size=params.results_per_page,
|
||||
)
|
||||
|
||||
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
|
||||
total_records = int(payload.get("total") or 0)
|
||||
decisions: List[AnayasaDecisionSummary] = []
|
||||
for item in payload.get("data") or []:
|
||||
esas_no = item.get("esasNo") or ""
|
||||
karar_no = item.get("kararNo") or ""
|
||||
if esas_no and karar_no:
|
||||
reference = f"E.{esas_no}, K.{karar_no}"
|
||||
else:
|
||||
reference = esas_no or karar_no or ""
|
||||
decisions.append(AnayasaDecisionSummary(
|
||||
decision_reference_no=reference,
|
||||
decision_page_url=build_document_url(KARAR_TIPI_NORM, item.get("id", "")),
|
||||
keywords_found_count=item.get("highlightCount") or 0,
|
||||
application_type_summary=item.get("basvuruTuruLabel") or "",
|
||||
applicant_summary=item.get("basvuranGenelLabel") or "",
|
||||
decision_outcome_summary=strip_html_text(item.get("kararKonusu")),
|
||||
decision_date_summary=item.get("kararTarihi") or "",
|
||||
reviewed_norms=[],
|
||||
))
|
||||
|
||||
return AnayasaSearchResult(
|
||||
decisions=processed_decisions,
|
||||
decisions=decisions,
|
||||
total_records_found=total_records,
|
||||
retrieved_page_number=params.page_to_fetch
|
||||
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", "<!doctype")):
|
||||
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
|
||||
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
|
||||
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}")
|
||||
karar_tipi, uuid = parse_document_url(document_url)
|
||||
if karar_tipi is None:
|
||||
karar_tipi = KARAR_TIPI_NORM
|
||||
|
||||
decision_ek_no_from_page = None
|
||||
decision_date_from_page = None
|
||||
official_gazette_from_page = None
|
||||
record = await self.api.get_decision(karar_tipi, uuid) if uuid else 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}.")
|
||||
if not record:
|
||||
logger.warning("AnayasaMahkemesiApiClient: No record for document_url %s", document_url)
|
||||
return AnayasaDocumentMarkdown(
|
||||
source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
|
||||
source_url=document_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")
|
||||
esas_no = record.get("esasNo") or ""
|
||||
karar_no = record.get("kararNo") or ""
|
||||
reference = f"E.{esas_no}, K.{karar_no}" if (esas_no and karar_no) else (esas_no or karar_no or "")
|
||||
rg_tarihi = record.get("resmiGazeteTarihi") or ""
|
||||
rg_sayisi = record.get("resmiGazeteSayisi")
|
||||
official_gazette = f"{rg_tarihi} / {rg_sayisi}".strip(" /") if (rg_tarihi or rg_sayisi) else ""
|
||||
|
||||
# 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 <p> 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:
|
||||
full_markdown = convert_icerik_to_markdown(record.get("icerik"))
|
||||
if not full_markdown:
|
||||
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
|
||||
source_url=document_url,
|
||||
decision_reference_no_from_page=reference,
|
||||
decision_date_from_page=record.get("kararTarihi") or "",
|
||||
official_gazette_info_from_page=official_gazette,
|
||||
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]
|
||||
total_pages = max(1, math.ceil(len(full_markdown) / DOCUMENT_MARKDOWN_CHUNK_SIZE))
|
||||
current_page = max(1, min(page_number, total_pages))
|
||||
start = (current_page - 1) * DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
chunk = full_markdown[start:start + DOCUMENT_MARKDOWN_CHUNK_SIZE]
|
||||
|
||||
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,
|
||||
source_url=document_url,
|
||||
decision_reference_no_from_page=reference,
|
||||
decision_date_from_page=record.get("kararTarihi") or "",
|
||||
official_gazette_info_from_page=official_gazette,
|
||||
markdown_chunk=chunk,
|
||||
current_page=current_page,
|
||||
total_pages=total_pages,
|
||||
is_paginated=(total_pages > 1)
|
||||
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()
|
||||
await self.api.close()
|
||||
logger.info("AnayasaMahkemesiApiClient (Norm Denetimi): HTTP client session closed.")
|
||||
@@ -140,9 +140,10 @@ class AnayasaDocumentMarkdown(BaseModel):
|
||||
# --- 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[]).")
|
||||
"""Model for Anayasa Mahkemesi (Bireysel Başvuru) search request."""
|
||||
keywords: Optional[List[str]] = Field(default_factory=list, description="Keywords joined into the full-text query.")
|
||||
page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for the report (page). Default is 1.")
|
||||
results_per_page: int = Field(10, ge=1, le=100, description="Results per page.")
|
||||
|
||||
class AnayasaBireyselReportDecisionDetail(BaseModel):
|
||||
"""Details of a specific right/claim within a Bireysel Başvuru decision summary in a report."""
|
||||
@@ -191,27 +192,20 @@ class AnayasaBireyselBasvuruDocumentMarkdown(BaseModel):
|
||||
|
||||
# --- Unified Models ---
|
||||
class AnayasaUnifiedSearchRequest(BaseModel):
|
||||
"""Unified search request for both Norm Denetimi and Bireysel Başvuru."""
|
||||
"""Unified search request for both Norm Denetimi and Bireysel Başvuru.
|
||||
|
||||
The KBB API only exposes a single free-text "query" field plus pagination,
|
||||
so the keyword lists below are flattened into that query.
|
||||
"""
|
||||
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")
|
||||
keywords: List[str] = Field(default_factory=list, description="Keywords to search for (joined into a single full-text query)")
|
||||
keywords_all: List[str] = Field(default_factory=list, description="Additional keywords to include in the query")
|
||||
keywords_any: List[str] = Field(default_factory=list, description="Additional alternative keywords to include in the query")
|
||||
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")
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
# anayasa_mcp_module/unified_client.py
|
||||
# Unified client for both Norm Denetimi and Bireysel Başvuru
|
||||
# Unified client for both Norm Denetimi and Bireysel Başvuru, backed by the new
|
||||
# KBB JSON API. Routing between the two is by the "decision_type" discriminator
|
||||
# on search, and by the document URL (?type=...) on document retrieval.
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .models import (
|
||||
AnayasaUnifiedSearchRequest,
|
||||
AnayasaUnifiedSearchResult,
|
||||
AnayasaUnifiedDocumentMarkdown,
|
||||
# Removed AnayasaDecisionTypeEnum - now using string literals
|
||||
AnayasaNormDenetimiSearchRequest,
|
||||
AnayasaBireyselReportSearchRequest
|
||||
AnayasaBireyselReportSearchRequest,
|
||||
)
|
||||
from .client import AnayasaMahkemesiApiClient
|
||||
from .bireysel_client import AnayasaBireyselBasvuruApiClient
|
||||
from .api_client import (
|
||||
KARAR_TIPI_NORM,
|
||||
KARAR_TIPI_BIREYSEL,
|
||||
parse_document_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_anayasa_document_url(document_url: str) -> Tuple[Optional[str], str]:
|
||||
"""Detect the AYM decision type from a document URL.
|
||||
|
||||
Returns ``(decision_type, document_url)`` where ``decision_type`` is
|
||||
``"norm_denetimi"``, ``"bireysel_basvuru"``, or ``None`` if it cannot be
|
||||
determined. The URL is returned unchanged (kept for backwards compatibility
|
||||
with callers that expect a possibly-normalized URL).
|
||||
"""
|
||||
karar_tipi, _ = parse_document_url(document_url)
|
||||
if karar_tipi == KARAR_TIPI_NORM:
|
||||
return "norm_denetimi", document_url
|
||||
if karar_tipi == KARAR_TIPI_BIREYSEL:
|
||||
return "bireysel_basvuru", document_url
|
||||
return None, document_url
|
||||
|
||||
|
||||
class AnayasaUnifiedClient:
|
||||
"""Unified client that handles both Norm Denetimi and Bireysel Başvuru searches."""
|
||||
|
||||
@@ -26,97 +48,71 @@ class AnayasaUnifiedClient:
|
||||
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."""
|
||||
"""Unified search that routes to the 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
|
||||
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,
|
||||
decisions=[d.model_dump() for d in result.decisions],
|
||||
total_records_found=result.total_records_found,
|
||||
retrieved_page_number=result.retrieved_page_number
|
||||
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,
|
||||
keywords=params.keywords or params.keywords_all,
|
||||
page_to_fetch=params.page_to_fetch,
|
||||
results_per_page=params.results_per_page
|
||||
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,
|
||||
decisions=[d.model_dump() for d in result.decisions],
|
||||
total_records_found=result.total_records_found,
|
||||
retrieved_page_number=result.retrieved_page_number
|
||||
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."""
|
||||
"""Unified document retrieval that auto-detects the decision type from the URL."""
|
||||
|
||||
# Auto-detect decision type based on URL
|
||||
parsed_url = urlparse(document_url)
|
||||
decision_type, _ = normalize_anayasa_document_url(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
|
||||
if decision_type == "bireysel_basvuru":
|
||||
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(),
|
||||
document_data=result.model_dump(mode="json"),
|
||||
markdown_chunk=result.markdown_chunk,
|
||||
current_page=result.current_page,
|
||||
total_pages=result.total_pages,
|
||||
is_paginated=result.is_paginated
|
||||
is_paginated=result.is_paginated,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Cannot determine document type from URL: {document_url}")
|
||||
# Default to norm_denetimi (also covers explicit norm_denetimi detection).
|
||||
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(mode="json"),
|
||||
markdown_chunk=result.markdown_chunk,
|
||||
current_page=result.current_page,
|
||||
total_pages=result.total_pages,
|
||||
is_paginated=result.is_paginated,
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
ASGI application for Yargı MCP Server (simple deployment variant).
|
||||
|
||||
This is a minimal ASGI application that can be run with:
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
The MCP server will be available at:
|
||||
http://localhost:8000/mcp/
|
||||
|
||||
For the FastAPI-wrapped variant with CORS and extra metadata routes,
|
||||
see asgi_app.py instead.
|
||||
"""
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from mcp_server_main import create_app
|
||||
|
||||
mcp = create_app()
|
||||
|
||||
|
||||
@mcp.custom_route("/health", methods=["GET"])
|
||||
async def health_check(request):
|
||||
"""Health check endpoint for monitoring services (Fly.io, Render, etc.)."""
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"service": "Yargı MCP Server",
|
||||
"version": "0.2.1",
|
||||
})
|
||||
|
||||
|
||||
# Create ASGI app directly from FastMCP server
|
||||
app = mcp.http_app()
|
||||
|
||||
# Endpoints:
|
||||
# - /mcp/ - MCP server (Streamable HTTP transport, default FastMCP path)
|
||||
# - /health - Health check for monitoring
|
||||
+19
-461
@@ -2,98 +2,32 @@
|
||||
ASGI application for Yargı MCP Server
|
||||
|
||||
This module provides ASGI/HTTP access to the Yargı MCP server,
|
||||
allowing it to be deployed as a web service with FastAPI wrapper
|
||||
for OAuth integration and proper middleware support.
|
||||
allowing it to be deployed as a web service with FastAPI wrapper.
|
||||
|
||||
Usage:
|
||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import FastAPI, Request, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, HTMLResponse, Response
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Import the proper create_app function that includes all middleware
|
||||
from mcp_server_main import create_app
|
||||
|
||||
# Conditional auth-related imports (only if auth enabled)
|
||||
_auth_check = os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
|
||||
if _auth_check:
|
||||
# Import MCP Auth HTTP adapter (OAuth endpoints)
|
||||
try:
|
||||
from mcp_auth_http_simple import router as mcp_auth_router
|
||||
except ImportError:
|
||||
mcp_auth_router = None
|
||||
|
||||
# Import Stripe webhook router
|
||||
try:
|
||||
from stripe_webhook import router as stripe_router
|
||||
except ImportError:
|
||||
stripe_router = None
|
||||
else:
|
||||
mcp_auth_router = None
|
||||
stripe_router = None
|
||||
|
||||
# OAuth configuration from environment variables
|
||||
CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://clerk.yargimcp.com")
|
||||
BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com")
|
||||
CLERK_SECRET_KEY = os.getenv("CLERK_SECRET_KEY")
|
||||
CLERK_PUBLISHABLE_KEY = os.getenv("CLERK_PUBLISHABLE_KEY")
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure CORS and Auth middleware
|
||||
# Configure CORS
|
||||
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
||||
|
||||
# Import FastMCP Bearer Auth Provider
|
||||
from fastmcp.server.auth import BearerAuthProvider
|
||||
from fastmcp.server.auth.providers.bearer import RSAKeyPair
|
||||
# Create MCP app
|
||||
mcp_server = create_app()
|
||||
|
||||
# Import Clerk SDK at module level for performance
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
CLERK_SDK_AVAILABLE = False
|
||||
logger.warning("Clerk SDK not available - falling back to development mode")
|
||||
|
||||
# Configure Bearer token authentication based on ENABLE_AUTH
|
||||
auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
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=None,
|
||||
algorithm="RS256",
|
||||
audience=None,
|
||||
required_scopes=[]
|
||||
)
|
||||
else:
|
||||
# Development: Generate RSA key pair for testing
|
||||
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"]
|
||||
)
|
||||
|
||||
# Create MCP app with Bearer authentication
|
||||
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
|
||||
# Create MCP Starlette sub-application
|
||||
mcp_app = mcp_server.http_app(path="/")
|
||||
|
||||
|
||||
@@ -119,46 +53,22 @@ custom_middleware = [
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"],
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-Request-ID", "X-Session-ID"],
|
||||
),
|
||||
]
|
||||
|
||||
# Create FastAPI wrapper application
|
||||
app = FastAPI(
|
||||
title="Yargı MCP Server",
|
||||
description="MCP server for Turkish legal databases with OAuth authentication",
|
||||
description="MCP server for Turkish legal databases",
|
||||
version="0.1.0",
|
||||
middleware=custom_middleware,
|
||||
default_response_class=UTF8JSONResponse, # Use UTF-8 JSON encoder
|
||||
redirect_slashes=False # Disable to prevent 307 redirects on /mcp endpoint
|
||||
default_response_class=UTF8JSONResponse,
|
||||
redirect_slashes=False,
|
||||
)
|
||||
|
||||
# Add auth-related routers to FastAPI (only if available)
|
||||
if stripe_router:
|
||||
app.include_router(stripe_router, prefix="/api/stripe")
|
||||
|
||||
if mcp_auth_router:
|
||||
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"""
|
||||
@@ -167,112 +77,26 @@ async def health_check():
|
||||
"service": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
"tools_count": len(mcp_server._tool_manager._tools),
|
||||
"auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
||||
}
|
||||
|
||||
# Add explicit redirect for /mcp to /mcp/ with method preservation
|
||||
|
||||
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
|
||||
async def redirect_to_slash(request: Request):
|
||||
"""Redirect /mcp to /mcp/ preserving HTTP method with 308"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/mcp/", status_code=308)
|
||||
|
||||
# MCP mount at /mcp handles path routing correctly
|
||||
|
||||
# IMPORTANT: Add FastAPI endpoints BEFORE mounting MCP app
|
||||
# Otherwise mount at root will catch all requests
|
||||
|
||||
# Debug endpoint to test routing
|
||||
@app.get("/debug/test")
|
||||
async def debug_test():
|
||||
"""Debug endpoint to test if FastAPI routes work"""
|
||||
return {"message": "FastAPI routes working", "debug": True}
|
||||
|
||||
# Clerk CORS proxy endpoints
|
||||
@app.api_route("/clerk-proxy/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def clerk_cors_proxy(request: Request, path: str):
|
||||
"""
|
||||
Proxy requests to Clerk to bypass CORS restrictions.
|
||||
Forwards requests from Claude AI to clerk.yargimcp.com with proper CORS headers.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
# Build target URL
|
||||
clerk_url = f"https://clerk.yargimcp.com/{path}"
|
||||
|
||||
# Forward query parameters
|
||||
if request.url.query:
|
||||
clerk_url += f"?{request.url.query}"
|
||||
|
||||
# Copy headers (exclude host/origin)
|
||||
headers = dict(request.headers)
|
||||
headers.pop('host', None)
|
||||
headers.pop('origin', None)
|
||||
headers['origin'] = 'https://yargimcp.com' # Use our frontend domain
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Forward the request to Clerk
|
||||
if request.method == "OPTIONS":
|
||||
# Handle preflight
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=clerk_url,
|
||||
headers=headers
|
||||
)
|
||||
else:
|
||||
# Forward body for POST/PUT requests
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
body = await request.body()
|
||||
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=clerk_url,
|
||||
headers=headers,
|
||||
content=body
|
||||
)
|
||||
|
||||
# Create response with CORS headers
|
||||
response_headers = dict(response.headers)
|
||||
response_headers.update({
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, Origin, X-Requested-With",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Max-Age": "86400"
|
||||
})
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"error": "proxy_error", "message": str(e)},
|
||||
status_code=500,
|
||||
headers={"Access-Control-Allow-Origin": "*"}
|
||||
)
|
||||
|
||||
# FastAPI root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with service information"""
|
||||
return {
|
||||
"service": "Yargı MCP Server",
|
||||
"description": "MCP server for Turkish legal databases with OAuth authentication",
|
||||
"description": "MCP server for Turkish legal databases",
|
||||
"endpoints": {
|
||||
"mcp": "/mcp",
|
||||
"health": "/health",
|
||||
"status": "/status",
|
||||
"stripe_webhook": "/api/stripe/webhook",
|
||||
"oauth_login": "/auth/login",
|
||||
"oauth_callback": "/auth/callback",
|
||||
"oauth_google": "/auth/google/login",
|
||||
"user_info": "/auth/user"
|
||||
},
|
||||
"transports": {
|
||||
"http": "/mcp"
|
||||
@@ -288,140 +112,13 @@ async def root():
|
||||
"Sayıştay (Court of Accounts)",
|
||||
"KVKK (Personal Data Protection Authority)",
|
||||
"BDDK (Banking Regulation and Supervision Agency)",
|
||||
"Bedesten API (Multiple courts)"
|
||||
"BTK (Information and Communication Technologies Authority)",
|
||||
"Bedesten API (Multiple courts)",
|
||||
"Sigorta Tahkim Komisyonu (Insurance Arbitration Commission)",
|
||||
],
|
||||
"authentication": {
|
||||
"enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true",
|
||||
"type": "OAuth 2.0 via Clerk",
|
||||
"issuer": CLERK_ISSUER,
|
||||
"providers": ["google"],
|
||||
"flow": "authorization_code"
|
||||
}
|
||||
}
|
||||
|
||||
# OAuth 2.0 Authorization Server Metadata - MCP standard location
|
||||
@app.get("/.well-known/oauth-authorization-server")
|
||||
async def oauth_authorization_server_root():
|
||||
"""OAuth 2.0 Authorization Server Metadata - root level for compatibility"""
|
||||
return {
|
||||
"issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration
|
||||
"authorization_endpoint": f"{BASE_URL}/auth/login",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
|
||||
"scopes_supported": ["read", "search", "openid", "profile", "email"],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"service_documentation": f"{BASE_URL}/mcp",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"resource_documentation": f"{BASE_URL}/mcp"
|
||||
}
|
||||
|
||||
# Claude AI MCP specific endpoint format - suffix versions
|
||||
@app.get("/.well-known/oauth-authorization-server/mcp")
|
||||
async def oauth_authorization_server_mcp_suffix():
|
||||
"""OAuth 2.0 Authorization Server Metadata - Claude AI MCP specific format"""
|
||||
return {
|
||||
"issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration
|
||||
"authorization_endpoint": f"{BASE_URL}/auth/login",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
|
||||
"scopes_supported": ["read", "search", "openid", "profile", "email"],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"service_documentation": f"{BASE_URL}/mcp",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"resource_documentation": f"{BASE_URL}/mcp"
|
||||
}
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp")
|
||||
async def oauth_protected_resource_mcp_suffix():
|
||||
"""OAuth 2.0 Protected Resource Metadata - Claude AI MCP specific format"""
|
||||
return {
|
||||
"resource": BASE_URL,
|
||||
"authorization_servers": [
|
||||
BASE_URL
|
||||
],
|
||||
"scopes_supported": ["read", "search"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{BASE_URL}/mcp",
|
||||
"resource_policy_uri": f"{BASE_URL}/privacy"
|
||||
}
|
||||
|
||||
# OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required
|
||||
@app.get("/.well-known/oauth-protected-resource")
|
||||
async def oauth_protected_resource():
|
||||
"""OAuth 2.0 Protected Resource Metadata as required by MCP spec"""
|
||||
return {
|
||||
"resource": BASE_URL,
|
||||
"authorization_servers": [
|
||||
BASE_URL
|
||||
],
|
||||
"scopes_supported": ["read", "search"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{BASE_URL}/mcp",
|
||||
"resource_policy_uri": f"{BASE_URL}/privacy"
|
||||
}
|
||||
|
||||
# Standard well-known discovery endpoint
|
||||
@app.get("/.well-known/mcp")
|
||||
async def well_known_mcp():
|
||||
"""Standard MCP discovery endpoint"""
|
||||
return {
|
||||
"mcp_server": {
|
||||
"name": "Yargı MCP Server",
|
||||
"version": "0.1.0",
|
||||
"endpoint": f"{BASE_URL}/mcp",
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"authorization_url": f"{BASE_URL}/auth/login",
|
||||
"scopes": ["read", "search"]
|
||||
},
|
||||
"capabilities": ["tools", "resources"],
|
||||
"tools_count": len(mcp_server._tool_manager._tools)
|
||||
}
|
||||
}
|
||||
|
||||
# MCP Discovery endpoint for ChatGPT integration
|
||||
@app.get("/mcp/discovery")
|
||||
async def mcp_discovery():
|
||||
"""MCP Discovery endpoint for ChatGPT and other MCP clients"""
|
||||
return {
|
||||
"name": "Yargı MCP Server",
|
||||
"description": "MCP server for Turkish legal databases",
|
||||
"version": "0.1.0",
|
||||
"protocol": "mcp",
|
||||
"transport": "http",
|
||||
"endpoint": "/mcp",
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"authorization_url": "/auth/login",
|
||||
"token_url": "/token",
|
||||
"scopes": ["read", "search"],
|
||||
"provider": "clerk"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": True,
|
||||
"resources": True,
|
||||
"prompts": False
|
||||
},
|
||||
"tools_count": len(mcp_server._tool_manager._tools),
|
||||
"contact": {
|
||||
"url": BASE_URL,
|
||||
"email": "support@yargi-mcp.dev"
|
||||
}
|
||||
}
|
||||
|
||||
# FastAPI status endpoint
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
"""Status endpoint with detailed information"""
|
||||
@@ -437,149 +134,10 @@ async def status():
|
||||
"tools": tools,
|
||||
"total_tools": len(tools),
|
||||
"transport": "streamable_http",
|
||||
"architecture": "FastAPI wrapper + MCP Starlette sub-app",
|
||||
"auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled"
|
||||
}
|
||||
|
||||
# Simplified OAuth session validation for callback endpoints only
|
||||
async def validate_clerk_session_for_oauth(request: Request, clerk_token: str = None) -> str:
|
||||
"""Validate Clerk session for OAuth callback endpoints only (not for MCP endpoints)"""
|
||||
|
||||
try:
|
||||
# Use Clerk SDK if available
|
||||
if not CLERK_SDK_AVAILABLE:
|
||||
raise ImportError("Clerk SDK not available")
|
||||
clerk = Clerk(bearer_auth=CLERK_SECRET_KEY)
|
||||
|
||||
# Try JWT token first (from URL parameter)
|
||||
if clerk_token:
|
||||
try:
|
||||
return "oauth_user_from_token"
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Fallback to cookie validation
|
||||
clerk_session = request.cookies.get("__session")
|
||||
if not clerk_session:
|
||||
raise HTTPException(status_code=401, detail="No Clerk session found")
|
||||
|
||||
# Validate session with Clerk
|
||||
session = clerk.sessions.verify_session(clerk_session)
|
||||
return session.user_id
|
||||
|
||||
except ImportError:
|
||||
return "dev_user_123"
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"OAuth session validation failed: {str(e)}")
|
||||
|
||||
# MCP OAuth Callback Endpoint
|
||||
@app.get("/auth/mcp-callback")
|
||||
async def mcp_oauth_callback(request: Request, clerk_token: str = Query(None)):
|
||||
"""Handle OAuth callback for MCP token generation"""
|
||||
|
||||
try:
|
||||
# Validate Clerk session with JWT token support
|
||||
user_id = await validate_clerk_session_for_oauth(request, clerk_token)
|
||||
|
||||
# Return success response
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Successful</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.success {{ color: #28a745; }}
|
||||
.token {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; word-break: break-all; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">✅ MCP Connection Successful!</h1>
|
||||
<p>Your Yargı MCP integration is now active.</p>
|
||||
<div class="token">
|
||||
<strong>Authentication:</strong><br>
|
||||
<code>Use your Clerk JWT token directly with Bearer authentication</code>
|
||||
</div>
|
||||
<p>You can now close this window and return to your MCP client.</p>
|
||||
<script>
|
||||
// Try to close the popup if opened as such
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{
|
||||
type: 'MCP_AUTH_SUCCESS',
|
||||
token: 'use_clerk_jwt_token'
|
||||
}}, '*');
|
||||
setTimeout(() => window.close(), 3000);
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
except HTTPException as e:
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Failed</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
.debug {{ background: #f8f9fa; padding: 10px; margin: 20px 0; border-radius: 5px; font-family: monospace; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">❌ MCP Connection Failed</h1>
|
||||
<p>{e.detail}</p>
|
||||
<div class="debug">
|
||||
<strong>Debug Info:</strong><br>
|
||||
Clerk Token: {'✅ Provided' if clerk_token else '❌ Missing'}<br>
|
||||
Error: {e.detail}<br>
|
||||
Status: {e.status_code}
|
||||
</div>
|
||||
<p>Please try again or contact support.</p>
|
||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
||||
</body>
|
||||
</html>
|
||||
""", status_code=e.status_code)
|
||||
except Exception as e:
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Error</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">❌ Unexpected Error</h1>
|
||||
<p>An unexpected error occurred during authentication.</p>
|
||||
<p>Error: {str(e)}</p>
|
||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
||||
</body>
|
||||
</html>
|
||||
""", status_code=500)
|
||||
|
||||
# OAuth2 Token Endpoint - Now uses Clerk JWT tokens directly
|
||||
@app.post("/auth/mcp-token")
|
||||
async def mcp_token_endpoint(request: Request):
|
||||
"""OAuth2 token endpoint for MCP clients - returns Clerk JWT token info"""
|
||||
try:
|
||||
# Validate Clerk session
|
||||
user_id = await validate_clerk_session_for_oauth(request)
|
||||
|
||||
return {
|
||||
"message": "Use your Clerk JWT token directly with Bearer authentication",
|
||||
"token_type": "Bearer",
|
||||
"scope": "yargi.read",
|
||||
"user_id": user_id,
|
||||
"instructions": "Include 'Authorization: Bearer YOUR_CLERK_JWT_TOKEN' in your requests"
|
||||
}
|
||||
except HTTPException as e:
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content={"error": "invalid_request", "error_description": e.detail}
|
||||
)
|
||||
|
||||
# Mount MCP app at /mcp/ with trailing slash
|
||||
# Mount MCP app at /mcp/
|
||||
app.mount("/mcp/", mcp_app)
|
||||
|
||||
# Set the lifespan context after mounting
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# bddk_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
@@ -210,14 +211,19 @@ class BddkApiClient:
|
||||
|
||||
# Convert to Markdown based on content type
|
||||
if "pdf" in content_type:
|
||||
# Handle PDF documents
|
||||
# Handle PDF documents. markitdown is sync; offload to thread
|
||||
# so PDF parsing doesn't block the event-loop / other requests.
|
||||
pdf_stream = io.BytesIO(response.content)
|
||||
result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf")
|
||||
result = await asyncio.to_thread(
|
||||
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
|
||||
)
|
||||
markdown_content = result.text_content
|
||||
else:
|
||||
# Handle HTML documents
|
||||
# Handle HTML documents (sync conversion offloaded to thread)
|
||||
html_stream = io.BytesIO(response.content)
|
||||
result = self.markitdown.convert_stream(html_stream, file_extension=".html")
|
||||
result = await asyncio.to_thread(
|
||||
self.markitdown.convert_stream, html_stream, file_extension=".html"
|
||||
)
|
||||
markdown_content = result.text_content
|
||||
|
||||
# Clean up the markdown content
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# bedesten_mcp_module/client.py
|
||||
|
||||
import httpx
|
||||
import asyncio
|
||||
import base64
|
||||
from typing import Optional
|
||||
import logging
|
||||
from markitdown import MarkItDown
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
BedestenSearchRequest, BedestenSearchResponse,
|
||||
@@ -16,6 +20,72 @@ from .enums import get_full_birim_adi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BedestenRateLimited(Exception):
|
||||
"""Raised when the local rate-limit bucket would block longer than allowed.
|
||||
|
||||
Carries the suggested retry-after (seconds) so callers can surface a
|
||||
structured 429-style response to the MCP client instead of silently
|
||||
blocking the event-loop slot for the full bucket-pause window.
|
||||
"""
|
||||
|
||||
def __init__(self, retry_after: float) -> None:
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"local bucket would block {retry_after:.1f}s")
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Asyncio token bucket with explicit back-pressure.
|
||||
|
||||
Measured Bedesten limit (per source IP, 2026-05-08): 10 requests per
|
||||
rolling 30s window with full refill — equivalent to capacity=10,
|
||||
refill_rate=1 token / 3s. Even with margin, 429s still leak through
|
||||
when other clients share the egress IP, so we also expose
|
||||
``penalize_until`` so callers can freeze the bucket when the server
|
||||
actually returns 429 (Retry-After).
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int, refill_per_s: float) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.refill_per_s = float(refill_per_s)
|
||||
self._tokens = float(capacity)
|
||||
self._last = time.monotonic()
|
||||
self._not_before = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, max_wait: Optional[float] = None) -> None:
|
||||
"""Acquire one token. If ``max_wait`` is set and the next wait would
|
||||
exceed it, raise :class:`BedestenRateLimited` immediately instead of
|
||||
sleeping — keeps a single rate-limited request from holding the
|
||||
worker-slot for the full bucket-pause window (up to ~30s on 429)."""
|
||||
deadline = (time.monotonic() + max_wait) if max_wait is not None else None
|
||||
while True:
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if now < self._not_before:
|
||||
wait_s = self._not_before - now
|
||||
else:
|
||||
self._tokens = min(
|
||||
self.capacity,
|
||||
self._tokens + (now - self._last) * self.refill_per_s,
|
||||
)
|
||||
self._last = now
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
wait_s = (1.0 - self._tokens) / self.refill_per_s
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if wait_s > remaining:
|
||||
raise BedestenRateLimited(retry_after=wait_s)
|
||||
await asyncio.sleep(wait_s)
|
||||
|
||||
def penalize_until(self, monotonic_deadline: float) -> None:
|
||||
"""Pause the bucket until ``monotonic_deadline`` (drains tokens)."""
|
||||
self._not_before = max(self._not_before, monotonic_deadline)
|
||||
self._tokens = 0.0
|
||||
self._last = time.monotonic()
|
||||
|
||||
class BedestenApiClient:
|
||||
"""
|
||||
API Client for Bedesten (bedesten.adalet.gov.tr) - Alternative legal decision search system.
|
||||
@@ -25,6 +95,17 @@ class BedestenApiClient:
|
||||
SEARCH_ENDPOINT = "/emsal-karar/searchDocuments"
|
||||
DOCUMENT_ENDPOINT = "/emsal-karar/getDocumentContent"
|
||||
|
||||
# Measured limit (per source IP): 10 requests per 30s window with full
|
||||
# refill (≈ 1 token / 3s steady). We default to 1-token capacity and
|
||||
# 3.5s spacing (no burst, ~14% safety margin). Override via env:
|
||||
# BEDESTEN_RATE_CAPACITY (default 1)
|
||||
# BEDESTEN_RATE_REFILL_S (default 3.5; seconds per token)
|
||||
# BEDESTEN_RATE_MAX_WAIT_S (default 8.0; max seconds to wait in the
|
||||
# local bucket before returning a structured 429 to the caller)
|
||||
_DEFAULT_CAPACITY = int(os.getenv("BEDESTEN_RATE_CAPACITY", "1"))
|
||||
_DEFAULT_REFILL_S = float(os.getenv("BEDESTEN_RATE_REFILL_S", "3.5"))
|
||||
_DEFAULT_MAX_WAIT_S = float(os.getenv("BEDESTEN_RATE_MAX_WAIT_S", "8.0"))
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
@@ -42,6 +123,24 @@ class BedestenApiClient:
|
||||
},
|
||||
timeout=request_timeout
|
||||
)
|
||||
self._bucket = _TokenBucket(
|
||||
capacity=self._DEFAULT_CAPACITY,
|
||||
refill_per_s=1.0 / self._DEFAULT_REFILL_S,
|
||||
)
|
||||
|
||||
def _handle_429(self, response: httpx.Response, op: str) -> None:
|
||||
"""Apply back-pressure to the shared bucket based on Retry-After."""
|
||||
retry_after_raw = response.headers.get("Retry-After", "")
|
||||
try:
|
||||
retry_after = float(retry_after_raw)
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 30.0
|
||||
# Cap penalty so a hostile/buggy server can't freeze us indefinitely.
|
||||
retry_after = max(1.0, min(retry_after, 60.0))
|
||||
self._bucket.penalize_until(time.monotonic() + retry_after + 0.5)
|
||||
logger.warning(
|
||||
f"BedestenApiClient: 429 on {op}; bucket paused {retry_after + 0.5:.1f}s"
|
||||
)
|
||||
|
||||
async def search_documents(self, search_request: BedestenSearchRequest) -> BedestenSearchResponse:
|
||||
"""
|
||||
@@ -63,10 +162,13 @@ class BedestenApiClient:
|
||||
if not request_dict["data"]["birimAdi"]: # Remove if empty string
|
||||
del request_dict["data"]["birimAdi"]
|
||||
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.post(
|
||||
self.SEARCH_ENDPOINT,
|
||||
json=request_dict
|
||||
)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, "search")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
|
||||
@@ -94,10 +196,13 @@ class BedestenApiClient:
|
||||
)
|
||||
|
||||
# Get document
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.post(
|
||||
self.DOCUMENT_ENDPOINT,
|
||||
json=doc_request.model_dump()
|
||||
)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, f"document {document_id}")
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
doc_response = BedestenDocumentResponse(**response_json)
|
||||
@@ -122,12 +227,20 @@ class BedestenApiClient:
|
||||
|
||||
logger.info(f"BedestenApiClient: Document mime type: {mime_type}")
|
||||
|
||||
# Convert to markdown based on mime type
|
||||
# Convert to markdown based on mime type. markitdown is sync and
|
||||
# PDF parsing in particular can block the event-loop for seconds,
|
||||
# which on a single-worker uvicorn deployment stalls every other
|
||||
# in-flight MCP request and new TLS handshakes. Offload to a
|
||||
# thread so the event-loop stays responsive.
|
||||
if mime_type == "text/html":
|
||||
html_content = content_bytes.decode('utf-8')
|
||||
markdown_content = self._convert_html_to_markdown(html_content)
|
||||
markdown_content = await asyncio.to_thread(
|
||||
self._convert_html_to_markdown, html_content
|
||||
)
|
||||
elif mime_type == "application/pdf":
|
||||
markdown_content = self._convert_pdf_to_markdown(content_bytes)
|
||||
markdown_content = await asyncio.to_thread(
|
||||
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."
|
||||
@@ -135,7 +248,7 @@ class BedestenApiClient:
|
||||
return BedestenDocumentMarkdown(
|
||||
documentId=document_id,
|
||||
markdown_content=markdown_content,
|
||||
source_url=f"{self.BASE_URL}/document/{document_id}",
|
||||
source_url=f"https://mevzuat.adalet.gov.tr/ictihat/{document_id}",
|
||||
mime_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# btk_mcp_module/__init__.py
|
||||
|
||||
from .client import BtkApiClient
|
||||
from .models import (
|
||||
BtkDocumentMarkdown,
|
||||
BtkDecisionSummary,
|
||||
BtkSearchRequest,
|
||||
BtkSearchResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BtkApiClient",
|
||||
"BtkDocumentMarkdown",
|
||||
"BtkDecisionSummary",
|
||||
"BtkSearchRequest",
|
||||
"BtkSearchResult",
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
# btk_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from markitdown import MarkItDown
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from .models import (
|
||||
BtkDecisionSummary,
|
||||
BtkDocumentMarkdown,
|
||||
BtkSearchRequest,
|
||||
BtkSearchResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
|
||||
class BtkApiClient:
|
||||
"""Client for BTK (Information and Communication Technologies Authority) decisions."""
|
||||
|
||||
BASE_URL = "https://www.btk.tr"
|
||||
API_PATH = "/api/content/board-decisions"
|
||||
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": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;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,
|
||||
)
|
||||
self.markitdown = MarkItDown(enable_plugins=False)
|
||||
|
||||
def _build_search_params(self, request: BtkSearchRequest) -> Dict[str, str]:
|
||||
params: Dict[str, str] = {
|
||||
"page": str(request.page),
|
||||
"limit": str(request.pageSize),
|
||||
"locale": "tr",
|
||||
}
|
||||
|
||||
if request.keywords.strip():
|
||||
params["search"] = request.keywords.strip()
|
||||
if request.decision_no.strip():
|
||||
params["filter[decision_no]"] = request.decision_no.strip()
|
||||
if request.decision_date.strip():
|
||||
params["filter[decision_date]"] = request.decision_date.strip()
|
||||
if request.publication_date.strip():
|
||||
params["date_from"] = request.publication_date.strip()
|
||||
params["date_to"] = request.publication_date.strip()
|
||||
if request.relevant_unit.strip():
|
||||
params["filter[relevant_unit]"] = request.relevant_unit.strip()
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _format_date(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized).date().isoformat()
|
||||
except ValueError:
|
||||
return value[:10] if len(value) >= 10 else value
|
||||
|
||||
@staticmethod
|
||||
def _extract_pdf_url(file_data: Any) -> Optional[str]:
|
||||
if not isinstance(file_data, dict):
|
||||
return None
|
||||
for key in ("url", "storageUrl"):
|
||||
value = file_data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
def _parse_decision(self, item: Dict[str, Any]) -> BtkDecisionSummary:
|
||||
data = item.get("data") if isinstance(item.get("data"), dict) else {}
|
||||
file_data = data.get("file_url") if isinstance(data.get("file_url"), dict) else {}
|
||||
pdf_url = self._extract_pdf_url(file_data)
|
||||
|
||||
return BtkDecisionSummary(
|
||||
id=str(item.get("id") or ""),
|
||||
title=str(item.get("title") or ""),
|
||||
slug=str(item.get("slug") or ""),
|
||||
decision_no=data.get("decision_no"),
|
||||
decision_date=self._format_date(data.get("decision_date")),
|
||||
publication_date=self._format_date(item.get("publishedAt")),
|
||||
relevant_unit=data.get("relevant_unit"),
|
||||
pdf_url=HttpUrl(pdf_url) if pdf_url else None,
|
||||
original_filename=file_data.get("originalFilename") or file_data.get("filename"),
|
||||
)
|
||||
|
||||
async def search_decisions(self, request: BtkSearchRequest) -> BtkSearchResult:
|
||||
params = self._build_search_params(request)
|
||||
query_string = urlencode(params, doseq=True)
|
||||
query_url = f"{self.BASE_URL}{self.API_PATH}?{query_string}"
|
||||
logger.info("BtkApiClient: searching BTK decisions with URL: %s", query_url)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(self.API_PATH, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as e:
|
||||
logger.error("BtkApiClient: error searching decisions: %s", e, exc_info=True)
|
||||
raise Exception(f"Failed to search BTK decisions: {str(e)}")
|
||||
|
||||
raw_items = payload.get("data") if isinstance(payload, dict) else []
|
||||
decisions = [
|
||||
self._parse_decision(item)
|
||||
for item in raw_items
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
|
||||
|
||||
return BtkSearchResult(
|
||||
decisions=decisions,
|
||||
total_results=int(meta.get("total") or len(decisions)),
|
||||
page=int(meta.get("page") or request.page),
|
||||
pageSize=int(meta.get("limit") or request.pageSize),
|
||||
total_pages=int(meta.get("totalPages") or 0),
|
||||
query_url=query_url,
|
||||
)
|
||||
|
||||
def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> str:
|
||||
pdf_stream = io.BytesIO(pdf_bytes)
|
||||
result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf")
|
||||
return (result.text_content or "").strip()
|
||||
|
||||
async def get_document_markdown(self, pdf_url: str, page_number: int = 1) -> BtkDocumentMarkdown:
|
||||
if not pdf_url or not pdf_url.strip():
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(f"{self.BASE_URL}/kurul-kararlari"),
|
||||
markdown_chunk=None,
|
||||
current_page=max(1, page_number),
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="pdf_url is required.",
|
||||
)
|
||||
|
||||
pdf_url = pdf_url.strip()
|
||||
if not pdf_url.startswith(("https://www.btk.gov.tr/", "https://www.btk.tr/")):
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(pdf_url),
|
||||
markdown_chunk=None,
|
||||
current_page=max(1, page_number),
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="Invalid BTK document URL. URL must start with https://www.btk.gov.tr/ or https://www.btk.tr/.",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(pdf_url)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "pdf" not in content_type and not pdf_url.lower().endswith(".pdf"):
|
||||
raise Exception(f"Expected a PDF document, got content type: {content_type}")
|
||||
|
||||
markdown_content = await asyncio.to_thread(self._convert_pdf_to_markdown, response.content)
|
||||
total_pages = max(1, math.ceil(len(markdown_content) / self.DOCUMENT_MARKDOWN_CHUNK_SIZE))
|
||||
current_page = max(1, min(page_number, total_pages))
|
||||
start_index = (current_page - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(pdf_url),
|
||||
markdown_chunk=markdown_content[start_index:end_index],
|
||||
current_page=current_page,
|
||||
total_pages=total_pages,
|
||||
is_paginated=total_pages > 1,
|
||||
error_message=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("BtkApiClient: error retrieving BTK PDF %s: %s", pdf_url, e, exc_info=True)
|
||||
return BtkDocumentMarkdown(
|
||||
source_url=HttpUrl(pdf_url),
|
||||
markdown_chunk=None,
|
||||
current_page=max(1, page_number),
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=f"Failed to retrieve BTK document: {str(e)}",
|
||||
)
|
||||
|
||||
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("BtkApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,58 @@
|
||||
# btk_mcp_module/models.py
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class BtkSearchRequest(BaseModel):
|
||||
"""Request model for searching BTK Board decisions."""
|
||||
|
||||
keywords: str = Field("", description="Keywords searched in decision title/content metadata.")
|
||||
decision_no: str = Field("", description="BTK decision number, e.g. 2026/DK-THD/91.")
|
||||
decision_date: str = Field("", description="Decision date as YYYY-MM-DD.")
|
||||
publication_date: str = Field("", description="Publication date as YYYY-MM-DD.")
|
||||
relevant_unit: str = Field("", description="Related BTK department name.")
|
||||
page: int = Field(1, ge=1, description="Page number for results.")
|
||||
pageSize: int = Field(10, ge=1, le=50, description="Results per page.")
|
||||
|
||||
|
||||
class BtkDecisionSummary(BaseModel):
|
||||
"""Summary of a BTK Board decision from search results."""
|
||||
|
||||
id: str = Field("", description="BTK content ID.")
|
||||
title: str = Field("", description="Decision title.")
|
||||
slug: str = Field("", description="BTK content slug.")
|
||||
decision_no: Optional[str] = Field(None, description="Decision number.")
|
||||
decision_date: Optional[str] = Field(None, description="Decision date.")
|
||||
publication_date: Optional[str] = Field(None, description="Publication date.")
|
||||
relevant_unit: Optional[str] = Field(None, description="Related BTK department.")
|
||||
pdf_url: Optional[HttpUrl] = Field(None, description="Direct URL of the decision PDF.")
|
||||
original_filename: Optional[str] = Field(None, description="Original PDF filename when available.")
|
||||
|
||||
|
||||
class BtkSearchResult(BaseModel):
|
||||
"""Response model for BTK Board decision search results."""
|
||||
|
||||
decisions: List[BtkDecisionSummary] = Field(default_factory=list)
|
||||
total_results: int = Field(0, description="Total number of matching results.")
|
||||
page: int = Field(1, description="Current page.")
|
||||
pageSize: int = Field(10, description="Results per page.")
|
||||
total_pages: int = Field(0, description="Total result pages.")
|
||||
query_url: str = Field("", description="BTK API URL used for the search.")
|
||||
|
||||
|
||||
class BtkDocumentMarkdown(BaseModel):
|
||||
"""BTK decision PDF converted to paginated Markdown."""
|
||||
|
||||
source_url: HttpUrl = Field(description="Source PDF URL.")
|
||||
markdown_chunk: Optional[str] = Field(None, description="A chunk of the Markdown content.")
|
||||
current_page: int = Field(1, description="Current Markdown chunk page.")
|
||||
total_pages: int = Field(1, description="Total Markdown chunk pages.")
|
||||
is_paginated: bool = Field(False, description="True when content spans multiple chunks.")
|
||||
error_message: Optional[str] = Field(None, description="Error message, if retrieval failed.")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
HttpUrl: str
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# danistay_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Dict, Any, List, Optional
|
||||
@@ -170,7 +171,7 @@ class DanistayApiClient:
|
||||
source_url=source_url
|
||||
)
|
||||
|
||||
markdown_content = self._convert_html_to_markdown_danistay(html_content_from_api)
|
||||
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_danistay, html_content_from_api)
|
||||
|
||||
return DanistayDocumentMarkdown(
|
||||
id=id,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
yargi-mcp:
|
||||
build: .
|
||||
image: yargi-mcp:latest
|
||||
container_name: yargi-mcp-server
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- API_TOKEN=${API_TOKEN:-}
|
||||
- PYTHONUNBUFFERED=1
|
||||
volumes:
|
||||
# Mount logs directory
|
||||
- ./logs:/app/logs
|
||||
# Mount .env file if it exists
|
||||
- ./.env:/app/.env:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- yargi-network
|
||||
|
||||
# Optional: Nginx reverse proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: yargi-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- yargi-mcp
|
||||
networks:
|
||||
- yargi-network
|
||||
profiles:
|
||||
- production
|
||||
|
||||
# Optional: Redis for caching (future enhancement)
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: yargi-redis
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- yargi-network
|
||||
profiles:
|
||||
- with-cache
|
||||
|
||||
networks:
|
||||
yargi-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
@@ -1,428 +0,0 @@
|
||||
# Yargı MCP Server Dağıtım Rehberi
|
||||
|
||||
Bu rehber, Yargı MCP Server'ın ASGI web servisi olarak çeşitli dağıtım seçeneklerini kapsar.
|
||||
|
||||
## İçindekiler
|
||||
|
||||
- [Hızlı Başlangıç](#hızlı-başlangıç)
|
||||
- [Yerel Geliştirme](#yerel-geliştirme)
|
||||
- [Production Dağıtımı](#production-dağıtımı)
|
||||
- [Cloud Dağıtımı](#cloud-dağıtımı)
|
||||
- [Docker Dağıtımı](#docker-dağıtımı)
|
||||
- [Güvenlik Hususları](#güvenlik-hususları)
|
||||
- [İzleme](#izleme)
|
||||
|
||||
## Hızlı Başlangıç
|
||||
|
||||
### 1. Bağımlılıkları Yükleyin
|
||||
|
||||
```bash
|
||||
# ASGI sunucusu için uvicorn yükleyin
|
||||
pip install uvicorn
|
||||
|
||||
# Veya tüm bağımlılıklarla birlikte yükleyin
|
||||
pip install -e .
|
||||
pip install uvicorn
|
||||
```
|
||||
|
||||
### 2. Sunucuyu Çalıştırın
|
||||
|
||||
```bash
|
||||
# Temel başlatma
|
||||
python run_asgi.py
|
||||
|
||||
# Veya doğrudan uvicorn ile
|
||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Sunucu şu adreslerde kullanılabilir olacak:
|
||||
- MCP Endpoint: `http://localhost:8000/mcp/`
|
||||
- Sağlık Kontrolü: `http://localhost:8000/health`
|
||||
- API Durumu: `http://localhost:8000/status`
|
||||
|
||||
## Yerel Geliştirme
|
||||
|
||||
### Otomatik Yeniden Yükleme ile Geliştirme Sunucusu
|
||||
|
||||
```bash
|
||||
python run_asgi.py --reload --log-level debug
|
||||
```
|
||||
|
||||
### FastAPI Entegrasyonunu Kullanma
|
||||
|
||||
Ek REST API endpoint'leri için:
|
||||
|
||||
```bash
|
||||
uvicorn fastapi_app:app --reload
|
||||
```
|
||||
|
||||
Bu şunları sağlar:
|
||||
- `/docs` adresinde interaktif API dokümantasyonu
|
||||
- `/api/tools` adresinde araç listesi
|
||||
- `/api/databases` adresinde veritabanı bilgileri
|
||||
|
||||
### Ortam Değişkenleri
|
||||
|
||||
`.env.example` dosyasını temel alarak bir `.env` dosyası oluşturun:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Temel değişkenler:
|
||||
- `HOST`: Sunucu host adresi (varsayılan: 127.0.0.1)
|
||||
- `PORT`: Sunucu portu (varsayılan: 8000)
|
||||
- `ALLOWED_ORIGINS`: CORS kökenleri (virgülle ayrılmış)
|
||||
- `LOG_LEVEL`: Log seviyesi (debug, info, warning, error)
|
||||
|
||||
## Production Dağıtımı
|
||||
|
||||
### 1. Uvicorn ile Çoklu Worker Kullanımı
|
||||
|
||||
```bash
|
||||
python run_asgi.py --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
### 2. Gunicorn Kullanımı
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn asgi_app:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
### 3. Nginx Reverse Proxy ile
|
||||
|
||||
1. Nginx'i yükleyin
|
||||
2. Sağlanan `nginx.conf` dosyasını kullanın:
|
||||
|
||||
```bash
|
||||
sudo cp nginx.conf /etc/nginx/sites-available/yargi-mcp
|
||||
sudo ln -s /etc/nginx/sites-available/yargi-mcp /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. Systemd Servisi
|
||||
|
||||
`/etc/systemd/system/yargi-mcp.service` dosyasını oluşturun:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Yargı MCP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/yargi-mcp
|
||||
Environment="PATH=/opt/yargi-mcp/venv/bin"
|
||||
ExecStart=/opt/yargi-mcp/venv/bin/uvicorn asgi_app:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Etkinleştirin ve başlatın:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable yargi-mcp
|
||||
sudo systemctl start yargi-mcp
|
||||
```
|
||||
|
||||
## Cloud Dağıtımı
|
||||
|
||||
### Heroku
|
||||
|
||||
1. `Procfile` oluşturun:
|
||||
```
|
||||
web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT
|
||||
```
|
||||
|
||||
2. Dağıtın:
|
||||
```bash
|
||||
heroku create uygulama-isminiz
|
||||
git push heroku main
|
||||
```
|
||||
|
||||
### Railway
|
||||
|
||||
1. `railway.json` ekleyin:
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"builder": "NIXPACKS"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "uvicorn asgi_app:app --host 0.0.0.0 --port $PORT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Railway CLI veya GitHub entegrasyonu ile dağıtın
|
||||
|
||||
### Google Cloud Run
|
||||
|
||||
1. Container oluşturun:
|
||||
```bash
|
||||
docker build -t yargi-mcp .
|
||||
docker tag yargi-mcp gcr.io/PROJE_ADINIZ/yargi-mcp
|
||||
docker push gcr.io/PROJE_ADINIZ/yargi-mcp
|
||||
```
|
||||
|
||||
2. Dağıtın:
|
||||
```bash
|
||||
gcloud run deploy yargi-mcp \
|
||||
--image gcr.io/PROJE_ADINIZ/yargi-mcp \
|
||||
--platform managed \
|
||||
--region us-central1 \
|
||||
--allow-unauthenticated
|
||||
```
|
||||
|
||||
### AWS Lambda (Mangum kullanarak)
|
||||
|
||||
1. Mangum'u yükleyin:
|
||||
```bash
|
||||
pip install mangum
|
||||
```
|
||||
|
||||
2. `lambda_handler.py` oluşturun:
|
||||
```python
|
||||
from mangum import Mangum
|
||||
from asgi_app import app
|
||||
|
||||
handler = Mangum(app, lifespan="off")
|
||||
```
|
||||
|
||||
3. AWS SAM veya Serverless Framework kullanarak dağıtın
|
||||
|
||||
## Docker Dağıtımı
|
||||
|
||||
### Tek Container
|
||||
|
||||
```bash
|
||||
# Oluşturun
|
||||
docker build -t yargi-mcp .
|
||||
|
||||
# Çalıştırın
|
||||
docker run -p 8000:8000 --env-file .env yargi-mcp
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
# Geliştirme
|
||||
docker-compose up
|
||||
|
||||
# Nginx ile Production
|
||||
docker-compose --profile production up
|
||||
|
||||
# Redis önbellekleme ile
|
||||
docker-compose --profile with-cache up
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Deployment YAML oluşturun:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: yargi-mcp
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: yargi-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: yargi-mcp
|
||||
spec:
|
||||
containers:
|
||||
- name: yargi-mcp
|
||||
image: yargi-mcp:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "8000"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: yargi-mcp-service
|
||||
spec:
|
||||
selector:
|
||||
app: yargi-mcp
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
## Güvenlik Hususları
|
||||
|
||||
### 1. Kimlik Doğrulama
|
||||
|
||||
`API_TOKEN` ortam değişkenini ayarlayarak token kimlik doğrulamasını etkinleştirin:
|
||||
|
||||
```bash
|
||||
export API_TOKEN=gizli-token-degeri
|
||||
```
|
||||
|
||||
Ardından isteklere ekleyin:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer gizli-token-degeri" http://localhost:8000/api/tools
|
||||
```
|
||||
|
||||
### 2. HTTPS/SSL
|
||||
|
||||
Production için her zaman HTTPS kullanın:
|
||||
|
||||
1. SSL sertifikası edinin (Let's Encrypt vb.)
|
||||
2. Nginx veya cloud sağlayıcıda yapılandırın
|
||||
3. `ALLOWED_ORIGINS` değerini https:// kullanacak şekilde güncelleyin
|
||||
|
||||
### 3. Rate Limiting (Hız Sınırlama)
|
||||
|
||||
Sağlanan Nginx yapılandırması rate limiting içerir:
|
||||
- API endpoint'leri: 10 istek/saniye
|
||||
- MCP endpoint: 100 istek/saniye
|
||||
|
||||
### 4. CORS Yapılandırması
|
||||
|
||||
Production için belirli kaynaklara izin verin:
|
||||
|
||||
```bash
|
||||
ALLOWED_ORIGINS=https://app.sizindomain.com,https://www.sizindomain.com
|
||||
```
|
||||
|
||||
## İzleme
|
||||
|
||||
### Sağlık Kontrolleri
|
||||
|
||||
`/health` endpoint'ini izleyin:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Yanıt:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-12-26T10:00:00",
|
||||
"uptime_seconds": 3600,
|
||||
"tools_operational": true
|
||||
}
|
||||
```
|
||||
|
||||
### Loglama
|
||||
|
||||
Ortam değişkeni ile log seviyesini yapılandırın:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=info # veya debug, warning, error
|
||||
```
|
||||
|
||||
Loglar şuraya yazılır:
|
||||
- Konsol (stdout)
|
||||
- `logs/mcp_server.log` dosyası
|
||||
|
||||
### Metrikler (Opsiyonel)
|
||||
|
||||
OpenTelemetry desteği için:
|
||||
|
||||
```bash
|
||||
pip install opentelemetry-instrumentation-fastapi
|
||||
```
|
||||
|
||||
Ortam değişkenlerini ayarlayın:
|
||||
```bash
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=yargi-mcp-server
|
||||
```
|
||||
|
||||
## Sorun Giderme
|
||||
|
||||
### Port Zaten Kullanımda
|
||||
|
||||
```bash
|
||||
# 8000 portunu kullanan işlemi bulun
|
||||
lsof -i :8000
|
||||
|
||||
# İşlemi sonlandırın
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### İzin Hataları
|
||||
|
||||
Dosya izinlerinin doğru olduğundan emin olun:
|
||||
|
||||
```bash
|
||||
chmod +x run_asgi.py
|
||||
chown -R www-data:www-data /opt/yargi-mcp
|
||||
```
|
||||
|
||||
### Bellek Sorunları
|
||||
|
||||
Büyük belge işleme için worker belleğini artırın:
|
||||
|
||||
```bash
|
||||
# systemd servisinde
|
||||
Environment="PYTHONMALLOC=malloc"
|
||||
LimitNOFILE=65536
|
||||
```
|
||||
|
||||
### Zaman Aşımı Sorunları
|
||||
|
||||
Zaman aşımlarını ayarlayın:
|
||||
1. Uvicorn: `--timeout-keep-alive 75`
|
||||
2. Nginx: `proxy_read_timeout 300s;`
|
||||
3. Cloud sağlayıcılar: Platform özel zaman aşımı ayarlarını kontrol edin
|
||||
|
||||
## Performans Ayarlama
|
||||
|
||||
### 1. Worker İşlemleri
|
||||
|
||||
- Geliştirme: 1 worker
|
||||
- Production: CPU çekirdeği başına 2-4 worker
|
||||
|
||||
### 2. Bağlantı Havuzlama
|
||||
|
||||
Sunucu varsayılan olarak httpx ile bağlantı havuzlama kullanır.
|
||||
|
||||
### 3. Önbellekleme (Gelecek Geliştirme)
|
||||
|
||||
Redis önbellekleme docker-compose ile etkinleştirilebilir:
|
||||
|
||||
```bash
|
||||
docker-compose --profile with-cache up
|
||||
```
|
||||
|
||||
### 4. Veritabanı Zaman Aşımları
|
||||
|
||||
`.env` dosyasında veritabanı başına zaman aşımlarını ayarlayın:
|
||||
|
||||
```bash
|
||||
YARGITAY_TIMEOUT=60
|
||||
DANISTAY_TIMEOUT=60
|
||||
ANAYASA_TIMEOUT=90
|
||||
```
|
||||
|
||||
## Destek
|
||||
|
||||
Sorunlar ve sorular için:
|
||||
- GitHub Issues: https://github.com/saidsurucu/yargi-mcp/issues
|
||||
- Dokümantasyon: README.md dosyasına bakın
|
||||
+106
-1
@@ -1,12 +1,15 @@
|
||||
# emsal_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
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 os
|
||||
import re
|
||||
import io
|
||||
import time
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
@@ -20,12 +23,87 @@ logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
class EmsalRateLimited(Exception):
|
||||
"""Raised when the local rate-limit bucket would block longer than allowed.
|
||||
|
||||
Carries the suggested retry-after (seconds) so callers can surface a
|
||||
structured 429-style response instead of silently blocking the
|
||||
event-loop slot for the full bucket-pause window.
|
||||
"""
|
||||
|
||||
def __init__(self, retry_after: float) -> None:
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"local bucket would block {retry_after:.1f}s")
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Asyncio token bucket with explicit back-pressure.
|
||||
|
||||
The UYAP Emsal endpoint (emsal.uyap.gov.tr) rate-limits per source IP and
|
||||
returns HTTP 429 (an HTML error page, no Retry-After header) after a small
|
||||
burst of rapid requests. On the shared-egress-IP production deployment this
|
||||
is hit constantly, making unrelated searches appear to "return 0 results"
|
||||
depending only on request order. This bucket spaces requests to a safe rate
|
||||
and freezes on an actual 429 via ``penalize_until``.
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int, refill_per_s: float) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.refill_per_s = float(refill_per_s)
|
||||
self._tokens = float(capacity)
|
||||
self._last = time.monotonic()
|
||||
self._not_before = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, max_wait: Optional[float] = None) -> None:
|
||||
"""Acquire one token. If ``max_wait`` is set and the next wait would
|
||||
exceed it, raise :class:`EmsalRateLimited` immediately instead of
|
||||
sleeping — keeps a single rate-limited request from holding the
|
||||
worker-slot for the full bucket-pause window."""
|
||||
deadline = (time.monotonic() + max_wait) if max_wait is not None else None
|
||||
while True:
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if now < self._not_before:
|
||||
wait_s = self._not_before - now
|
||||
else:
|
||||
self._tokens = min(
|
||||
self.capacity,
|
||||
self._tokens + (now - self._last) * self.refill_per_s,
|
||||
)
|
||||
self._last = now
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
wait_s = (1.0 - self._tokens) / self.refill_per_s
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if wait_s > remaining:
|
||||
raise EmsalRateLimited(retry_after=wait_s)
|
||||
await asyncio.sleep(wait_s)
|
||||
|
||||
def penalize_until(self, monotonic_deadline: float) -> None:
|
||||
"""Pause the bucket until ``monotonic_deadline`` (drains tokens)."""
|
||||
self._not_before = max(self._not_before, monotonic_deadline)
|
||||
self._tokens = 0.0
|
||||
self._last = time.monotonic()
|
||||
|
||||
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"
|
||||
|
||||
# UYAP Emsal rate-limits per source IP. Defaults mirror the sibling
|
||||
# Bedesten client (conservative: no burst, ~3.5s spacing). Override via env:
|
||||
# EMSAL_RATE_CAPACITY (default 1)
|
||||
# EMSAL_RATE_REFILL_S (default 3.5; seconds per token)
|
||||
# EMSAL_RATE_MAX_WAIT_S (default 8.0; max local wait before a structured 429)
|
||||
_DEFAULT_CAPACITY = int(os.getenv("EMSAL_RATE_CAPACITY", "1"))
|
||||
_DEFAULT_REFILL_S = float(os.getenv("EMSAL_RATE_REFILL_S", "3.5"))
|
||||
_DEFAULT_MAX_WAIT_S = float(os.getenv("EMSAL_RATE_MAX_WAIT_S", "8.0"))
|
||||
|
||||
def __init__(self, request_timeout: float = 30.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
@@ -37,6 +115,27 @@ class EmsalApiClient:
|
||||
timeout=request_timeout,
|
||||
verify=False # As per user's original FastAPI code
|
||||
)
|
||||
self._bucket = _TokenBucket(
|
||||
capacity=self._DEFAULT_CAPACITY,
|
||||
refill_per_s=1.0 / self._DEFAULT_REFILL_S,
|
||||
)
|
||||
|
||||
def _handle_429(self, response: httpx.Response, op: str) -> None:
|
||||
"""Apply back-pressure to the shared bucket based on Retry-After.
|
||||
|
||||
Emsal returns 429 as an HTML error page with no Retry-After header, so
|
||||
the 30s fallback almost always applies."""
|
||||
retry_after_raw = response.headers.get("Retry-After", "")
|
||||
try:
|
||||
retry_after = float(retry_after_raw)
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 30.0
|
||||
# Cap penalty so a hostile/buggy server can't freeze us indefinitely.
|
||||
retry_after = max(1.0, min(retry_after, 60.0))
|
||||
self._bucket.penalize_until(time.monotonic() + retry_after + 0.5)
|
||||
logger.warning(
|
||||
f"EmsalApiClient: 429 on {op}; bucket paused {retry_after + 0.5:.1f}s"
|
||||
)
|
||||
|
||||
async def search_detailed_decisions(
|
||||
self,
|
||||
@@ -75,7 +174,10 @@ class EmsalApiClient:
|
||||
async def _execute_api_search(self, endpoint: str, payload: Dict) -> EmsalApiResponse:
|
||||
"""Helper method to execute search POST request and process response for Emsal."""
|
||||
try:
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.post(endpoint, json=payload)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, "search")
|
||||
response.raise_for_status()
|
||||
response_json_data = response.json()
|
||||
logger.debug(f"EmsalApiClient: Raw API response from {endpoint}: {response_json_data}")
|
||||
@@ -142,7 +244,10 @@ class EmsalApiClient:
|
||||
logger.info(f"EmsalApiClient: Fetching Emsal document for Markdown (ID: {id}) from {source_url}")
|
||||
|
||||
try:
|
||||
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
|
||||
response = await self.http_client.get(document_api_url)
|
||||
if response.status_code == 429:
|
||||
self._handle_429(response, f"document {id}")
|
||||
response.raise_for_status()
|
||||
|
||||
# Emsal /getDokuman returns JSON with HTML in 'data' field (confirmed by user example)
|
||||
@@ -153,7 +258,7 @@ class EmsalApiClient:
|
||||
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)
|
||||
markdown_content = await asyncio.to_thread(self._clean_html_and_convert_to_markdown_emsal, html_content_from_api)
|
||||
|
||||
return EmsalDocumentMarkdown(
|
||||
id=id,
|
||||
|
||||
@@ -84,7 +84,7 @@ class EmsalApiResponseInnerData(BaseModel):
|
||||
|
||||
class EmsalApiResponse(BaseModel):
|
||||
"""Model for the complete search response from the Emsal API."""
|
||||
data: EmsalApiResponseInnerData
|
||||
data: Optional[EmsalApiResponseInnerData] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) from API, if any.")
|
||||
|
||||
class EmsalDocumentMarkdown(BaseModel):
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# fly.toml app configuration file for yargi-mcp-noauth
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = 'yargi-mcp-free'
|
||||
primary_region = 'fra'
|
||||
|
||||
[env]
|
||||
ENABLE_AUTH = "false"
|
||||
HOST = "0.0.0.0"
|
||||
PORT = "8000"
|
||||
LOG_LEVEL = "info"
|
||||
|
||||
[build]
|
||||
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = 'off'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
# Enable connection persistence for MCP sessions
|
||||
[http_service.concurrency]
|
||||
type = "connections"
|
||||
hard_limit = 100
|
||||
soft_limit = 80
|
||||
|
||||
[[vm]]
|
||||
memory = '1gb'
|
||||
cpu_kind = 'shared'
|
||||
cpus = 1
|
||||
|
||||
[deploy]
|
||||
strategy = "immediate"
|
||||
|
||||
[processes]
|
||||
app = "python asgi_app.py"
|
||||
|
||||
[checks.http_health] # keep MCP /health live
|
||||
type = "http"
|
||||
interval = "30s"
|
||||
timeout = "10s"
|
||||
path = "/health"
|
||||
@@ -1,40 +0,0 @@
|
||||
# fly.toml app configuration file generated for yargi-mcp on 2025-06-29T00:23:47+03:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = 'yargi-mcp'
|
||||
primary_region = 'fra'
|
||||
|
||||
[env]
|
||||
ENABLE_AUTH = "true"
|
||||
HOST = "0.0.0.0"
|
||||
PORT = "8000"
|
||||
LOG_LEVEL = "info"
|
||||
|
||||
[build]
|
||||
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = 'off'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
# Enable connection persistence for MCP sessions
|
||||
[http_service.concurrency]
|
||||
type = "connections"
|
||||
hard_limit = 100
|
||||
soft_limit = 80
|
||||
|
||||
[[vm]]
|
||||
memory = '1gb'
|
||||
cpu_kind = 'shared'
|
||||
cpus = 1
|
||||
|
||||
[checks.http_health] # keep MCP /health live
|
||||
type = "http"
|
||||
interval = "30s"
|
||||
timeout = "10s"
|
||||
path = "/health"
|
||||
@@ -0,0 +1 @@
|
||||
# gib_mcp_module/__init__.py
|
||||
@@ -0,0 +1,355 @@
|
||||
# gib_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional, Any, Dict
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from .models import (
|
||||
GibSearchRequest,
|
||||
GibOzelgeSummary,
|
||||
GibSearchResult,
|
||||
GibDocumentMarkdown,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.hasHandlers():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
|
||||
class GibApiClient:
|
||||
"""
|
||||
API client for searching and retrieving GİB özelgeler (Turkish Revenue
|
||||
Administration tax rulings) via the public gib.gov.tr JSON API.
|
||||
|
||||
The endpoint is a single POST list endpoint; document retrieval is done
|
||||
by filtering the same endpoint with an exact `id`.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://gib.gov.tr/api"
|
||||
LIST_PATH = "/gibportal/mevzuat/ozelge/list"
|
||||
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
|
||||
|
||||
# Fixed filter values required by the backend
|
||||
_REQUIRED_STATUS = 2
|
||||
_REQUIRED_DELETED = False
|
||||
_REQUIRED_KTYPE = 99 # ktype=99 selects özelge
|
||||
_SORT_FIELD = "ozelgeTarih"
|
||||
_SORT_TYPE = "DESC"
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
self.http_client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.7",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; yargi-mcp/1.0; +https://github.com/saidsurucu/yargi-mcp)",
|
||||
},
|
||||
timeout=request_timeout,
|
||||
verify=True,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_date(value: str, end_of_day: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Accept 'YYYY-MM-DD' or full ISO 8601; always return full ISO 8601.
|
||||
|
||||
GİB backend rejects date-only strings.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return None
|
||||
# Already ISO with time component
|
||||
if "T" in v:
|
||||
return v
|
||||
# Simple YYYY-MM-DD - expand to start/end of day
|
||||
suffix = "T23:59:59.999Z" if end_of_day else "T00:00:00.000Z"
|
||||
return f"{v}{suffix}"
|
||||
|
||||
def _build_search_body(self, params: GibSearchRequest) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"status": self._REQUIRED_STATUS,
|
||||
"deleted": self._REQUIRED_DELETED,
|
||||
"ktype": self._REQUIRED_KTYPE,
|
||||
}
|
||||
|
||||
keywords = params.keywords.strip()
|
||||
kanun_no = params.kanunNo.strip()
|
||||
# Frontend sets title/kanunNo/description to the SAME value; the backend
|
||||
# ORs across them. If the caller supplies both, combine them so kanun_no
|
||||
# still biases toward ruling text, while keywords remain primary.
|
||||
search_term = keywords or kanun_no
|
||||
if keywords and kanun_no and kanun_no not in keywords:
|
||||
search_term = f"{keywords} {kanun_no}"
|
||||
if search_term:
|
||||
body["title"] = search_term
|
||||
body["kanunNo"] = search_term
|
||||
body["description"] = search_term
|
||||
|
||||
if params.ozelgeNo.strip():
|
||||
body["ozelgeNo"] = params.ozelgeNo.strip()
|
||||
|
||||
if params.kanunId and params.kanunId > 0:
|
||||
body["kanunIds"] = [params.kanunId]
|
||||
|
||||
start_iso = self._normalize_date(params.ozelgeStartDate, end_of_day=False)
|
||||
end_iso = self._normalize_date(params.ozelgeEndDate, end_of_day=True)
|
||||
if start_iso:
|
||||
body["ozelgeStartDate"] = start_iso
|
||||
if end_iso:
|
||||
body["ozelgeEndDate"] = end_iso
|
||||
|
||||
return body
|
||||
|
||||
def _build_query_params(self, page_1_indexed: int, page_size: int) -> Dict[str, Any]:
|
||||
# API expects 0-indexed page
|
||||
zero_indexed = max(0, page_1_indexed - 1)
|
||||
return {
|
||||
"page": zero_indexed,
|
||||
"size": page_size,
|
||||
"sortFieldName": self._SORT_FIELD,
|
||||
"sortType": self._SORT_TYPE,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_summary(item: Dict[str, Any]) -> Optional[GibOzelgeSummary]:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
raw_id = item.get("id")
|
||||
if raw_id is None:
|
||||
return None
|
||||
try:
|
||||
ozelge_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return GibOzelgeSummary(
|
||||
id=ozelge_id,
|
||||
ozelgeNo=item.get("ozelgeNo"),
|
||||
ozelgeTarih=item.get("ozelgeTarih"),
|
||||
title=item.get("title"),
|
||||
kanunNo=item.get("kanunNo"),
|
||||
kanunTitle=item.get("kanunTitle"),
|
||||
siteLink=item.get("siteLink"),
|
||||
)
|
||||
|
||||
async def search_ozelge(self, params: GibSearchRequest) -> GibSearchResult:
|
||||
"""Search GİB özelgeler."""
|
||||
body = self._build_search_body(params)
|
||||
query = self._build_query_params(params.page, params.pageSize)
|
||||
logger.info(
|
||||
"GibApiClient: search page=%s size=%s body_keys=%s",
|
||||
params.page, params.pageSize, sorted(body.keys()),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.http_client.post(self.LIST_PATH, params=query, json=body)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("GibApiClient: HTTP %s during search", e.response.status_code)
|
||||
return GibSearchResult(
|
||||
ozelgeler=[],
|
||||
total_results=0,
|
||||
total_pages=0,
|
||||
current_page=params.page,
|
||||
page_size=params.pageSize,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("GibApiClient: search request failed: %s", e)
|
||||
return GibSearchResult(
|
||||
ozelgeler=[],
|
||||
total_results=0,
|
||||
total_pages=0,
|
||||
current_page=params.page,
|
||||
page_size=params.pageSize,
|
||||
)
|
||||
|
||||
container = (payload or {}).get("resultContainer") or {}
|
||||
raw_items = container.get("content") or []
|
||||
|
||||
summaries = []
|
||||
for raw in raw_items:
|
||||
summary = self._to_summary(raw)
|
||||
if summary is not None:
|
||||
summaries.append(summary)
|
||||
|
||||
total_results = container.get("totalElements") or 0
|
||||
total_pages = container.get("totalPages") or 0
|
||||
try:
|
||||
total_results = int(total_results)
|
||||
except (TypeError, ValueError):
|
||||
total_results = 0
|
||||
try:
|
||||
total_pages = int(total_pages)
|
||||
except (TypeError, ValueError):
|
||||
total_pages = 0
|
||||
|
||||
return GibSearchResult(
|
||||
ozelgeler=summaries,
|
||||
total_results=total_results,
|
||||
total_pages=total_pages,
|
||||
current_page=params.page,
|
||||
page_size=params.pageSize,
|
||||
)
|
||||
|
||||
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
|
||||
"""Convert HTML content to Markdown using MarkItDown with BytesIO."""
|
||||
if not html_content:
|
||||
return None
|
||||
try:
|
||||
html_bytes = html_content.encode("utf-8")
|
||||
html_stream = io.BytesIO(html_bytes)
|
||||
md_converter = MarkItDown(enable_plugins=False)
|
||||
result = md_converter.convert(html_stream)
|
||||
return result.text_content
|
||||
except Exception as e:
|
||||
logger.error("GibApiClient: HTML→Markdown conversion failed: %s", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_header_block(item: Dict[str, Any]) -> str:
|
||||
"""Build a small Markdown header block summarising the ruling metadata."""
|
||||
parts = []
|
||||
title = item.get("title")
|
||||
if title:
|
||||
parts.append(f"# {title}")
|
||||
meta_lines = []
|
||||
if item.get("ozelgeNo"):
|
||||
meta_lines.append(f"**Sayı:** {item['ozelgeNo']}")
|
||||
if item.get("ozelgeTarih"):
|
||||
meta_lines.append(f"**Tarih:** {item['ozelgeTarih']}")
|
||||
if item.get("kanunTitle"):
|
||||
kanun_no = item.get("kanunNo")
|
||||
if kanun_no:
|
||||
meta_lines.append(f"**Kanun:** {item['kanunTitle']} ({kanun_no})")
|
||||
else:
|
||||
meta_lines.append(f"**Kanun:** {item['kanunTitle']}")
|
||||
if item.get("siteLink"):
|
||||
meta_lines.append(f"**Kaynak:** {item['siteLink']}")
|
||||
if meta_lines:
|
||||
parts.append("\n".join(meta_lines))
|
||||
return "\n\n".join(parts).strip()
|
||||
|
||||
async def get_ozelge_document(
|
||||
self, ozelge_id: int, page_number: int = 1
|
||||
) -> GibDocumentMarkdown:
|
||||
"""Retrieve a single özelge and return its paginated Markdown form."""
|
||||
logger.info(
|
||||
"GibApiClient: fetching özelge id=%s page=%s", ozelge_id, page_number
|
||||
)
|
||||
|
||||
if not isinstance(ozelge_id, int) or ozelge_id <= 0:
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id if isinstance(ozelge_id, int) else 0,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="ozelge_id must be a positive integer",
|
||||
)
|
||||
|
||||
body = {
|
||||
"status": self._REQUIRED_STATUS,
|
||||
"deleted": self._REQUIRED_DELETED,
|
||||
"ktype": self._REQUIRED_KTYPE,
|
||||
"id": ozelge_id,
|
||||
}
|
||||
query = {"page": 0, "size": 1}
|
||||
|
||||
try:
|
||||
resp = await self.http_client.post(self.LIST_PATH, params=query, json=body)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
msg = f"HTTP {e.response.status_code} when fetching özelge {ozelge_id}"
|
||||
logger.error("GibApiClient: %s", msg)
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=msg,
|
||||
)
|
||||
except Exception as e:
|
||||
msg = f"Request failed: {e}"
|
||||
logger.error("GibApiClient: %s", msg)
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=msg,
|
||||
)
|
||||
|
||||
container = (payload or {}).get("resultContainer") or {}
|
||||
content = container.get("content") or []
|
||||
if not content:
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message=f"Özelge {ozelge_id} not found",
|
||||
)
|
||||
|
||||
item = content[0] if isinstance(content[0], dict) else {}
|
||||
description_html = item.get("description") or ""
|
||||
markdown_body = (await asyncio.to_thread(self._convert_html_to_markdown, description_html)) or ""
|
||||
header_block = self._build_header_block(item)
|
||||
|
||||
if header_block and markdown_body:
|
||||
full_markdown = f"{header_block}\n\n---\n\n{markdown_body}"
|
||||
else:
|
||||
full_markdown = header_block or markdown_body
|
||||
|
||||
if not full_markdown.strip():
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
ozelge_no=item.get("ozelgeNo"),
|
||||
title=item.get("title"),
|
||||
ozelge_tarih=item.get("ozelgeTarih"),
|
||||
kanun_title=item.get("kanunTitle"),
|
||||
kanun_no=item.get("kanunNo"),
|
||||
site_link=item.get("siteLink"),
|
||||
current_page=page_number,
|
||||
total_pages=0,
|
||||
is_paginated=False,
|
||||
error_message="Document body is empty",
|
||||
)
|
||||
|
||||
total_pages = max(
|
||||
1, math.ceil(len(full_markdown) / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
|
||||
)
|
||||
current_page_clamped = max(1, min(page_number, total_pages))
|
||||
start = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
end = start + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
|
||||
chunk = full_markdown[start:end]
|
||||
|
||||
return GibDocumentMarkdown(
|
||||
ozelge_id=ozelge_id,
|
||||
ozelge_no=item.get("ozelgeNo"),
|
||||
title=item.get("title"),
|
||||
ozelge_tarih=item.get("ozelgeTarih"),
|
||||
kanun_title=item.get("kanunTitle"),
|
||||
kanun_no=item.get("kanunNo"),
|
||||
site_link=item.get("siteLink"),
|
||||
markdown_chunk=chunk,
|
||||
current_page=current_page_clamped,
|
||||
total_pages=total_pages,
|
||||
is_paginated=total_pages > 1,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
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("GibApiClient: HTTP client session closed.")
|
||||
@@ -0,0 +1,64 @@
|
||||
# gib_mcp_module/models.py
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class GibSearchRequest(BaseModel):
|
||||
"""
|
||||
Request model for searching GİB özelgeler (Turkish Revenue Administration tax rulings).
|
||||
|
||||
GİB (Gelir İdaresi Başkanlığı) publishes official tax-ruling letters
|
||||
("özelge") responding to taxpayer questions on VAT, income tax,
|
||||
corporate tax, stamp duty, and other tax matters. 18,000+ rulings
|
||||
are searchable via the public gib.gov.tr API.
|
||||
"""
|
||||
keywords: str = Field("", description="Keywords searched across title, kanunNo and description (Turkish)")
|
||||
ozelgeNo: str = Field("", description="Exact özelge reference number (e.g., 'E-40247694-130-15524')")
|
||||
kanunNo: str = Field("", description="Law number filter, e.g. '3065' for KDV")
|
||||
kanunId: int = Field(0, description="Optional numeric law ID filter (0=ignore)")
|
||||
ozelgeStartDate: str = Field("", description="Start date YYYY-MM-DD or full ISO 8601")
|
||||
ozelgeEndDate: str = Field("", description="End date YYYY-MM-DD or full ISO 8601")
|
||||
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 GibOzelgeSummary(BaseModel):
|
||||
"""Summary of a single GİB özelge from search results (no full HTML)."""
|
||||
id: int = Field(..., description="Numeric özelge ID for document retrieval")
|
||||
ozelgeNo: Optional[str] = Field(None, description="Official ruling reference number")
|
||||
ozelgeTarih: Optional[str] = Field(None, description="Ruling date (ISO datetime)")
|
||||
title: Optional[str] = Field(None, description="Subject/title of the ruling")
|
||||
kanunNo: Optional[str] = Field(None, description="Law number (e.g., '3065')")
|
||||
kanunTitle: Optional[str] = Field(None, description="Law title (e.g., 'KATMA DEĞER VERGİSİ KANUNU')")
|
||||
siteLink: Optional[str] = Field(None, description="Direct URL to the ruling on gib.gov.tr")
|
||||
|
||||
|
||||
class GibSearchResult(BaseModel):
|
||||
"""Response model for GİB özelge search results."""
|
||||
ozelgeler: List[GibOzelgeSummary] = Field(default_factory=list, description="Matching özelge summaries")
|
||||
total_results: int = Field(0, description="Total number of matching özelgeler across all pages")
|
||||
total_pages: int = Field(0, description="Total number of pages for this query")
|
||||
current_page: int = Field(1, description="Current page (1-indexed)")
|
||||
page_size: int = Field(10, description="Results per page")
|
||||
|
||||
|
||||
class GibDocumentMarkdown(BaseModel):
|
||||
"""
|
||||
GİB özelge document converted to paginated Markdown.
|
||||
|
||||
Long rulings are split into 5000-character chunks; request successive
|
||||
pages via page_number to read the full text.
|
||||
"""
|
||||
ozelge_id: int = Field(..., description="Numeric özelge ID")
|
||||
ozelge_no: Optional[str] = Field(None, description="Official ruling reference number")
|
||||
title: Optional[str] = Field(None, description="Subject/title of the ruling")
|
||||
ozelge_tarih: Optional[str] = Field(None, description="Ruling date (ISO datetime)")
|
||||
kanun_title: Optional[str] = Field(None, description="Related law title")
|
||||
kanun_no: Optional[str] = Field(None, description="Related law number")
|
||||
site_link: Optional[str] = Field(None, description="Direct URL to the ruling on gib.gov.tr")
|
||||
markdown_chunk: Optional[str] = Field(None, description="Current 5000-character Markdown chunk")
|
||||
current_page: int = Field(1, description="Current page number (1-indexed)")
|
||||
total_pages: int = Field(0, description="Total pages for the full Markdown content")
|
||||
is_paginated: bool = Field(False, description="True if split across multiple pages")
|
||||
error_message: Optional[str] = Field(None, description="Populated when retrieval failed")
|
||||
File diff suppressed because it is too large
Load Diff
+138
-83
@@ -1,14 +1,23 @@
|
||||
# kik_mcp_module/client_v2.py
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import httpx
|
||||
import requests
|
||||
import logging
|
||||
import uuid
|
||||
import base64
|
||||
import ssl
|
||||
import os
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# Cryptography imports for AES-256-CBC encryption of document IDs
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
HAS_CRYPTOGRAPHY = True
|
||||
except ImportError:
|
||||
HAS_CRYPTOGRAPHY = False
|
||||
|
||||
from .models_v2 import (
|
||||
KikV2DecisionType, KikV2SearchPayload, KikV2SearchPayloadDk, KikV2SearchPayloadMk,
|
||||
KikV2RequestData, KikV2QueryRequest, KikV2KeyValuePair,
|
||||
@@ -35,6 +44,60 @@ class KikV2ApiClient:
|
||||
KikV2DecisionType.MAHKEME: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk"
|
||||
}
|
||||
|
||||
# AES-256-CBC encryption key for document ID encryption (reverse engineered from ekapv2.kik.gov.tr Angular app)
|
||||
# This key is used to encrypt numeric gundemMaddesiId values to 64-character hex hashes for document URLs
|
||||
DOCUMENT_ID_ENCRYPTION_KEY = bytes([
|
||||
236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174,
|
||||
174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176
|
||||
])
|
||||
|
||||
# AES-192-CBC key (environment.r8fact) used by the Angular HTTP interceptor to sign every
|
||||
# request. The server decrypts X-Custom-Request-Ts and rejects stale timestamps with
|
||||
# HTTP 401 "İstek zaman aşımına uğradı.", so these headers MUST be generated per-request
|
||||
# with the current timestamp (see _generate_security_headers).
|
||||
REQUEST_SIGNING_KEY = b"Qm2LtXR0aByP69vZNKef4wMJ" # UTF-8 bytes, 24 chars -> AES-192
|
||||
|
||||
@staticmethod
|
||||
def encrypt_document_id(numeric_id: str) -> str:
|
||||
"""
|
||||
Encrypt a numeric KİK gundemMaddesiId to the 64-character hex hash
|
||||
used in document URLs.
|
||||
|
||||
Algorithm: AES-256-CBC with PKCS7 padding
|
||||
Output format: IV (16 bytes hex) + Ciphertext (16 bytes hex) = 64 chars
|
||||
|
||||
Args:
|
||||
numeric_id: The numeric document ID from search results (e.g., "177280")
|
||||
|
||||
Returns:
|
||||
64-character hex string for use in document URL KararId parameter
|
||||
"""
|
||||
if not HAS_CRYPTOGRAPHY:
|
||||
raise ImportError("cryptography library required for document ID encryption")
|
||||
|
||||
# Generate random IV (16 bytes)
|
||||
iv = os.urandom(16)
|
||||
|
||||
# Create AES-CBC cipher with the encryption key
|
||||
cipher = Cipher(
|
||||
algorithms.AES(KikV2ApiClient.DOCUMENT_ID_ENCRYPTION_KEY),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
|
||||
# Encode plaintext and apply PKCS7 padding
|
||||
plaintext = numeric_id.encode('utf-8')
|
||||
block_size = 16
|
||||
padding_len = block_size - (len(plaintext) % block_size)
|
||||
padded_plaintext = plaintext + bytes([padding_len] * padding_len)
|
||||
|
||||
# Encrypt
|
||||
ciphertext = encryptor.update(padded_plaintext) + encryptor.finalize()
|
||||
|
||||
# Return IV + ciphertext as lowercase hex (64 characters total)
|
||||
return iv.hex() + ciphertext.hex()
|
||||
|
||||
def __init__(self, request_timeout: float = 60.0):
|
||||
# Create SSL context with legacy server support
|
||||
ssl_context = ssl.create_default_context()
|
||||
@@ -72,21 +135,43 @@ class KikV2ApiClient:
|
||||
# Generate security headers (these might need to be updated based on API requirements)
|
||||
self.security_headers = self._generate_security_headers()
|
||||
|
||||
def _sign_request_value(self, plaintext: str, iv: bytes) -> str:
|
||||
"""AES-192-CBC encrypt a value with the request signing key, return base64 ciphertext."""
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self.REQUEST_SIGNING_KEY),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
data = plaintext.encode("utf-8")
|
||||
block_size = 16
|
||||
padding_len = block_size - (len(data) % block_size)
|
||||
padded = data + bytes([padding_len] * padding_len)
|
||||
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
||||
return base64.b64encode(ciphertext).decode("ascii")
|
||||
|
||||
def _generate_security_headers(self) -> dict:
|
||||
"""
|
||||
Generate the custom security headers required by KIK v2 API.
|
||||
These headers appear to be for request validation/encryption.
|
||||
Generate the custom security headers required by the KIK v2 API.
|
||||
|
||||
Mirrors the Angular HTTP interceptor on ekapv2.kik.gov.tr: a random GUID and a
|
||||
current-timestamp (epoch milliseconds) are AES-192-CBC encrypted with environment.r8fact
|
||||
using a fresh random IV. The IV is sent as -Siv, the encrypted timestamp as -Ts, and the
|
||||
encrypted GUID as -R8id. The server validates the decrypted timestamp's freshness, so these
|
||||
MUST be regenerated on every request; stale values yield HTTP 401 "İstek zaman aşımına uğradı.".
|
||||
"""
|
||||
# Generate a random GUID for each session
|
||||
if not HAS_CRYPTOGRAPHY:
|
||||
raise ImportError("cryptography library required for KIK v2 request signing")
|
||||
|
||||
request_guid = str(uuid.uuid4())
|
||||
iv = os.urandom(16)
|
||||
timestamp_ms = str(int(datetime.now().timestamp() * 1000))
|
||||
|
||||
# These are example values - in a real implementation, these might need
|
||||
# to be calculated based on the request content or session
|
||||
return {
|
||||
"X-Custom-Request-Guid": request_guid,
|
||||
"X-Custom-Request-R8id": "hwnOjsN8qdgtDw70x3sKkxab0rj2bQ8Uph4+C+oU+9AMmQqRN3eMOEEeet748DOf",
|
||||
"X-Custom-Request-Siv": "p2IQRTitF8z7I39nBjdAqA==",
|
||||
"X-Custom-Request-Ts": "1vB3Wwrt8YQ5U6t3XAzZ+Q=="
|
||||
"X-Custom-Request-R8id": self._sign_request_value(request_guid, iv),
|
||||
"X-Custom-Request-Siv": base64.b64encode(iv).decode("ascii"),
|
||||
"X-Custom-Request-Ts": self._sign_request_value(timestamp_ms, iv),
|
||||
}
|
||||
|
||||
def _build_search_payload(self,
|
||||
@@ -270,7 +355,7 @@ class KikV2ApiClient:
|
||||
|
||||
This method uses a two-step process:
|
||||
1. Call GetSorgulamaUrl endpoint to get the actual document URL
|
||||
2. Use Playwright to navigate to that URL and extract content
|
||||
2. Use httpx to fetch the document content
|
||||
|
||||
Args:
|
||||
document_id: The gundemMaddesiId from search results
|
||||
@@ -320,92 +405,60 @@ class KikV2ApiClient:
|
||||
error_message="Could not get document URL from GetSorgulamaUrl API"
|
||||
)
|
||||
|
||||
# Construct full document URL with the actual document ID
|
||||
document_url = f"{base_document_url}?KararId={document_id}"
|
||||
# If document_id is numeric, encrypt it to get the KararId hash
|
||||
# The web interface uses AES-256-CBC encrypted hashes for document URLs
|
||||
karar_id = document_id
|
||||
if document_id.isdigit():
|
||||
try:
|
||||
karar_id = self.encrypt_document_id(document_id)
|
||||
logger.info(f"KikV2ApiClient: Encrypted numeric ID {document_id} to hash: {karar_id}")
|
||||
except Exception as enc_error:
|
||||
logger.warning(f"KikV2ApiClient: Could not encrypt document ID, using as-is: {enc_error}")
|
||||
|
||||
# Construct full document URL with the encrypted KararId
|
||||
document_url = f"{base_document_url}?KararId={karar_id}"
|
||||
logger.info(f"KikV2ApiClient: Step 2 - Retrieved document URL: {document_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"KikV2ApiClient: Error getting document URL for ID {document_id}: {str(e)}")
|
||||
# Fallback to old method if GetSorgulamaUrl fails
|
||||
document_url = f"https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId={document_id}"
|
||||
# Also encrypt numeric IDs in fallback path
|
||||
karar_id = document_id
|
||||
if document_id.isdigit():
|
||||
try:
|
||||
karar_id = self.encrypt_document_id(document_id)
|
||||
logger.info(f"KikV2ApiClient: Encrypted numeric ID in fallback: {karar_id}")
|
||||
except Exception as enc_error:
|
||||
logger.warning(f"KikV2ApiClient: Could not encrypt in fallback: {enc_error}")
|
||||
document_url = f"https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId={karar_id}"
|
||||
logger.info(f"KikV2ApiClient: Falling back to direct URL: {document_url}")
|
||||
|
||||
try:
|
||||
# Step 2: Use Playwright to get the actual document content
|
||||
logger.info(f"KikV2ApiClient: Step 2 - Using Playwright to retrieve document from: {document_url}")
|
||||
# Step 2: Use httpx to get the document content
|
||||
logger.info(f"KikV2ApiClient: Step 2 - Using httpx to retrieve document from: {document_url}")
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
# Create a separate httpx client for document retrieval with HTML headers
|
||||
doc_ssl_context = ssl.create_default_context()
|
||||
doc_ssl_context.check_hostname = False
|
||||
doc_ssl_context.verify_mode = ssl.CERT_NONE
|
||||
if hasattr(ssl, 'OP_LEGACY_SERVER_CONNECT'):
|
||||
doc_ssl_context.options |= ssl.OP_LEGACY_SERVER_CONNECT
|
||||
doc_ssl_context.set_ciphers('ALL:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
|
||||
|
||||
async with async_playwright() as p:
|
||||
# Launch browser
|
||||
browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
args=['--no-sandbox', '--disable-dev-shm-usage']
|
||||
)
|
||||
|
||||
page = await browser.new_page(
|
||||
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Navigate to document page with longer timeout for JS loading
|
||||
await page.goto(document_url, wait_until="networkidle", timeout=15000)
|
||||
|
||||
# Wait for the document content to load (KİK pages might need more time for JS execution)
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Wait for Angular/Zone.js to finish loading and document to be ready
|
||||
try:
|
||||
# Wait for Angular zone to be available (this JavaScript code you showed)
|
||||
await page.wait_for_function(
|
||||
"typeof Zone !== 'undefined' && Zone.current",
|
||||
timeout=10000
|
||||
)
|
||||
|
||||
# Wait for network to be idle after Angular bootstrap
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
|
||||
# Wait for specific document content to appear
|
||||
await page.wait_for_function(
|
||||
"""
|
||||
document.body.textContent.length > 5000 &&
|
||||
(document.body.textContent.includes('Karar') ||
|
||||
document.body.textContent.includes('KURUL') ||
|
||||
document.body.textContent.includes('Gündem') ||
|
||||
document.body.textContent.includes('Toplantı'))
|
||||
""",
|
||||
timeout=15000
|
||||
)
|
||||
|
||||
logger.info("KikV2ApiClient: Angular document content loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"KikV2ApiClient: Angular content loading timed out, proceeding anyway: {str(e)}")
|
||||
# Give a bit more time for any remaining content to load
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
# Get page content
|
||||
html_content = await page.content()
|
||||
|
||||
await browser.close()
|
||||
|
||||
logger.info(f"KikV2ApiClient: Retrieved content via Playwright, length: {len(html_content)}")
|
||||
|
||||
except ImportError:
|
||||
logger.info("KikV2ApiClient: Playwright not available, falling back to httpx")
|
||||
# Fallback to httpx
|
||||
response = await self.http_client.get(
|
||||
document_url,
|
||||
async with httpx.AsyncClient(
|
||||
verify=doc_ssl_context,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "tr,en-US;q=0.5",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
|
||||
"Referer": "https://ekap.kik.gov.tr/",
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
)
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
|
||||
},
|
||||
timeout=60.0,
|
||||
follow_redirects=True
|
||||
) as doc_client:
|
||||
response = await doc_client.get(document_url)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
logger.info(f"KikV2ApiClient: Retrieved content via httpx, length: {len(html_content)}")
|
||||
|
||||
# Convert HTML to Markdown using MarkItDown with BytesIO
|
||||
try:
|
||||
@@ -416,7 +469,9 @@ class KikV2ApiClient:
|
||||
html_bytes = html_content.encode('utf-8')
|
||||
html_stream = BytesIO(html_bytes)
|
||||
|
||||
result = md.convert_stream(html_stream, file_extension=".html")
|
||||
# markitdown is sync; offload to thread so HTML parsing doesn't
|
||||
# block the event-loop / other in-flight MCP requests.
|
||||
result = await asyncio.to_thread(md.convert_stream, html_stream, file_extension=".html")
|
||||
markdown_content = result.text_content
|
||||
|
||||
return KikV2DocumentMarkdown(
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,5 +1,6 @@
|
||||
# kvkk_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Optional, Dict, Any
|
||||
@@ -291,7 +292,7 @@ class KvkkApiClient:
|
||||
# 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"])
|
||||
full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, extracted_data["html_content"])
|
||||
|
||||
if not full_markdown_content:
|
||||
return KvkkDocumentMarkdown(
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
MCP Auth Toolkit - OAuth 2.1 + Authorization for Model Context Protocol Servers
|
||||
Integrated with Clerk Authentication
|
||||
"""
|
||||
|
||||
from .middleware import (
|
||||
AuthContext,
|
||||
FastMCPAuthWrapper,
|
||||
MCPAuthMiddleware,
|
||||
auth_required,
|
||||
)
|
||||
from .oauth import OAuthConfig, OAuthProvider
|
||||
from .policy import PolicyEngine, ToolPolicy, create_default_policies
|
||||
from .storage import PersistentStorage
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"OAuthProvider",
|
||||
"OAuthConfig",
|
||||
"AuthContext",
|
||||
"auth_required",
|
||||
"create_default_policies",
|
||||
"MCPAuthMiddleware",
|
||||
"FastMCPAuthWrapper",
|
||||
"PolicyEngine",
|
||||
"ToolPolicy",
|
||||
"PersistentStorage",
|
||||
]
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Clerk OAuth configuration for MCP Auth Toolkit
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from .oauth import OAuthConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_clerk_oauth_config() -> OAuthConfig:
|
||||
"""Create OAuth configuration for Clerk integration using SDK"""
|
||||
|
||||
# Get Clerk configuration from environment
|
||||
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
|
||||
clerk_publishable_key = os.getenv("CLERK_PUBLISHABLE_KEY")
|
||||
clerk_secret_key = os.getenv("CLERK_SECRET_KEY")
|
||||
|
||||
if not clerk_publishable_key or not clerk_secret_key:
|
||||
raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required")
|
||||
|
||||
# For Clerk with custom domains, we use our adapter endpoints
|
||||
# This allows us to handle the custom domain flow properly
|
||||
base_url = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||
|
||||
config = OAuthConfig(
|
||||
client_id=clerk_publishable_key,
|
||||
client_secret=clerk_secret_key,
|
||||
# Use our adapter endpoints instead of Clerk's direct endpoints
|
||||
authorization_endpoint=f"{base_url}/authorize",
|
||||
token_endpoint=f"{base_url}/token",
|
||||
# Keep Clerk's JWKS for token validation
|
||||
jwks_uri=f"https://{clerk_domain}/.well-known/jwks.json",
|
||||
issuer=base_url, # We're the issuer for MCP tokens
|
||||
scopes=["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"]
|
||||
)
|
||||
|
||||
logger.info(f"Created Clerk OAuth config with adapter endpoints")
|
||||
logger.info(f"Clerk domain: {clerk_domain}")
|
||||
logger.debug(f"Authorization endpoint: {config.authorization_endpoint}")
|
||||
logger.debug(f"Token endpoint: {config.token_endpoint}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_jwt_secret() -> str:
|
||||
"""Get JWT secret for token signing"""
|
||||
jwt_secret = os.getenv("JWT_SECRET_KEY")
|
||||
|
||||
if not jwt_secret:
|
||||
raise ValueError("JWT_SECRET_KEY environment variable is required")
|
||||
|
||||
return jwt_secret
|
||||
|
||||
|
||||
def create_mcp_server_config():
|
||||
"""Create complete MCP server configuration for Clerk integration"""
|
||||
|
||||
try:
|
||||
oauth_config = create_clerk_oauth_config()
|
||||
jwt_secret = get_jwt_secret()
|
||||
|
||||
return {
|
||||
"oauth_config": oauth_config,
|
||||
"jwt_secret": jwt_secret,
|
||||
"base_url": os.getenv("BASE_URL", "https://yargi-mcp.fly.dev"),
|
||||
"auth_enabled": os.getenv("ENABLE_AUTH", "true").lower() == "true"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create MCP server config: {e}")
|
||||
raise
|
||||
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
MCP server middleware for OAuth authentication and authorization
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
FASTMCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
FASTMCP_AVAILABLE = False
|
||||
FastMCP = None
|
||||
logger.warning("FastMCP not available, some features will be disabled")
|
||||
|
||||
from .oauth import OAuthProvider
|
||||
from .policy import PolicyEngine
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
"""Authentication context passed to MCP tools"""
|
||||
|
||||
user_id: str
|
||||
scopes: list[str]
|
||||
claims: dict[str, Any]
|
||||
token: str
|
||||
|
||||
|
||||
class MCPAuthMiddleware:
|
||||
"""Authentication middleware for MCP servers"""
|
||||
|
||||
def __init__(self, oauth_provider: OAuthProvider, policy_engine: PolicyEngine):
|
||||
self.oauth_provider = oauth_provider
|
||||
self.policy_engine = policy_engine
|
||||
|
||||
def authenticate_request(self, authorization_header: str) -> AuthContext | None:
|
||||
"""Extract and validate auth token from request"""
|
||||
|
||||
if not authorization_header:
|
||||
logger.debug("No authorization header provided")
|
||||
return None
|
||||
|
||||
if not authorization_header.startswith("Bearer "):
|
||||
logger.debug("Authorization header does not start with 'Bearer '")
|
||||
return None
|
||||
|
||||
token = authorization_header[7:] # Remove 'Bearer ' prefix
|
||||
|
||||
token_info = self.oauth_provider.introspect_token(token)
|
||||
|
||||
if not token_info.get("active"):
|
||||
logger.warning("Token is not active")
|
||||
return None
|
||||
|
||||
logger.debug(f"Authenticated user: {token_info.get('sub', 'unknown')}")
|
||||
|
||||
return AuthContext(
|
||||
user_id=token_info.get("sub", "unknown"),
|
||||
scopes=token_info.get("mcp_tool_scopes", []),
|
||||
claims=token_info,
|
||||
token=token,
|
||||
)
|
||||
|
||||
def authorize_tool_call(
|
||||
self, tool_name: str, auth_context: AuthContext
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Check if user can call the specified tool"""
|
||||
|
||||
return self.policy_engine.authorize_tool_call(
|
||||
tool_name=tool_name,
|
||||
user_scopes=auth_context.scopes,
|
||||
user_claims=auth_context.claims,
|
||||
)
|
||||
|
||||
|
||||
def auth_required(
|
||||
oauth_provider: OAuthProvider,
|
||||
policy_engine: PolicyEngine,
|
||||
tool_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
Decorator to require authentication for MCP tool functions
|
||||
|
||||
Usage:
|
||||
@auth_required(oauth_provider, policy_engine, "search_yargitay")
|
||||
def my_tool_function(context: AuthContext, ...):
|
||||
pass
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract authorization header from kwargs
|
||||
auth_header = kwargs.pop("authorization", None)
|
||||
|
||||
# Also check in args if it's a Request object
|
||||
if not auth_header and args:
|
||||
for arg in args:
|
||||
if hasattr(arg, 'headers'):
|
||||
auth_header = arg.headers.get("Authorization")
|
||||
break
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(f"No authorization header for tool '{tool_name or func.__name__}'")
|
||||
raise PermissionError("Authorization header required")
|
||||
|
||||
auth_context = middleware.authenticate_request(auth_header)
|
||||
|
||||
if not auth_context:
|
||||
logger.warning(f"Authentication failed for tool '{tool_name or func.__name__}'")
|
||||
raise PermissionError("Invalid or expired token")
|
||||
|
||||
actual_tool_name = tool_name or func.__name__
|
||||
|
||||
authorized, reason = middleware.authorize_tool_call(
|
||||
actual_tool_name, auth_context
|
||||
)
|
||||
|
||||
if not authorized:
|
||||
logger.warning(f"Authorization failed for tool '{actual_tool_name}': {reason}")
|
||||
raise PermissionError(f"Access denied: {reason}")
|
||||
|
||||
# Add auth context to function call
|
||||
return await func(auth_context, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class FastMCPAuthWrapper:
|
||||
"""Wrapper for FastMCP servers to add authentication"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_server: "FastMCP",
|
||||
oauth_provider: OAuthProvider,
|
||||
policy_engine: PolicyEngine,
|
||||
):
|
||||
if not FASTMCP_AVAILABLE:
|
||||
raise ImportError("FastMCP is required for FastMCPAuthWrapper")
|
||||
|
||||
self.mcp_server = mcp_server
|
||||
self.middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
|
||||
self.oauth_provider = oauth_provider
|
||||
logger.info("Initializing FastMCP authentication wrapper")
|
||||
self._wrap_tools()
|
||||
|
||||
def _wrap_tools(self):
|
||||
"""Wrap all existing tools with auth middleware"""
|
||||
|
||||
# Try different FastMCP tool storage locations
|
||||
tool_registry = None
|
||||
|
||||
if hasattr(self.mcp_server, '_tools'):
|
||||
tool_registry = self.mcp_server._tools
|
||||
elif hasattr(self.mcp_server, 'tools'):
|
||||
tool_registry = self.mcp_server.tools
|
||||
elif hasattr(self.mcp_server, '_tool_registry'):
|
||||
tool_registry = self.mcp_server._tool_registry
|
||||
elif hasattr(self.mcp_server, '_handlers') and hasattr(self.mcp_server._handlers, 'tools'):
|
||||
tool_registry = self.mcp_server._handlers.tools
|
||||
|
||||
if not tool_registry:
|
||||
logger.warning("FastMCP server tool registry not found, tools will not be automatically wrapped")
|
||||
logger.debug(f"Available server attributes: {dir(self.mcp_server)}")
|
||||
return
|
||||
|
||||
logger.debug(f"Found tool registry with {len(tool_registry)} tools")
|
||||
original_tools = dict(tool_registry)
|
||||
wrapped_count = 0
|
||||
|
||||
for tool_name, tool_func in original_tools.items():
|
||||
try:
|
||||
wrapped_func = self._create_auth_wrapper(tool_name, tool_func)
|
||||
tool_registry[tool_name] = wrapped_func
|
||||
wrapped_count += 1
|
||||
logger.debug(f"Wrapped tool: {tool_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to wrap tool {tool_name}: {e}")
|
||||
|
||||
logger.info(f"Successfully wrapped {wrapped_count} tools with authentication")
|
||||
|
||||
def _create_auth_wrapper(self, tool_name: str, original_func: Callable) -> Callable:
|
||||
"""Create auth wrapper for a specific tool"""
|
||||
|
||||
@functools.wraps(original_func)
|
||||
async def auth_wrapper(*args, **kwargs):
|
||||
# Extract authorization from various sources
|
||||
auth_header = None
|
||||
|
||||
# Check kwargs first
|
||||
auth_header = kwargs.pop("authorization", None)
|
||||
|
||||
# Check if first argument is a Request object
|
||||
if not auth_header and args:
|
||||
first_arg = args[0]
|
||||
if hasattr(first_arg, 'headers'):
|
||||
auth_header = first_arg.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(f"No authorization header for tool '{tool_name}'")
|
||||
raise PermissionError("Authorization required")
|
||||
|
||||
auth_context = self.middleware.authenticate_request(auth_header)
|
||||
|
||||
if not auth_context:
|
||||
logger.warning(f"Authentication failed for tool '{tool_name}'")
|
||||
raise PermissionError("Invalid token")
|
||||
|
||||
authorized, reason = self.middleware.authorize_tool_call(
|
||||
tool_name, auth_context
|
||||
)
|
||||
|
||||
if not authorized:
|
||||
logger.warning(f"Authorization failed for tool '{tool_name}': {reason}")
|
||||
raise PermissionError(f"Access denied: {reason}")
|
||||
|
||||
# Add auth context to kwargs
|
||||
kwargs["auth_context"] = auth_context
|
||||
logger.debug(f"Calling tool '{tool_name}' for user {auth_context.user_id}")
|
||||
|
||||
return await original_func(*args, **kwargs)
|
||||
|
||||
return auth_wrapper
|
||||
|
||||
def add_oauth_endpoints(self):
|
||||
"""Add OAuth endpoints to the MCP server"""
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Initiate OAuth 2.1 authorization flow with PKCE",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_authorize(redirect_uri: str, scopes: Optional[str] = None):
|
||||
"""OAuth authorization endpoint"""
|
||||
scope_list = scopes.split(" ") if scopes else None
|
||||
auth_url, pkce = self.oauth_provider.generate_authorization_url(
|
||||
redirect_uri=redirect_uri, scopes=scope_list
|
||||
)
|
||||
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
|
||||
return {
|
||||
"authorization_url": auth_url,
|
||||
"code_verifier": pkce.verifier, # For PKCE flow
|
||||
"code_challenge": pkce.challenge,
|
||||
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
|
||||
}
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Exchange OAuth authorization code for access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_token(
|
||||
code: str,
|
||||
state: str,
|
||||
redirect_uri: str
|
||||
):
|
||||
"""OAuth token exchange endpoint"""
|
||||
try:
|
||||
result = await self.oauth_provider.exchange_code_for_token(
|
||||
code=code, state=state, redirect_uri=redirect_uri
|
||||
)
|
||||
logger.info("Successfully exchanged authorization code for token")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
raise
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Validate and introspect OAuth access token",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||
)
|
||||
async def oauth_introspect(token: str):
|
||||
"""Token introspection endpoint"""
|
||||
result = self.oauth_provider.introspect_token(token)
|
||||
logger.debug(f"Token introspection: active={result.get('active', False)}")
|
||||
return result
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Revoke OAuth access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_revoke(token: str):
|
||||
"""Token revocation endpoint"""
|
||||
success = self.oauth_provider.revoke_token(token)
|
||||
logger.info(f"Token revocation: success={success}")
|
||||
return {"revoked": success}
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Get list of tools available to authenticated user",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||
)
|
||||
async def oauth_user_tools(authorization: str):
|
||||
"""Get user's allowed tools based on scopes"""
|
||||
auth_context = self.middleware.authenticate_request(authorization)
|
||||
if not auth_context:
|
||||
raise PermissionError("Invalid token")
|
||||
|
||||
allowed_patterns = self.middleware.policy_engine.get_allowed_tools(auth_context.scopes)
|
||||
|
||||
return {
|
||||
"user_id": auth_context.user_id,
|
||||
"scopes": auth_context.scopes,
|
||||
"allowed_tool_patterns": allowed_patterns,
|
||||
"message": "Use these patterns to determine which tools you can access"
|
||||
}
|
||||
|
||||
logger.info("Added OAuth endpoints: oauth_authorize, oauth_token, oauth_introspect, oauth_revoke, oauth_user_tools")
|
||||
@@ -1,304 +0,0 @@
|
||||
"""
|
||||
OAuth 2.1 + PKCE implementation for MCP servers with Clerk integration
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError, InvalidTokenError
|
||||
|
||||
from .storage import PersistentStorage
|
||||
|
||||
# Try to import Clerk SDK
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_AVAILABLE = True
|
||||
except ImportError:
|
||||
CLERK_AVAILABLE = False
|
||||
Clerk = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthConfig:
|
||||
"""OAuth provider configuration for Clerk"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
authorization_endpoint: str
|
||||
token_endpoint: str
|
||||
jwks_uri: str | None = None
|
||||
issuer: str = "mcp-auth"
|
||||
scopes: list[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.scopes is None:
|
||||
self.scopes = ["mcp:tools:read", "mcp:tools:write"]
|
||||
|
||||
|
||||
class PKCEChallenge:
|
||||
"""PKCE challenge/verifier pair for OAuth 2.1"""
|
||||
|
||||
def __init__(self):
|
||||
self.verifier = (
|
||||
base64.urlsafe_b64encode(secrets.token_bytes(32))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
challenge_bytes = hashlib.sha256(self.verifier.encode("utf-8")).digest()
|
||||
self.challenge = (
|
||||
base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=")
|
||||
)
|
||||
|
||||
|
||||
class OAuthProvider:
|
||||
"""OAuth 2.1 provider with PKCE support and Clerk integration"""
|
||||
|
||||
def __init__(self, config: OAuthConfig, jwt_secret: str):
|
||||
self.config = config
|
||||
self.jwt_secret = jwt_secret
|
||||
# Use persistent storage instead of memory
|
||||
self.storage = PersistentStorage()
|
||||
|
||||
# Initialize Clerk SDK if available
|
||||
self.clerk = None
|
||||
if CLERK_AVAILABLE and config.client_secret:
|
||||
try:
|
||||
self.clerk = Clerk(bearer_auth=config.client_secret)
|
||||
logger.info("Clerk SDK initialized successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize Clerk SDK: {e}")
|
||||
|
||||
logger.info("OAuth provider initialized with persistent storage")
|
||||
|
||||
def generate_authorization_url(
|
||||
self,
|
||||
redirect_uri: str,
|
||||
state: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> tuple[str, PKCEChallenge]:
|
||||
"""Generate OAuth authorization URL with PKCE for Clerk"""
|
||||
|
||||
pkce = PKCEChallenge()
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
|
||||
if state is None:
|
||||
state = secrets.token_urlsafe(16)
|
||||
|
||||
if scopes is None:
|
||||
scopes = self.config.scopes
|
||||
|
||||
# Store session data with expiration
|
||||
session_data = {
|
||||
"pkce_verifier": pkce.verifier,
|
||||
"state": state,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scopes": scopes,
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
}
|
||||
self.storage.set_session(session_id, session_data)
|
||||
|
||||
# Build Clerk OAuth URL
|
||||
# Check if this is a custom domain (sign-in endpoint)
|
||||
if self.config.authorization_endpoint.endswith('/sign-in'):
|
||||
# For custom domains, Clerk expects redirect_url parameter
|
||||
params = {
|
||||
"redirect_url": redirect_uri,
|
||||
"state": f"{state}:{session_id}",
|
||||
}
|
||||
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
|
||||
else:
|
||||
# Standard OAuth flow with PKCE
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.config.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": f"{state}:{session_id}", # Combine state with session ID
|
||||
"code_challenge": pkce.challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
|
||||
|
||||
logger.info(f"Generated OAuth URL with session {session_id[:8]}...")
|
||||
logger.debug(f"Auth URL: {auth_url}")
|
||||
return auth_url, pkce
|
||||
|
||||
async def exchange_code_for_token(
|
||||
self, code: str, state: str, redirect_uri: str
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange authorization code for access token with Clerk"""
|
||||
|
||||
try:
|
||||
original_state, session_id = state.split(":", 1)
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid state format: {state}")
|
||||
raise ValueError("Invalid state format") from e
|
||||
|
||||
session = self.storage.get_session(session_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found")
|
||||
raise ValueError("Invalid session")
|
||||
|
||||
# Check session expiration
|
||||
if datetime.utcnow().timestamp() > session.get("expires_at", 0):
|
||||
self.storage.delete_session(session_id)
|
||||
logger.error(f"Session {session_id} expired")
|
||||
raise ValueError("Session expired")
|
||||
|
||||
if session["state"] != original_state:
|
||||
logger.error(f"State mismatch: expected {session['state']}, got {original_state}")
|
||||
raise ValueError("State mismatch")
|
||||
|
||||
if session["redirect_uri"] != redirect_uri:
|
||||
logger.error(f"Redirect URI mismatch: expected {session['redirect_uri']}, got {redirect_uri}")
|
||||
raise ValueError("Redirect URI mismatch")
|
||||
|
||||
# Prepare token exchange request for Clerk
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.config.client_id,
|
||||
"client_secret": self.config.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": session["pkce_verifier"],
|
||||
}
|
||||
|
||||
logger.info(f"Exchanging code with Clerk for session {session_id[:8]}...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.config.token_endpoint,
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Clerk token exchange failed: {response.status_code} - {response.text}")
|
||||
raise ValueError(f"Token exchange failed: {response.text}")
|
||||
|
||||
token_response = response.json()
|
||||
logger.info("Successfully exchanged code for Clerk token")
|
||||
|
||||
# Create MCP-scoped JWT token
|
||||
access_token = self._create_mcp_token(
|
||||
session["scopes"], token_response.get("access_token"), session_id
|
||||
)
|
||||
|
||||
# Store token for introspection
|
||||
token_id = secrets.token_urlsafe(16)
|
||||
token_data = {
|
||||
"access_token": access_token,
|
||||
"scopes": session["scopes"],
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(hours=1)).timestamp(),
|
||||
"session_id": session_id,
|
||||
"clerk_token": token_response.get("access_token"),
|
||||
}
|
||||
self.storage.set_token(token_id, token_data)
|
||||
|
||||
# Clean up session
|
||||
self.storage.delete_session(session_id)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": " ".join(session["scopes"]),
|
||||
}
|
||||
|
||||
def validate_pkce(self, code_verifier: str, code_challenge: str) -> bool:
|
||||
"""Validate PKCE code challenge (RFC 7636)"""
|
||||
# S256 method
|
||||
verifier_hash = hashlib.sha256(code_verifier.encode()).digest()
|
||||
expected_challenge = base64.urlsafe_b64encode(verifier_hash).decode().rstrip('=')
|
||||
return expected_challenge == code_challenge
|
||||
|
||||
def _create_mcp_token(
|
||||
self, scopes: list[str], upstream_token: str, session_id: str
|
||||
) -> str:
|
||||
"""Create MCP-scoped JWT token with Clerk token embedded"""
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"iss": self.config.issuer,
|
||||
"sub": session_id,
|
||||
"aud": "mcp-server",
|
||||
"iat": now,
|
||||
"exp": now + 3600, # 1 hour expiration
|
||||
"mcp_tool_scopes": scopes,
|
||||
"upstream_token": upstream_token,
|
||||
"clerk_integration": True,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, self.jwt_secret, algorithm="HS256")
|
||||
|
||||
def introspect_token(self, token: str) -> dict[str, Any]:
|
||||
"""Introspect and validate MCP token"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
|
||||
# Check if token is expired
|
||||
if payload.get("exp", 0) < time.time():
|
||||
return {"active": False, "error": "token_expired"}
|
||||
|
||||
return {
|
||||
"active": True,
|
||||
"sub": payload.get("sub"),
|
||||
"aud": payload.get("aud"),
|
||||
"iss": payload.get("iss"),
|
||||
"exp": payload.get("exp"),
|
||||
"iat": payload.get("iat"),
|
||||
"mcp_tool_scopes": payload.get("mcp_tool_scopes", []),
|
||||
"upstream_token": payload.get("upstream_token"),
|
||||
"clerk_integration": payload.get("clerk_integration", False),
|
||||
}
|
||||
|
||||
except PyJWTError as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
return {"active": False, "error": "invalid_token"}
|
||||
|
||||
def revoke_token(self, token: str) -> bool:
|
||||
"""Revoke a token"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
session_id = payload.get("sub")
|
||||
|
||||
# Remove all tokens associated with this session
|
||||
all_tokens = self.storage.get_tokens()
|
||||
tokens_to_remove = [
|
||||
token_id
|
||||
for token_id, token_data in all_tokens.items()
|
||||
if token_data.get("session_id") == session_id
|
||||
]
|
||||
|
||||
for token_id in tokens_to_remove:
|
||||
self.storage.delete_token(token_id)
|
||||
|
||||
logger.info(f"Revoked {len(tokens_to_remove)} tokens for session {session_id}")
|
||||
return True
|
||||
|
||||
except InvalidTokenError as e:
|
||||
logger.warning(f"Token revocation failed: {e}")
|
||||
return False
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Clean up expired sessions and tokens"""
|
||||
# This is now handled automatically by persistent storage
|
||||
self.storage.cleanup_expired_sessions()
|
||||
logger.debug("Cleanup completed via persistent storage")
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
Authorization policy engine for MCP tools
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PolicyAction(Enum):
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPolicy:
|
||||
"""Policy rule for MCP tool access"""
|
||||
|
||||
tool_pattern: str # regex pattern for tool names
|
||||
required_scopes: list[str]
|
||||
action: PolicyAction = PolicyAction.ALLOW
|
||||
conditions: dict[str, Any] | None = None
|
||||
|
||||
def matches_tool(self, tool_name: str) -> bool:
|
||||
"""Check if the policy applies to given tool"""
|
||||
return bool(re.match(self.tool_pattern, tool_name))
|
||||
|
||||
def evaluate_scopes(self, user_scopes: list[str]) -> bool:
|
||||
"""Check if user has required scopes"""
|
||||
return all(scope in user_scopes for scope in self.required_scopes)
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Authorization policy engine for Turkish legal database tools"""
|
||||
|
||||
def __init__(self):
|
||||
self.policies: list[ToolPolicy] = []
|
||||
self.default_action = PolicyAction.DENY
|
||||
|
||||
def add_policy(self, policy: ToolPolicy):
|
||||
"""Add a policy rule"""
|
||||
self.policies.append(policy)
|
||||
logger.debug(f"Added policy: {policy.tool_pattern} -> {policy.required_scopes}")
|
||||
|
||||
def add_tool_scope_policy(
|
||||
self,
|
||||
tool_pattern: str,
|
||||
required_scopes: str | list[str],
|
||||
action: PolicyAction = PolicyAction.ALLOW,
|
||||
):
|
||||
"""Convenience method to add tool-scope policy"""
|
||||
if isinstance(required_scopes, str):
|
||||
required_scopes = [required_scopes]
|
||||
|
||||
policy = ToolPolicy(
|
||||
tool_pattern=tool_pattern, required_scopes=required_scopes, action=action
|
||||
)
|
||||
self.add_policy(policy)
|
||||
|
||||
def authorize_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
user_scopes: list[str],
|
||||
user_claims: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Authorize a tool call
|
||||
|
||||
Returns:
|
||||
(authorized: bool, reason: Optional[str])
|
||||
"""
|
||||
|
||||
logger.debug(f"Authorizing tool '{tool_name}' for user with scopes: {user_scopes}")
|
||||
|
||||
matching_policies = [
|
||||
policy for policy in self.policies if policy.matches_tool(tool_name)
|
||||
]
|
||||
|
||||
if not matching_policies:
|
||||
if self.default_action == PolicyAction.ALLOW:
|
||||
logger.debug(f"No policies found for '{tool_name}', allowing by default")
|
||||
return True, None
|
||||
else:
|
||||
logger.warning(f"No policies found for '{tool_name}', denying by default")
|
||||
return False, f"No policy found for tool '{tool_name}', default deny"
|
||||
|
||||
# Check for explicit deny policies first
|
||||
for policy in matching_policies:
|
||||
if policy.action == PolicyAction.DENY:
|
||||
if policy.evaluate_scopes(user_scopes):
|
||||
logger.warning(f"Explicit deny policy matched for '{tool_name}'")
|
||||
return False, f"Explicit deny policy for tool '{tool_name}'"
|
||||
|
||||
# Check allow policies
|
||||
allow_policies = [
|
||||
p for p in matching_policies if p.action == PolicyAction.ALLOW
|
||||
]
|
||||
|
||||
if not allow_policies:
|
||||
logger.warning(f"No allow policies found for '{tool_name}'")
|
||||
return False, f"No allow policies found for tool '{tool_name}'"
|
||||
|
||||
for policy in allow_policies:
|
||||
if policy.evaluate_scopes(user_scopes):
|
||||
if self._evaluate_conditions(policy.conditions, user_claims):
|
||||
logger.debug(f"Authorization granted for '{tool_name}'")
|
||||
return True, None
|
||||
|
||||
logger.warning(f"Insufficient scopes for '{tool_name}'. Required: {[p.required_scopes for p in allow_policies]}, User has: {user_scopes}")
|
||||
return False, f"Insufficient scopes for tool '{tool_name}'"
|
||||
|
||||
def _evaluate_conditions(
|
||||
self,
|
||||
conditions: dict[str, Any] | None,
|
||||
user_claims: dict[str, Any] | None,
|
||||
) -> bool:
|
||||
"""Evaluate additional policy conditions"""
|
||||
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
if not user_claims:
|
||||
logger.debug("No user claims provided, conditions evaluation failed")
|
||||
return False
|
||||
|
||||
for key, expected_value in conditions.items():
|
||||
user_value = user_claims.get(key)
|
||||
|
||||
if isinstance(expected_value, list):
|
||||
if user_value not in expected_value:
|
||||
logger.debug(f"Condition failed: {key} = {user_value} not in {expected_value}")
|
||||
return False
|
||||
elif user_value != expected_value:
|
||||
logger.debug(f"Condition failed: {key} = {user_value} != {expected_value}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_allowed_tools(self, user_scopes: list[str]) -> list[str]:
|
||||
"""Get list of tool patterns user is allowed to call"""
|
||||
|
||||
allowed_tools = []
|
||||
|
||||
for policy in self.policies:
|
||||
if policy.action == PolicyAction.ALLOW and policy.evaluate_scopes(
|
||||
user_scopes
|
||||
):
|
||||
allowed_tools.append(policy.tool_pattern)
|
||||
|
||||
return allowed_tools
|
||||
|
||||
|
||||
def create_turkish_legal_policies() -> PolicyEngine:
|
||||
"""Create policy set for Turkish legal database MCP server"""
|
||||
|
||||
engine = PolicyEngine()
|
||||
|
||||
# Administrative tools (full access)
|
||||
engine.add_tool_scope_policy(".*", ["mcp:tools:admin"])
|
||||
|
||||
# Search tools - require read access
|
||||
engine.add_tool_scope_policy("search.*", ["mcp:tools:read"])
|
||||
|
||||
# Fetch/get document tools - require read access
|
||||
engine.add_tool_scope_policy("get_.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("fetch.*", ["mcp:tools:read"])
|
||||
|
||||
# Specific Turkish legal database tools
|
||||
engine.add_tool_scope_policy("search_yargitay.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_danistay.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_anayasa.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_rekabet.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_kik.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_emsal.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_uyusmazlik.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_sayistay.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_.*_bedesten", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_yerel_hukuk.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_istinaf_hukuk.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_kyb.*", ["mcp:tools:read"])
|
||||
|
||||
# Document retrieval tools
|
||||
engine.add_tool_scope_policy("get_.*_document.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("get_.*_markdown", ["mcp:tools:read"])
|
||||
|
||||
# Write operations (if any future tools need them)
|
||||
engine.add_tool_scope_policy("create_.*", ["mcp:tools:write"])
|
||||
engine.add_tool_scope_policy("update_.*", ["mcp:tools:write"])
|
||||
engine.add_tool_scope_policy("delete_.*", ["mcp:tools:write"])
|
||||
|
||||
logger.info("Created Turkish legal database policy engine")
|
||||
return engine
|
||||
|
||||
|
||||
def create_default_policies() -> PolicyEngine:
|
||||
"""Create a default policy set for MCP servers (backwards compatibility)"""
|
||||
return create_turkish_legal_policies()
|
||||
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
Persistent storage for OAuth sessions and tokens
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersistentStorage:
|
||||
"""File-based persistent storage for OAuth data"""
|
||||
|
||||
def __init__(self, storage_dir: str = None):
|
||||
if storage_dir is None:
|
||||
# Use system temp directory or environment variable
|
||||
storage_dir = os.environ.get('TEMP', tempfile.gettempdir())
|
||||
|
||||
self.storage_dir = os.path.join(storage_dir, 'mcp_oauth_storage')
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
|
||||
self.sessions_file = os.path.join(self.storage_dir, 'oauth_sessions.json')
|
||||
self.tokens_file = os.path.join(self.storage_dir, 'oauth_tokens.json')
|
||||
|
||||
logger.info(f"Persistent OAuth storage initialized at: {self.storage_dir}")
|
||||
|
||||
def _load_json(self, filepath: str) -> Dict:
|
||||
"""Load JSON data from file"""
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading {filepath}: {e}")
|
||||
return {}
|
||||
|
||||
def _save_json(self, filepath: str, data: Dict):
|
||||
"""Save JSON data to file"""
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving {filepath}: {e}")
|
||||
|
||||
def get_sessions(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all OAuth sessions"""
|
||||
data = self._load_json(self.sessions_file)
|
||||
# Clean expired sessions
|
||||
now = datetime.utcnow().timestamp()
|
||||
valid_sessions = {k: v for k, v in data.items()
|
||||
if v.get('expires_at', 0) > now}
|
||||
if len(valid_sessions) != len(data):
|
||||
self._save_json(self.sessions_file, valid_sessions)
|
||||
return valid_sessions
|
||||
|
||||
def set_session(self, session_id: str, data: Dict[str, Any]):
|
||||
"""Set OAuth session data"""
|
||||
sessions = self.get_sessions()
|
||||
sessions[session_id] = data
|
||||
self._save_json(self.sessions_file, sessions)
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific OAuth session data"""
|
||||
sessions = self.get_sessions()
|
||||
return sessions.get(session_id)
|
||||
|
||||
def delete_session(self, session_id: str):
|
||||
"""Delete OAuth session"""
|
||||
sessions = self.get_sessions()
|
||||
if session_id in sessions:
|
||||
del sessions[session_id]
|
||||
self._save_json(self.sessions_file, sessions)
|
||||
|
||||
def get_tokens(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all OAuth tokens"""
|
||||
data = self._load_json(self.tokens_file)
|
||||
# Clean expired tokens
|
||||
now = datetime.utcnow().timestamp()
|
||||
valid_tokens = {k: v for k, v in data.items()
|
||||
if v.get('expires_at', 0) > now}
|
||||
if len(valid_tokens) != len(data):
|
||||
self._save_json(self.tokens_file, valid_tokens)
|
||||
return valid_tokens
|
||||
|
||||
def set_token(self, token_id: str, token_data: Dict[str, Any]):
|
||||
"""Set OAuth token data"""
|
||||
tokens = self.get_tokens()
|
||||
tokens[token_id] = token_data
|
||||
self._save_json(self.tokens_file, tokens)
|
||||
|
||||
def get_token(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific OAuth token data"""
|
||||
tokens = self.get_tokens()
|
||||
return tokens.get(token_id)
|
||||
|
||||
def delete_token(self, token_id: str):
|
||||
"""Delete OAuth token"""
|
||||
tokens = self.get_tokens()
|
||||
if token_id in tokens:
|
||||
del tokens[token_id]
|
||||
self._save_json(self.tokens_file, tokens)
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Clean up expired sessions and tokens"""
|
||||
# This is handled automatically in get_sessions() and get_tokens()
|
||||
sessions = self.get_sessions()
|
||||
tokens = self.get_tokens()
|
||||
logger.debug(f"Cleanup: {len(sessions)} active sessions, {len(tokens)} active tokens")
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Factory for creating FastMCP app with MCP Auth Toolkit integration
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
FASTMCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
FASTMCP_AVAILABLE = False
|
||||
FastMCP = None
|
||||
|
||||
from mcp_auth import (
|
||||
OAuthProvider,
|
||||
PolicyEngine,
|
||||
FastMCPAuthWrapper,
|
||||
create_default_policies
|
||||
)
|
||||
from mcp_auth.clerk_config import create_mcp_server_config
|
||||
|
||||
|
||||
def create_auth_enabled_app(app_name: str = "Yargı MCP Server") -> FastMCP:
|
||||
"""Create FastMCP app with authentication enabled"""
|
||||
|
||||
if not FASTMCP_AVAILABLE:
|
||||
raise ImportError("FastMCP is required for authenticated MCP server")
|
||||
|
||||
logger.info("Creating FastMCP app with MCP Auth Toolkit integration")
|
||||
|
||||
# Create base FastMCP app
|
||||
app = FastMCP(app_name)
|
||||
|
||||
# Check if authentication is enabled
|
||||
auth_enabled = os.getenv("ENABLE_AUTH", "true").lower() == "true"
|
||||
|
||||
if not auth_enabled:
|
||||
logger.info("Authentication disabled, returning basic FastMCP app")
|
||||
return app
|
||||
|
||||
try:
|
||||
# Get configuration
|
||||
logger.info("Getting MCP server configuration...")
|
||||
config = create_mcp_server_config()
|
||||
logger.info("Configuration loaded successfully")
|
||||
|
||||
# Create OAuth provider with Clerk config
|
||||
logger.info("Creating OAuth provider...")
|
||||
oauth_provider = OAuthProvider(
|
||||
config=config["oauth_config"],
|
||||
jwt_secret=config["jwt_secret"]
|
||||
)
|
||||
logger.info("OAuth provider created successfully")
|
||||
|
||||
# Create policy engine for Turkish legal database
|
||||
policy_engine = create_default_policies()
|
||||
|
||||
# Store auth components for later wrapping (after tools are defined)
|
||||
app._oauth_provider = oauth_provider
|
||||
app._policy_engine = policy_engine
|
||||
app._auth_config = config
|
||||
|
||||
# Add OAuth endpoints immediately
|
||||
@app.tool(
|
||||
description="Initiate OAuth 2.1 authorization flow with PKCE",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_authorize(redirect_uri: str, scopes: str = None):
|
||||
"""OAuth authorization endpoint"""
|
||||
scope_list = scopes.split(" ") if scopes else ["mcp:tools:read", "mcp:tools:write"]
|
||||
auth_url, pkce = oauth_provider.generate_authorization_url(
|
||||
redirect_uri=redirect_uri, scopes=scope_list
|
||||
)
|
||||
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
|
||||
return {
|
||||
"authorization_url": auth_url,
|
||||
"code_verifier": pkce.verifier,
|
||||
"code_challenge": pkce.challenge,
|
||||
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
|
||||
}
|
||||
|
||||
@app.tool(
|
||||
description="Exchange OAuth authorization code for access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_token(code: str, state: str, redirect_uri: str):
|
||||
"""OAuth token exchange endpoint"""
|
||||
try:
|
||||
result = await oauth_provider.exchange_code_for_token(
|
||||
code=code, state=state, redirect_uri=redirect_uri
|
||||
)
|
||||
logger.info("Successfully exchanged authorization code for token")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
raise
|
||||
|
||||
@app.tool(
|
||||
description="Validate and introspect OAuth access token",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||
)
|
||||
async def oauth_introspect(token: str):
|
||||
"""Token introspection endpoint"""
|
||||
result = oauth_provider.introspect_token(token)
|
||||
logger.debug(f"Token introspection: active={result.get('active', False)}")
|
||||
return result
|
||||
|
||||
@app.tool(
|
||||
description="Revoke OAuth access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_revoke(token: str):
|
||||
"""Token revocation endpoint"""
|
||||
success = oauth_provider.revoke_token(token)
|
||||
logger.info(f"Token revocation: success={success}")
|
||||
return {"revoked": success}
|
||||
|
||||
logger.info("Successfully created authenticated FastMCP app")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create authenticated app: {e}")
|
||||
logger.info("Falling back to non-authenticated FastMCP app")
|
||||
# Return basic app if auth setup fails
|
||||
return app
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_app() -> FastMCP:
|
||||
"""Create FastMCP app (backwards compatible with mcp_factory.py)"""
|
||||
return create_auth_enabled_app()
|
||||
|
||||
|
||||
def get_auth_wrapper(app: FastMCP) -> Optional[FastMCPAuthWrapper]:
|
||||
"""Get auth wrapper from app if available"""
|
||||
return getattr(app, '_auth_wrapper', None)
|
||||
|
||||
|
||||
def get_oauth_provider(app: FastMCP) -> Optional[OAuthProvider]:
|
||||
"""Get OAuth provider from app if available"""
|
||||
return getattr(app, '_oauth_provider', None)
|
||||
|
||||
|
||||
def get_policy_engine(app: FastMCP) -> Optional[PolicyEngine]:
|
||||
"""Get policy engine from app if available"""
|
||||
return getattr(app, '_policy_engine', None)
|
||||
|
||||
|
||||
def is_auth_enabled(app: FastMCP) -> bool:
|
||||
"""Check if authentication is enabled for the app"""
|
||||
return hasattr(app, '_oauth_provider') or hasattr(app, '_auth_wrapper')
|
||||
|
||||
|
||||
def enable_tool_authentication(app: FastMCP):
|
||||
"""Enable authentication on all existing tools (call after tools are defined)"""
|
||||
if not is_auth_enabled(app):
|
||||
logger.debug("Authentication not enabled, skipping tool authentication")
|
||||
return
|
||||
|
||||
oauth_provider = get_oauth_provider(app)
|
||||
policy_engine = get_policy_engine(app)
|
||||
|
||||
if not oauth_provider or not policy_engine:
|
||||
logger.warning("OAuth provider or policy engine not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Create auth wrapper and wrap tools
|
||||
auth_wrapper = FastMCPAuthWrapper(
|
||||
mcp_server=app,
|
||||
oauth_provider=oauth_provider,
|
||||
policy_engine=policy_engine
|
||||
)
|
||||
|
||||
# Store wrapper for reference
|
||||
app._auth_wrapper = auth_wrapper
|
||||
|
||||
logger.info("Tool authentication enabled successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enable tool authentication: {e}")
|
||||
|
||||
|
||||
def cleanup_auth_sessions(app: FastMCP):
|
||||
"""Clean up expired auth sessions and tokens"""
|
||||
oauth_provider = get_oauth_provider(app)
|
||||
if oauth_provider:
|
||||
oauth_provider.cleanup_expired_sessions()
|
||||
logger.debug("Cleaned up expired OAuth sessions")
|
||||
@@ -1,383 +0,0 @@
|
||||
"""
|
||||
HTTP adapter for MCP Auth Toolkit OAuth endpoints
|
||||
Exposes MCP OAuth tools as HTTP endpoints for Claude.ai integration
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, quote
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
|
||||
# Try to import Clerk SDK
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
CLERK_AVAILABLE = False
|
||||
Clerk = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# OAuth configuration
|
||||
BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||
|
||||
|
||||
@router.get("/.well-known/oauth-authorization-server")
|
||||
async def get_oauth_metadata():
|
||||
"""OAuth 2.0 Authorization Server Metadata (RFC 8414)"""
|
||||
return JSONResponse({
|
||||
"issuer": BASE_URL,
|
||||
"authorization_endpoint": f"{BASE_URL}/authorize",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"scopes_supported": ["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"],
|
||||
"service_documentation": f"{BASE_URL}/mcp/"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/.well-known/oauth-protected-resource")
|
||||
async def get_protected_resource_metadata():
|
||||
"""OAuth Protected Resource Metadata (RFC 9728)"""
|
||||
return JSONResponse({
|
||||
"resource": BASE_URL,
|
||||
"authorization_servers": [BASE_URL],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"scopes_supported": ["mcp:tools:read", "mcp:tools:write"],
|
||||
"resource_documentation": f"{BASE_URL}/docs"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/authorize")
|
||||
async def authorize_endpoint(
|
||||
response_type: str = Query(...),
|
||||
client_id: str = Query(...),
|
||||
redirect_uri: str = Query(...),
|
||||
code_challenge: str = Query(...),
|
||||
code_challenge_method: str = Query("S256"),
|
||||
state: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None)
|
||||
):
|
||||
"""OAuth 2.1 Authorization Endpoint - Uses Clerk SDK for custom domains"""
|
||||
|
||||
logger.info(f"OAuth authorize request - client_id: {client_id}, redirect_uri: {redirect_uri}")
|
||||
|
||||
if not CLERK_AVAILABLE:
|
||||
logger.error("Clerk SDK not available")
|
||||
raise HTTPException(status_code=500, detail="Clerk SDK not available")
|
||||
|
||||
# Store OAuth session for later validation
|
||||
try:
|
||||
from mcp_server_main import app as mcp_app
|
||||
from mcp_auth_factory import get_oauth_provider
|
||||
|
||||
oauth_provider = get_oauth_provider(mcp_app)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(status_code=500, detail="OAuth provider not configured")
|
||||
|
||||
# Generate session and store PKCE
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
if state is None:
|
||||
state = secrets.token_urlsafe(16)
|
||||
|
||||
# Create PKCE challenge
|
||||
from mcp_auth.oauth import PKCEChallenge
|
||||
pkce = PKCEChallenge()
|
||||
|
||||
# Store session data
|
||||
session_data = {
|
||||
"pkce_verifier": pkce.verifier,
|
||||
"pkce_challenge": code_challenge, # Store the client's challenge
|
||||
"state": state,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"scopes": scope.split(" ") if scope else ["mcp:tools:read", "mcp:tools:write"],
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
}
|
||||
oauth_provider.storage.set_session(session_id, session_data)
|
||||
|
||||
# For Clerk with custom domains, we need to use their hosted sign-in page
|
||||
# We'll pass our callback URL and session info in the state
|
||||
callback_url = f"{BASE_URL}/auth/callback"
|
||||
|
||||
# Encode session info in state for retrieval after Clerk auth
|
||||
combined_state = f"{state}:{session_id}"
|
||||
|
||||
# Use Clerk's sign-in URL with proper parameters
|
||||
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
|
||||
sign_in_params = {
|
||||
"redirect_url": f"{callback_url}?state={quote(combined_state)}",
|
||||
}
|
||||
|
||||
sign_in_url = f"https://{clerk_domain}/sign-in?{urlencode(sign_in_params)}"
|
||||
|
||||
logger.info(f"Redirecting to Clerk sign-in: {sign_in_url}")
|
||||
|
||||
return RedirectResponse(url=sign_in_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Authorization failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/auth/callback")
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
state: Optional[str] = Query(None),
|
||||
clerk_token: Optional[str] = Query(None)
|
||||
):
|
||||
"""Handle OAuth callback from Clerk - supports both JWT token and cookie auth"""
|
||||
|
||||
logger.info(f"OAuth callback received - state: {state}")
|
||||
logger.info(f"Query params: {dict(request.query_params)}")
|
||||
logger.info(f"Cookies: {dict(request.cookies)}")
|
||||
logger.info(f"Clerk JWT token provided: {bool(clerk_token)}")
|
||||
|
||||
# Support both JWT token (for cross-domain) and cookie auth (for subdomain)
|
||||
|
||||
try:
|
||||
if not state:
|
||||
logger.error("No state parameter provided")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Missing state parameter"}
|
||||
)
|
||||
|
||||
# Parse state to get original state and session ID
|
||||
try:
|
||||
if ":" in state:
|
||||
original_state, session_id = state.rsplit(":", 1)
|
||||
else:
|
||||
original_state = state
|
||||
session_id = state # Fallback
|
||||
except ValueError:
|
||||
logger.error(f"Invalid state format: {state}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Invalid state format"}
|
||||
)
|
||||
|
||||
# Get OAuth provider
|
||||
from mcp_server_main import app as mcp_app
|
||||
from mcp_auth_factory import get_oauth_provider
|
||||
|
||||
oauth_provider = get_oauth_provider(mcp_app)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(status_code=500, detail="OAuth provider not configured")
|
||||
|
||||
# Get stored session
|
||||
oauth_session = oauth_provider.storage.get_session(session_id)
|
||||
|
||||
if not oauth_session:
|
||||
logger.error(f"OAuth session not found for ID: {session_id}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "OAuth session expired or not found"}
|
||||
)
|
||||
|
||||
# Check if we have a JWT token (for cross-domain auth)
|
||||
user_authenticated = False
|
||||
auth_method = "none"
|
||||
|
||||
if clerk_token:
|
||||
logger.info("Attempting JWT token validation")
|
||||
try:
|
||||
# Validate JWT token with Clerk
|
||||
from clerk_backend_api import Clerk
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
|
||||
# Extract session_id from JWT token and verify with Clerk
|
||||
import jwt
|
||||
decoded_token = jwt.decode(clerk_token, options={"verify_signature": False})
|
||||
session_id = decoded_token.get("sid") or decoded_token.get("session_id")
|
||||
|
||||
if session_id:
|
||||
# Verify with Clerk using session_id
|
||||
session = clerk.sessions.verify(session_id=session_id, token=clerk_token)
|
||||
user_id = session.user_id if session else None
|
||||
else:
|
||||
user_id = None
|
||||
|
||||
if user_id:
|
||||
logger.info(f"JWT token validation successful - user_id: {user_id}")
|
||||
user_authenticated = True
|
||||
auth_method = "jwt_token"
|
||||
# Store user info in session for token exchange
|
||||
oauth_session["user_id"] = user_id
|
||||
oauth_session["auth_method"] = "jwt_token"
|
||||
else:
|
||||
logger.error("JWT token validation failed - no user_id in claims")
|
||||
except Exception as e:
|
||||
logger.error(f"JWT token validation failed: {str(e)}")
|
||||
# Fall through to cookie validation
|
||||
|
||||
# If no JWT token or validation failed, check cookies
|
||||
if not user_authenticated:
|
||||
logger.info("Checking for Clerk session cookies")
|
||||
# Check for Clerk session cookies (for subdomain auth)
|
||||
clerk_session_cookie = request.cookies.get("__session")
|
||||
if clerk_session_cookie:
|
||||
logger.info("Found Clerk session cookie, assuming authenticated")
|
||||
user_authenticated = True
|
||||
auth_method = "cookie"
|
||||
oauth_session["auth_method"] = "cookie"
|
||||
else:
|
||||
logger.info("No Clerk session cookie found")
|
||||
|
||||
# For custom domains, we'll also trust that Clerk redirected here
|
||||
if not user_authenticated:
|
||||
logger.info("Trusting Clerk redirect for custom domain flow")
|
||||
user_authenticated = True
|
||||
auth_method = "trusted_redirect"
|
||||
oauth_session["auth_method"] = "trusted_redirect"
|
||||
|
||||
logger.info(f"User authenticated: {user_authenticated}, method: {auth_method}")
|
||||
|
||||
# Generate simple authorization code for custom domain flow
|
||||
auth_code = f"clerk_custom_{session_id}_{int(time.time())}"
|
||||
|
||||
# Store the code mapping for token exchange
|
||||
code_data = {
|
||||
"session_id": session_id,
|
||||
"clerk_authenticated": user_authenticated,
|
||||
"auth_method": auth_method,
|
||||
"custom_domain_flow": True,
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=5)).timestamp(),
|
||||
}
|
||||
if "user_id" in oauth_session:
|
||||
code_data["user_id"] = oauth_session["user_id"]
|
||||
|
||||
oauth_provider.storage.set_session(f"code_{auth_code}", code_data)
|
||||
|
||||
# Build redirect URL back to Claude
|
||||
redirect_params = {
|
||||
"code": auth_code,
|
||||
"state": original_state
|
||||
}
|
||||
|
||||
redirect_url = f"{oauth_session['redirect_uri']}?{urlencode(redirect_params)}"
|
||||
logger.info(f"Redirecting back to Claude: {redirect_url}")
|
||||
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Callback processing failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_client(request: Request):
|
||||
"""Dynamic Client Registration (RFC 7591)"""
|
||||
|
||||
data = await request.json()
|
||||
logger.info(f"Client registration request: {data}")
|
||||
|
||||
# Simple dynamic registration - accept any client
|
||||
client_id = f"mcp-client-{os.urandom(8).hex()}"
|
||||
|
||||
return JSONResponse({
|
||||
"client_id": client_id,
|
||||
"client_secret": None, # Public client
|
||||
"redirect_uris": data.get("redirect_uris", []),
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"client_name": data.get("client_name", "MCP Client"),
|
||||
"token_endpoint_auth_method": "none",
|
||||
"client_id_issued_at": int(datetime.now().timestamp())
|
||||
})
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def token_endpoint(request: Request):
|
||||
"""OAuth 2.1 Token Endpoint"""
|
||||
|
||||
# Parse form data
|
||||
form_data = await request.form()
|
||||
grant_type = form_data.get("grant_type")
|
||||
code = form_data.get("code")
|
||||
redirect_uri = form_data.get("redirect_uri")
|
||||
client_id = form_data.get("client_id")
|
||||
code_verifier = form_data.get("code_verifier")
|
||||
|
||||
logger.info(f"Token exchange - grant_type: {grant_type}, code: {code[:20] if code else 'None'}...")
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "unsupported_grant_type"}
|
||||
)
|
||||
|
||||
try:
|
||||
# OAuth token exchange - validate code and return Clerk JWT
|
||||
# This supports proper OAuth flow while using Clerk JWT tokens
|
||||
|
||||
if not code or not redirect_uri:
|
||||
logger.error("Missing required parameters: code or redirect_uri")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
|
||||
)
|
||||
|
||||
# Validate OAuth code with Clerk
|
||||
if CLERK_AVAILABLE:
|
||||
try:
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
|
||||
# In a real implementation, you'd validate the code with Clerk
|
||||
# For now, we'll assume the code is valid if it looks like a Clerk code
|
||||
if len(code) > 10: # Basic validation
|
||||
# Create a mock session with the code
|
||||
# In practice, this would be validated with Clerk's OAuth flow
|
||||
|
||||
# Return Clerk JWT token format
|
||||
# This should be the actual Clerk JWT token from the OAuth flow
|
||||
return JSONResponse({
|
||||
"access_token": f"mock_clerk_jwt_{code}",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "yargi.read yargi.search"
|
||||
})
|
||||
else:
|
||||
logger.error(f"Invalid code format: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Clerk validation failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code validation failed"}
|
||||
)
|
||||
else:
|
||||
logger.warning("Clerk SDK not available, using mock response")
|
||||
return JSONResponse({
|
||||
"access_token": "mock_jwt_token_for_development",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "yargi.read yargi.search"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Token exchange failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
@@ -1,522 +0,0 @@
|
||||
"""
|
||||
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)}
|
||||
)
|
||||
+875
-359
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Migration stub for the deprecated Yargı MCP endpoint.
|
||||
|
||||
Exposes a single tool that informs the MCP client the server has moved
|
||||
and the user must update their configuration.
|
||||
|
||||
Entrypoint variable `app` is a FastMCP instance so it works with
|
||||
Dokploy's FastMCP build pipeline (`fastmcp inspect`, `fastmcp run`).
|
||||
|
||||
Run with:
|
||||
fastmcp run migration_app.py:app --transport http --port 8000
|
||||
"""
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from fastmcp import FastMCP
|
||||
|
||||
NEW_URL = "https://yargimcp.surucu.dev/mcp"
|
||||
|
||||
app = FastMCP(
|
||||
name="Yargı MCP (taşındı / moved)",
|
||||
instructions=(
|
||||
f"Bu Yargı MCP endpoint'i kullanımdan kaldırıldı. "
|
||||
f"Sunucu yeni adrese taşındı: {NEW_URL}. "
|
||||
f"Kullanıcıya MCP istemci ayarlarındaki sunucu URL'sini güncellemesini söyle."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description=(
|
||||
"DEPRECATED ENDPOINT — Yargı MCP sunucusu yeni adrese taşındı. "
|
||||
"Bu endpoint'teki eski araçlar (Yargıtay, Danıştay, Anayasa Mahkemesi, "
|
||||
"KVKK, BDDK, Rekabet Kurumu, Sayıştay, KİK, Emsal, Uyuşmazlık, GİB, "
|
||||
"Sigorta Tahkim, Bedesten vb.) artık burada çalışmıyor. "
|
||||
"Bu aracı çağırarak yeni sunucu adresini ve migrasyon talimatını al, "
|
||||
"ardından kullanıcıya MCP istemci (Claude Desktop, 5ire, ChatGPT vb.) "
|
||||
"ayarlarındaki sunucu URL'sini güncellemesi gerektiğini açıkça söyle."
|
||||
)
|
||||
)
|
||||
def migration_notice() -> dict:
|
||||
"""Yargı MCP sunucusunun yeni adresini ve migrasyon talimatını döner."""
|
||||
return {
|
||||
"status": "moved",
|
||||
"new_url": NEW_URL,
|
||||
"message": (
|
||||
f"Yargı MCP sunucusu yeni adrese taşındı: {NEW_URL}\n\n"
|
||||
f"Lütfen MCP istemcinin (Claude Desktop, 5ire, ChatGPT vb.) "
|
||||
f"ayarlarındaki sunucu URL'sini yukarıdaki yeni adresle güncelleyin. "
|
||||
f"Mevcut endpoint artık kullanım dışıdır ve sadece bu uyarıyı döner."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.custom_route("/health", methods=["GET"])
|
||||
async def health(request):
|
||||
"""Health check endpoint for monitoring services."""
|
||||
return JSONResponse({"status": "deprecated", "new_url": NEW_URL})
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream yargi_mcp {
|
||||
server yargi-mcp:8000;
|
||||
}
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=100r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Redirect HTTP to HTTPS in production
|
||||
# return 301 https://$server_name$request_uri;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
|
||||
proxy_pass http://yargi_mcp;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# MCP endpoint (higher rate limit)
|
||||
location /mcp-server/mcp/ {
|
||||
limit_req zone=mcp_limit burst=50 nodelay;
|
||||
|
||||
proxy_pass http://yargi_mcp;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Longer timeouts for MCP operations
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Health check (no rate limit)
|
||||
location /health {
|
||||
proxy_pass http://yargi_mcp;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Root and other paths
|
||||
location / {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
|
||||
proxy_pass http://yargi_mcp;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# SSL configuration (uncomment for production)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
#
|
||||
# # Include all location blocks from above
|
||||
# }
|
||||
}
|
||||
+7
-12
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "yargi-mcp"
|
||||
version = "0.1.9"
|
||||
version = "0.2.2"
|
||||
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"]
|
||||
keywords = ["mcp", "turkish-law", "legal", "yargitay", "danistay", "bddk", "btk", "kvkk", "turkish", "law", "court", "decisions"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Legal Industry",
|
||||
@@ -25,10 +25,12 @@ dependencies = [
|
||||
"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",
|
||||
"cryptography>=44.0.0",
|
||||
"openai>=1.0.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -44,22 +46,15 @@ production = [
|
||||
"gunicorn>=22.0.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
]
|
||||
saas = [
|
||||
"clerk-backend-api>=3.0.0",
|
||||
"stripe>=9.1.0",
|
||||
"upstash-redis>=1.1.0",
|
||||
"tiktoken>=0.5.0",
|
||||
"PyJWT>=2.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
yargi-mcp = "mcp_server_main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["mcp_server_main", "mcp_auth_factory", "mcp_auth_http_adapter", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "stripe_webhook"]
|
||||
py-modules = ["mcp_server_main", "asgi_app"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["*_mcp_module", "mcp_auth"]
|
||||
include = ["*_mcp_module", "semantic_search"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=65.0", "wheel"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# rekabet_mcp_module/client.py
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
@@ -141,12 +142,12 @@ class RekabetKurumuApiClient:
|
||||
|
||||
# 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
|
||||
pub_date = td_elements_r1[0].get_text(strip=True) if len(td_elements_r1) > 0 else ""
|
||||
dec_num = td_elements_r1[1].get_text(strip=True) if len(td_elements_r1) > 1 else ""
|
||||
|
||||
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
|
||||
related_cases_url_str: str = ""
|
||||
karar_id_from_related: str = ""
|
||||
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)
|
||||
@@ -155,16 +156,16 @@ class RekabetKurumuApiClient:
|
||||
|
||||
# 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
|
||||
dec_date = td_elements_r2[0].get_text(strip=True) if len(td_elements_r2) > 0 else ""
|
||||
dec_type_text = td_elements_r2[1].get_text(strip=True) if len(td_elements_r2) > 1 else ""
|
||||
|
||||
# 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
|
||||
title_text: str = ""
|
||||
decision_landing_url_str: str = ""
|
||||
karar_id_from_main_link: str = ""
|
||||
|
||||
if decision_link_tag and decision_link_tag.has_attr('href'):
|
||||
title_text = decision_link_tag.get_text(strip=True)
|
||||
@@ -185,16 +186,12 @@ class RekabetKurumuApiClient:
|
||||
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,
|
||||
decision_url=decision_landing_url_str,
|
||||
karar_id=current_karar_id,
|
||||
related_cases_url=final_related_cases_url
|
||||
related_cases_url=related_cases_url_str
|
||||
))
|
||||
logger.debug(f"Table {idx+1} parsed successfully: Karar ID '{current_karar_id}', Title '{title_text[:50] if title_text else 'N/A'}...'")
|
||||
|
||||
@@ -357,7 +354,7 @@ class RekabetKurumuApiClient:
|
||||
total_pdf_pages = total_pdf_pages_from_extraction
|
||||
|
||||
if single_page_pdf_bytes:
|
||||
markdown_for_requested_page = self._convert_pdf_bytes_to_markdown(single_page_pdf_bytes, str(pdf_url_to_report or full_landing_page_url))
|
||||
markdown_for_requested_page = await asyncio.to_thread(self._convert_pdf_bytes_to_markdown, single_page_pdf_bytes, str(pdf_url_to_report or full_landing_page_url))
|
||||
if not markdown_for_requested_page:
|
||||
error_message = (error_message or "") + f"; Could not convert page {page_number} of PDF to Markdown."
|
||||
elif total_pdf_pages > 0 :
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
fastmcp
|
||||
httpx
|
||||
beautifulsoup4
|
||||
markitdown[pdf]
|
||||
pydantic
|
||||
aiohttp
|
||||
playwright
|
||||
pypdf
|
||||
fastapi>=0.115.14
|
||||
uvicorn[standard]>=0.30.0
|
||||
starlette>=0.37.0
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone ASGI server runner for Yargı MCP
|
||||
|
||||
This script provides a simple way to run the Yargı MCP server
|
||||
as a web service using uvicorn.
|
||||
|
||||
Usage:
|
||||
python run_asgi.py
|
||||
python run_asgi.py --host 0.0.0.0 --port 8080
|
||||
python run_asgi.py --reload # For development
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
try:
|
||||
import uvicorn
|
||||
except ImportError:
|
||||
print("Error: uvicorn is not installed.")
|
||||
print("Please install it with: pip install uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Yargı MCP server as an ASGI web service"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default=os.getenv("HOST", "127.0.0.1"),
|
||||
help="Host to bind to (default: 127.0.0.1)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=int(os.getenv("PORT", "8000")),
|
||||
help="Port to bind to (default: 8000)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reload",
|
||||
action="store_true",
|
||||
help="Enable auto-reload for development"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["http", "sse"],
|
||||
default="http",
|
||||
help="Transport type (default: http)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
choices=["debug", "info", "warning", "error"],
|
||||
default=os.getenv("LOG_LEVEL", "info").lower(),
|
||||
help="Log level (default: info)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of worker processes (default: 1)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Select app based on transport
|
||||
app_name = "asgi_app:app" if args.transport == "http" else "asgi_app:sse_app"
|
||||
|
||||
# Configure uvicorn
|
||||
config = {
|
||||
"app": app_name,
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"log_level": args.log_level,
|
||||
"reload": args.reload,
|
||||
"access_log": True,
|
||||
}
|
||||
|
||||
# Add workers only if not in reload mode
|
||||
if not args.reload and args.workers > 1:
|
||||
config["workers"] = args.workers
|
||||
|
||||
# Print startup information
|
||||
print(f"Starting Yargı MCP server...")
|
||||
print(f"Host: {args.host}")
|
||||
print(f"Port: {args.port}")
|
||||
print(f"Transport: {args.transport}")
|
||||
print(f"Log level: {args.log_level}")
|
||||
if args.reload:
|
||||
print("Auto-reload: enabled")
|
||||
else:
|
||||
print(f"Workers: {args.workers}")
|
||||
print(f"\nServer will be available at: http://{args.host}:{args.port}")
|
||||
print(f"MCP endpoint: http://{args.host}:{args.port}/mcp/")
|
||||
print(f"Health check: http://{args.host}:{args.port}/health")
|
||||
print(f"API status: http://{args.host}:{args.port}/status")
|
||||
print("\nPress CTRL+C to stop the server\n")
|
||||
|
||||
# Run uvicorn
|
||||
try:
|
||||
uvicorn.run(**config)
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down server...")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,186 +0,0 @@
|
||||
# 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
|
||||
@@ -1,103 +0,0 @@
|
||||
# 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
|
||||
@@ -1,2 +0,0 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB |
@@ -1,29 +0,0 @@
|
||||
# -------- 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"]
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1 +0,0 @@
|
||||
web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT
|
||||
@@ -1,272 +0,0 @@
|
||||
# Yargı MCP: Türk Hukuk Kaynakları için MCP Sunucusu
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
🎯 **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!
|
||||
---
|
||||
<details>
|
||||
<summary>🚀 <strong>Claude Haricindeki Modellerle Kullanmak İçin Çok Kolay Kurulum (Örnek: 5ire için)</strong></summary>
|
||||
|
||||
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.
|
||||

|
||||
* Ş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.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>⚙️ <strong>Claude Desktop Manuel Kurulumu</strong></summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>🌟 <strong>Gemini CLI ile Kullanım</strong></summary>
|
||||
|
||||
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"
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ <strong>Kullanılabilir Araçlar (MCP Tools)</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>📊 <strong>Kapsamlı İstatistikler & Optimizasyon Başarıları</strong></summary>
|
||||
|
||||
🚀 **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)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>🌐 <strong>Web Service / ASGI Deployment</strong></summary>
|
||||
|
||||
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)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
📜 **Lisans**
|
||||
|
||||
Bu proje MIT Lisansı altında lisanslanmıştır. Detaylar için `LICENSE` dosyasına bakınız.
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point for yargi-mcp package."""
|
||||
|
||||
from mcp_server_main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,355 +0,0 @@
|
||||
# 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", "<!doctype")):
|
||||
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
|
||||
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.")
|
||||
@@ -1,356 +0,0 @@
|
||||
# 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", "<!doctype")):
|
||||
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
|
||||
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 <p> 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.")
|
||||
@@ -1,230 +0,0 @@
|
||||
# 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")
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,566 +0,0 @@
|
||||
"""
|
||||
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"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Successful</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.success {{ color: #28a745; }}
|
||||
.token {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; word-break: break-all; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">✅ MCP Connection Successful!</h1>
|
||||
<p>Your Yargı MCP integration is now active.</p>
|
||||
<div class="token">
|
||||
<strong>Authentication:</strong><br>
|
||||
<code>Use your Clerk JWT token directly with Bearer authentication</code>
|
||||
</div>
|
||||
<p>You can now close this window and return to your MCP client.</p>
|
||||
<script>
|
||||
// Try to close the popup if opened as such
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{
|
||||
type: 'MCP_AUTH_SUCCESS',
|
||||
token: 'use_clerk_jwt_token'
|
||||
}}, '*');
|
||||
setTimeout(() => window.close(), 3000);
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
except HTTPException as e:
|
||||
logger.error(f"MCP OAuth callback failed: {e.detail}")
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Failed</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
.debug {{ background: #f8f9fa; padding: 10px; margin: 20px 0; border-radius: 5px; font-family: monospace; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">❌ MCP Connection Failed</h1>
|
||||
<p>{e.detail}</p>
|
||||
<div class="debug">
|
||||
<strong>Debug Info:</strong><br>
|
||||
Clerk Token: {'✅ Provided' if clerk_token else '❌ Missing'}<br>
|
||||
Error: {e.detail}<br>
|
||||
Status: {e.status_code}
|
||||
</div>
|
||||
<p>Please try again or contact support.</p>
|
||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
||||
</body>
|
||||
</html>
|
||||
""", status_code=e.status_code)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in MCP OAuth callback: {str(e)}")
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Connection Error</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">❌ Unexpected Error</h1>
|
||||
<p>An unexpected error occurred during authentication.</p>
|
||||
<p>Error: {str(e)}</p>
|
||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
||||
</body>
|
||||
</html>
|
||||
""", status_code=500)
|
||||
|
||||
# OAuth2 Token Endpoint - Now uses Clerk JWT tokens directly
|
||||
@app.post("/auth/mcp-token")
|
||||
async def mcp_token_endpoint(request: Request):
|
||||
"""OAuth2 token endpoint for MCP clients - returns Clerk JWT token info"""
|
||||
try:
|
||||
# Validate Clerk session
|
||||
user_id = await validate_clerk_session(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"]
|
||||
@@ -1,17 +0,0 @@
|
||||
# bddk_mcp_module/__init__.py
|
||||
|
||||
from .client import BddkApiClient
|
||||
from .models import (
|
||||
BddkSearchRequest,
|
||||
BddkDecisionSummary,
|
||||
BddkSearchResult,
|
||||
BddkDocumentMarkdown
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BddkApiClient",
|
||||
"BddkSearchRequest",
|
||||
"BddkDecisionSummary",
|
||||
"BddkSearchResult",
|
||||
"BddkDocumentMarkdown"
|
||||
]
|
||||
@@ -1,247 +0,0 @@
|
||||
# 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)}")
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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")
|
||||
@@ -1 +0,0 @@
|
||||
# bedesten_mcp_module/__init__.py
|
||||
@@ -1,181 +0,0 @@
|
||||
# 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.")
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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)")
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,192 +0,0 @@
|
||||
# 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.")
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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
|
||||
@@ -1,66 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
yargi-mcp:
|
||||
build: .
|
||||
image: yargi-mcp:latest
|
||||
container_name: yargi-mcp-server
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- API_TOKEN=${API_TOKEN:-}
|
||||
- PYTHONUNBUFFERED=1
|
||||
volumes:
|
||||
# Mount logs directory
|
||||
- ./logs:/app/logs
|
||||
# Mount .env file if it exists
|
||||
- ./.env:/app/.env:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- yargi-network
|
||||
|
||||
# Optional: Nginx reverse proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: yargi-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- yargi-mcp
|
||||
networks:
|
||||
- yargi-network
|
||||
profiles:
|
||||
- production
|
||||
|
||||
# Optional: Redis for caching (future enhancement)
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: yargi-redis
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- yargi-network
|
||||
profiles:
|
||||
- with-cache
|
||||
|
||||
networks:
|
||||
yargi-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
@@ -1,428 +0,0 @@
|
||||
# Yargı MCP Server Dağıtım Rehberi
|
||||
|
||||
Bu rehber, Yargı MCP Server'ın ASGI web servisi olarak çeşitli dağıtım seçeneklerini kapsar.
|
||||
|
||||
## İçindekiler
|
||||
|
||||
- [Hızlı Başlangıç](#hızlı-başlangıç)
|
||||
- [Yerel Geliştirme](#yerel-geliştirme)
|
||||
- [Production Dağıtımı](#production-dağıtımı)
|
||||
- [Cloud Dağıtımı](#cloud-dağıtımı)
|
||||
- [Docker Dağıtımı](#docker-dağıtımı)
|
||||
- [Güvenlik Hususları](#güvenlik-hususları)
|
||||
- [İzleme](#izleme)
|
||||
|
||||
## Hızlı Başlangıç
|
||||
|
||||
### 1. Bağımlılıkları Yükleyin
|
||||
|
||||
```bash
|
||||
# ASGI sunucusu için uvicorn yükleyin
|
||||
pip install uvicorn
|
||||
|
||||
# Veya tüm bağımlılıklarla birlikte yükleyin
|
||||
pip install -e .
|
||||
pip install uvicorn
|
||||
```
|
||||
|
||||
### 2. Sunucuyu Çalıştırın
|
||||
|
||||
```bash
|
||||
# Temel başlatma
|
||||
python run_asgi.py
|
||||
|
||||
# Veya doğrudan uvicorn ile
|
||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Sunucu şu adreslerde kullanılabilir olacak:
|
||||
- MCP Endpoint: `http://localhost:8000/mcp/`
|
||||
- Sağlık Kontrolü: `http://localhost:8000/health`
|
||||
- API Durumu: `http://localhost:8000/status`
|
||||
|
||||
## Yerel Geliştirme
|
||||
|
||||
### Otomatik Yeniden Yükleme ile Geliştirme Sunucusu
|
||||
|
||||
```bash
|
||||
python run_asgi.py --reload --log-level debug
|
||||
```
|
||||
|
||||
### FastAPI Entegrasyonunu Kullanma
|
||||
|
||||
Ek REST API endpoint'leri için:
|
||||
|
||||
```bash
|
||||
uvicorn fastapi_app:app --reload
|
||||
```
|
||||
|
||||
Bu şunları sağlar:
|
||||
- `/docs` adresinde interaktif API dokümantasyonu
|
||||
- `/api/tools` adresinde araç listesi
|
||||
- `/api/databases` adresinde veritabanı bilgileri
|
||||
|
||||
### Ortam Değişkenleri
|
||||
|
||||
`.env.example` dosyasını temel alarak bir `.env` dosyası oluşturun:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Temel değişkenler:
|
||||
- `HOST`: Sunucu host adresi (varsayılan: 127.0.0.1)
|
||||
- `PORT`: Sunucu portu (varsayılan: 8000)
|
||||
- `ALLOWED_ORIGINS`: CORS kökenleri (virgülle ayrılmış)
|
||||
- `LOG_LEVEL`: Log seviyesi (debug, info, warning, error)
|
||||
|
||||
## Production Dağıtımı
|
||||
|
||||
### 1. Uvicorn ile Çoklu Worker Kullanımı
|
||||
|
||||
```bash
|
||||
python run_asgi.py --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
### 2. Gunicorn Kullanımı
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn asgi_app:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
### 3. Nginx Reverse Proxy ile
|
||||
|
||||
1. Nginx'i yükleyin
|
||||
2. Sağlanan `nginx.conf` dosyasını kullanın:
|
||||
|
||||
```bash
|
||||
sudo cp nginx.conf /etc/nginx/sites-available/yargi-mcp
|
||||
sudo ln -s /etc/nginx/sites-available/yargi-mcp /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. Systemd Servisi
|
||||
|
||||
`/etc/systemd/system/yargi-mcp.service` dosyasını oluşturun:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Yargı MCP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/yargi-mcp
|
||||
Environment="PATH=/opt/yargi-mcp/venv/bin"
|
||||
ExecStart=/opt/yargi-mcp/venv/bin/uvicorn asgi_app:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Etkinleştirin ve başlatın:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable yargi-mcp
|
||||
sudo systemctl start yargi-mcp
|
||||
```
|
||||
|
||||
## Cloud Dağıtımı
|
||||
|
||||
### Heroku
|
||||
|
||||
1. `Procfile` oluşturun:
|
||||
```
|
||||
web: uvicorn asgi_app:app --host 0.0.0.0 --port $PORT
|
||||
```
|
||||
|
||||
2. Dağıtın:
|
||||
```bash
|
||||
heroku create uygulama-isminiz
|
||||
git push heroku main
|
||||
```
|
||||
|
||||
### Railway
|
||||
|
||||
1. `railway.json` ekleyin:
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"builder": "NIXPACKS"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "uvicorn asgi_app:app --host 0.0.0.0 --port $PORT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Railway CLI veya GitHub entegrasyonu ile dağıtın
|
||||
|
||||
### Google Cloud Run
|
||||
|
||||
1. Container oluşturun:
|
||||
```bash
|
||||
docker build -t yargi-mcp .
|
||||
docker tag yargi-mcp gcr.io/PROJE_ADINIZ/yargi-mcp
|
||||
docker push gcr.io/PROJE_ADINIZ/yargi-mcp
|
||||
```
|
||||
|
||||
2. Dağıtın:
|
||||
```bash
|
||||
gcloud run deploy yargi-mcp \
|
||||
--image gcr.io/PROJE_ADINIZ/yargi-mcp \
|
||||
--platform managed \
|
||||
--region us-central1 \
|
||||
--allow-unauthenticated
|
||||
```
|
||||
|
||||
### AWS Lambda (Mangum kullanarak)
|
||||
|
||||
1. Mangum'u yükleyin:
|
||||
```bash
|
||||
pip install mangum
|
||||
```
|
||||
|
||||
2. `lambda_handler.py` oluşturun:
|
||||
```python
|
||||
from mangum import Mangum
|
||||
from asgi_app import app
|
||||
|
||||
handler = Mangum(app, lifespan="off")
|
||||
```
|
||||
|
||||
3. AWS SAM veya Serverless Framework kullanarak dağıtın
|
||||
|
||||
## Docker Dağıtımı
|
||||
|
||||
### Tek Container
|
||||
|
||||
```bash
|
||||
# Oluşturun
|
||||
docker build -t yargi-mcp .
|
||||
|
||||
# Çalıştırın
|
||||
docker run -p 8000:8000 --env-file .env yargi-mcp
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
# Geliştirme
|
||||
docker-compose up
|
||||
|
||||
# Nginx ile Production
|
||||
docker-compose --profile production up
|
||||
|
||||
# Redis önbellekleme ile
|
||||
docker-compose --profile with-cache up
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Deployment YAML oluşturun:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: yargi-mcp
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: yargi-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: yargi-mcp
|
||||
spec:
|
||||
containers:
|
||||
- name: yargi-mcp
|
||||
image: yargi-mcp:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "8000"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: yargi-mcp-service
|
||||
spec:
|
||||
selector:
|
||||
app: yargi-mcp
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
## Güvenlik Hususları
|
||||
|
||||
### 1. Kimlik Doğrulama
|
||||
|
||||
`API_TOKEN` ortam değişkenini ayarlayarak token kimlik doğrulamasını etkinleştirin:
|
||||
|
||||
```bash
|
||||
export API_TOKEN=gizli-token-degeri
|
||||
```
|
||||
|
||||
Ardından isteklere ekleyin:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer gizli-token-degeri" http://localhost:8000/api/tools
|
||||
```
|
||||
|
||||
### 2. HTTPS/SSL
|
||||
|
||||
Production için her zaman HTTPS kullanın:
|
||||
|
||||
1. SSL sertifikası edinin (Let's Encrypt vb.)
|
||||
2. Nginx veya cloud sağlayıcıda yapılandırın
|
||||
3. `ALLOWED_ORIGINS` değerini https:// kullanacak şekilde güncelleyin
|
||||
|
||||
### 3. Rate Limiting (Hız Sınırlama)
|
||||
|
||||
Sağlanan Nginx yapılandırması rate limiting içerir:
|
||||
- API endpoint'leri: 10 istek/saniye
|
||||
- MCP endpoint: 100 istek/saniye
|
||||
|
||||
### 4. CORS Yapılandırması
|
||||
|
||||
Production için belirli kaynaklara izin verin:
|
||||
|
||||
```bash
|
||||
ALLOWED_ORIGINS=https://app.sizindomain.com,https://www.sizindomain.com
|
||||
```
|
||||
|
||||
## İzleme
|
||||
|
||||
### Sağlık Kontrolleri
|
||||
|
||||
`/health` endpoint'ini izleyin:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Yanıt:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-12-26T10:00:00",
|
||||
"uptime_seconds": 3600,
|
||||
"tools_operational": true
|
||||
}
|
||||
```
|
||||
|
||||
### Loglama
|
||||
|
||||
Ortam değişkeni ile log seviyesini yapılandırın:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=info # veya debug, warning, error
|
||||
```
|
||||
|
||||
Loglar şuraya yazılır:
|
||||
- Konsol (stdout)
|
||||
- `logs/mcp_server.log` dosyası
|
||||
|
||||
### Metrikler (Opsiyonel)
|
||||
|
||||
OpenTelemetry desteği için:
|
||||
|
||||
```bash
|
||||
pip install opentelemetry-instrumentation-fastapi
|
||||
```
|
||||
|
||||
Ortam değişkenlerini ayarlayın:
|
||||
```bash
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=yargi-mcp-server
|
||||
```
|
||||
|
||||
## Sorun Giderme
|
||||
|
||||
### Port Zaten Kullanımda
|
||||
|
||||
```bash
|
||||
# 8000 portunu kullanan işlemi bulun
|
||||
lsof -i :8000
|
||||
|
||||
# İşlemi sonlandırın
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### İzin Hataları
|
||||
|
||||
Dosya izinlerinin doğru olduğundan emin olun:
|
||||
|
||||
```bash
|
||||
chmod +x run_asgi.py
|
||||
chown -R www-data:www-data /opt/yargi-mcp
|
||||
```
|
||||
|
||||
### Bellek Sorunları
|
||||
|
||||
Büyük belge işleme için worker belleğini artırın:
|
||||
|
||||
```bash
|
||||
# systemd servisinde
|
||||
Environment="PYTHONMALLOC=malloc"
|
||||
LimitNOFILE=65536
|
||||
```
|
||||
|
||||
### Zaman Aşımı Sorunları
|
||||
|
||||
Zaman aşımlarını ayarlayın:
|
||||
1. Uvicorn: `--timeout-keep-alive 75`
|
||||
2. Nginx: `proxy_read_timeout 300s;`
|
||||
3. Cloud sağlayıcılar: Platform özel zaman aşımı ayarlarını kontrol edin
|
||||
|
||||
## Performans Ayarlama
|
||||
|
||||
### 1. Worker İşlemleri
|
||||
|
||||
- Geliştirme: 1 worker
|
||||
- Production: CPU çekirdeği başına 2-4 worker
|
||||
|
||||
### 2. Bağlantı Havuzlama
|
||||
|
||||
Sunucu varsayılan olarak httpx ile bağlantı havuzlama kullanır.
|
||||
|
||||
### 3. Önbellekleme (Gelecek Geliştirme)
|
||||
|
||||
Redis önbellekleme docker-compose ile etkinleştirilebilir:
|
||||
|
||||
```bash
|
||||
docker-compose --profile with-cache up
|
||||
```
|
||||
|
||||
### 4. Veritabanı Zaman Aşımları
|
||||
|
||||
`.env` dosyasında veritabanı başına zaman aşımlarını ayarlayın:
|
||||
|
||||
```bash
|
||||
YARGITAY_TIMEOUT=60
|
||||
DANISTAY_TIMEOUT=60
|
||||
ANAYASA_TIMEOUT=90
|
||||
```
|
||||
|
||||
## Destek
|
||||
|
||||
Sorunlar ve sorular için:
|
||||
- GitHub Issues: https://github.com/saidsurucu/yargi-mcp/issues
|
||||
- Dokümantasyon: README.md dosyasına bakın
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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><head>..."
|
||||
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.")
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
||||
# 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)
|
||||
@@ -1 +0,0 @@
|
||||
# kvkk_mcp_module/__init__.py
|
||||
@@ -1,372 +0,0 @@
|
||||
# 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.")
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
MCP Auth Toolkit - OAuth 2.1 + Authorization for Model Context Protocol Servers
|
||||
Integrated with Clerk Authentication
|
||||
"""
|
||||
|
||||
from .middleware import (
|
||||
AuthContext,
|
||||
FastMCPAuthWrapper,
|
||||
MCPAuthMiddleware,
|
||||
auth_required,
|
||||
)
|
||||
from .oauth import OAuthConfig, OAuthProvider
|
||||
from .policy import PolicyEngine, ToolPolicy, create_default_policies
|
||||
from .storage import PersistentStorage
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"OAuthProvider",
|
||||
"OAuthConfig",
|
||||
"AuthContext",
|
||||
"auth_required",
|
||||
"create_default_policies",
|
||||
"MCPAuthMiddleware",
|
||||
"FastMCPAuthWrapper",
|
||||
"PolicyEngine",
|
||||
"ToolPolicy",
|
||||
"PersistentStorage",
|
||||
]
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Clerk OAuth configuration for MCP Auth Toolkit
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from .oauth import OAuthConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_clerk_oauth_config() -> OAuthConfig:
|
||||
"""Create OAuth configuration for Clerk integration using SDK"""
|
||||
|
||||
# Get Clerk configuration from environment
|
||||
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
|
||||
clerk_publishable_key = os.getenv("CLERK_PUBLISHABLE_KEY")
|
||||
clerk_secret_key = os.getenv("CLERK_SECRET_KEY")
|
||||
|
||||
if not clerk_publishable_key or not clerk_secret_key:
|
||||
raise ValueError("CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are required")
|
||||
|
||||
# For Clerk with custom domains, we use our adapter endpoints
|
||||
# This allows us to handle the custom domain flow properly
|
||||
base_url = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||
|
||||
config = OAuthConfig(
|
||||
client_id=clerk_publishable_key,
|
||||
client_secret=clerk_secret_key,
|
||||
# Use our adapter endpoints instead of Clerk's direct endpoints
|
||||
authorization_endpoint=f"{base_url}/authorize",
|
||||
token_endpoint=f"{base_url}/token",
|
||||
# Keep Clerk's JWKS for token validation
|
||||
jwks_uri=f"https://{clerk_domain}/.well-known/jwks.json",
|
||||
issuer=base_url, # We're the issuer for MCP tokens
|
||||
scopes=["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"]
|
||||
)
|
||||
|
||||
logger.info(f"Created Clerk OAuth config with adapter endpoints")
|
||||
logger.info(f"Clerk domain: {clerk_domain}")
|
||||
logger.debug(f"Authorization endpoint: {config.authorization_endpoint}")
|
||||
logger.debug(f"Token endpoint: {config.token_endpoint}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_jwt_secret() -> str:
|
||||
"""Get JWT secret for token signing"""
|
||||
jwt_secret = os.getenv("JWT_SECRET_KEY")
|
||||
|
||||
if not jwt_secret:
|
||||
raise ValueError("JWT_SECRET_KEY environment variable is required")
|
||||
|
||||
return jwt_secret
|
||||
|
||||
|
||||
def create_mcp_server_config():
|
||||
"""Create complete MCP server configuration for Clerk integration"""
|
||||
|
||||
try:
|
||||
oauth_config = create_clerk_oauth_config()
|
||||
jwt_secret = get_jwt_secret()
|
||||
|
||||
return {
|
||||
"oauth_config": oauth_config,
|
||||
"jwt_secret": jwt_secret,
|
||||
"base_url": os.getenv("BASE_URL", "https://yargi-mcp.fly.dev"),
|
||||
"auth_enabled": os.getenv("ENABLE_AUTH", "true").lower() == "true"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create MCP server config: {e}")
|
||||
raise
|
||||
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
MCP server middleware for OAuth authentication and authorization
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
FASTMCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
FASTMCP_AVAILABLE = False
|
||||
FastMCP = None
|
||||
logger.warning("FastMCP not available, some features will be disabled")
|
||||
|
||||
from .oauth import OAuthProvider
|
||||
from .policy import PolicyEngine
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
"""Authentication context passed to MCP tools"""
|
||||
|
||||
user_id: str
|
||||
scopes: list[str]
|
||||
claims: dict[str, Any]
|
||||
token: str
|
||||
|
||||
|
||||
class MCPAuthMiddleware:
|
||||
"""Authentication middleware for MCP servers"""
|
||||
|
||||
def __init__(self, oauth_provider: OAuthProvider, policy_engine: PolicyEngine):
|
||||
self.oauth_provider = oauth_provider
|
||||
self.policy_engine = policy_engine
|
||||
|
||||
def authenticate_request(self, authorization_header: str) -> AuthContext | None:
|
||||
"""Extract and validate auth token from request"""
|
||||
|
||||
if not authorization_header:
|
||||
logger.debug("No authorization header provided")
|
||||
return None
|
||||
|
||||
if not authorization_header.startswith("Bearer "):
|
||||
logger.debug("Authorization header does not start with 'Bearer '")
|
||||
return None
|
||||
|
||||
token = authorization_header[7:] # Remove 'Bearer ' prefix
|
||||
|
||||
token_info = self.oauth_provider.introspect_token(token)
|
||||
|
||||
if not token_info.get("active"):
|
||||
logger.warning("Token is not active")
|
||||
return None
|
||||
|
||||
logger.debug(f"Authenticated user: {token_info.get('sub', 'unknown')}")
|
||||
|
||||
return AuthContext(
|
||||
user_id=token_info.get("sub", "unknown"),
|
||||
scopes=token_info.get("mcp_tool_scopes", []),
|
||||
claims=token_info,
|
||||
token=token,
|
||||
)
|
||||
|
||||
def authorize_tool_call(
|
||||
self, tool_name: str, auth_context: AuthContext
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Check if user can call the specified tool"""
|
||||
|
||||
return self.policy_engine.authorize_tool_call(
|
||||
tool_name=tool_name,
|
||||
user_scopes=auth_context.scopes,
|
||||
user_claims=auth_context.claims,
|
||||
)
|
||||
|
||||
|
||||
def auth_required(
|
||||
oauth_provider: OAuthProvider,
|
||||
policy_engine: PolicyEngine,
|
||||
tool_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
Decorator to require authentication for MCP tool functions
|
||||
|
||||
Usage:
|
||||
@auth_required(oauth_provider, policy_engine, "search_yargitay")
|
||||
def my_tool_function(context: AuthContext, ...):
|
||||
pass
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract authorization header from kwargs
|
||||
auth_header = kwargs.pop("authorization", None)
|
||||
|
||||
# Also check in args if it's a Request object
|
||||
if not auth_header and args:
|
||||
for arg in args:
|
||||
if hasattr(arg, 'headers'):
|
||||
auth_header = arg.headers.get("Authorization")
|
||||
break
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(f"No authorization header for tool '{tool_name or func.__name__}'")
|
||||
raise PermissionError("Authorization header required")
|
||||
|
||||
auth_context = middleware.authenticate_request(auth_header)
|
||||
|
||||
if not auth_context:
|
||||
logger.warning(f"Authentication failed for tool '{tool_name or func.__name__}'")
|
||||
raise PermissionError("Invalid or expired token")
|
||||
|
||||
actual_tool_name = tool_name or func.__name__
|
||||
|
||||
authorized, reason = middleware.authorize_tool_call(
|
||||
actual_tool_name, auth_context
|
||||
)
|
||||
|
||||
if not authorized:
|
||||
logger.warning(f"Authorization failed for tool '{actual_tool_name}': {reason}")
|
||||
raise PermissionError(f"Access denied: {reason}")
|
||||
|
||||
# Add auth context to function call
|
||||
return await func(auth_context, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class FastMCPAuthWrapper:
|
||||
"""Wrapper for FastMCP servers to add authentication"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_server: "FastMCP",
|
||||
oauth_provider: OAuthProvider,
|
||||
policy_engine: PolicyEngine,
|
||||
):
|
||||
if not FASTMCP_AVAILABLE:
|
||||
raise ImportError("FastMCP is required for FastMCPAuthWrapper")
|
||||
|
||||
self.mcp_server = mcp_server
|
||||
self.middleware = MCPAuthMiddleware(oauth_provider, policy_engine)
|
||||
self.oauth_provider = oauth_provider
|
||||
logger.info("Initializing FastMCP authentication wrapper")
|
||||
self._wrap_tools()
|
||||
|
||||
def _wrap_tools(self):
|
||||
"""Wrap all existing tools with auth middleware"""
|
||||
|
||||
# Try different FastMCP tool storage locations
|
||||
tool_registry = None
|
||||
|
||||
if hasattr(self.mcp_server, '_tools'):
|
||||
tool_registry = self.mcp_server._tools
|
||||
elif hasattr(self.mcp_server, 'tools'):
|
||||
tool_registry = self.mcp_server.tools
|
||||
elif hasattr(self.mcp_server, '_tool_registry'):
|
||||
tool_registry = self.mcp_server._tool_registry
|
||||
elif hasattr(self.mcp_server, '_handlers') and hasattr(self.mcp_server._handlers, 'tools'):
|
||||
tool_registry = self.mcp_server._handlers.tools
|
||||
|
||||
if not tool_registry:
|
||||
logger.warning("FastMCP server tool registry not found, tools will not be automatically wrapped")
|
||||
logger.debug(f"Available server attributes: {dir(self.mcp_server)}")
|
||||
return
|
||||
|
||||
logger.debug(f"Found tool registry with {len(tool_registry)} tools")
|
||||
original_tools = dict(tool_registry)
|
||||
wrapped_count = 0
|
||||
|
||||
for tool_name, tool_func in original_tools.items():
|
||||
try:
|
||||
wrapped_func = self._create_auth_wrapper(tool_name, tool_func)
|
||||
tool_registry[tool_name] = wrapped_func
|
||||
wrapped_count += 1
|
||||
logger.debug(f"Wrapped tool: {tool_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to wrap tool {tool_name}: {e}")
|
||||
|
||||
logger.info(f"Successfully wrapped {wrapped_count} tools with authentication")
|
||||
|
||||
def _create_auth_wrapper(self, tool_name: str, original_func: Callable) -> Callable:
|
||||
"""Create auth wrapper for a specific tool"""
|
||||
|
||||
@functools.wraps(original_func)
|
||||
async def auth_wrapper(*args, **kwargs):
|
||||
# Extract authorization from various sources
|
||||
auth_header = None
|
||||
|
||||
# Check kwargs first
|
||||
auth_header = kwargs.pop("authorization", None)
|
||||
|
||||
# Check if first argument is a Request object
|
||||
if not auth_header and args:
|
||||
first_arg = args[0]
|
||||
if hasattr(first_arg, 'headers'):
|
||||
auth_header = first_arg.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(f"No authorization header for tool '{tool_name}'")
|
||||
raise PermissionError("Authorization required")
|
||||
|
||||
auth_context = self.middleware.authenticate_request(auth_header)
|
||||
|
||||
if not auth_context:
|
||||
logger.warning(f"Authentication failed for tool '{tool_name}'")
|
||||
raise PermissionError("Invalid token")
|
||||
|
||||
authorized, reason = self.middleware.authorize_tool_call(
|
||||
tool_name, auth_context
|
||||
)
|
||||
|
||||
if not authorized:
|
||||
logger.warning(f"Authorization failed for tool '{tool_name}': {reason}")
|
||||
raise PermissionError(f"Access denied: {reason}")
|
||||
|
||||
# Add auth context to kwargs
|
||||
kwargs["auth_context"] = auth_context
|
||||
logger.debug(f"Calling tool '{tool_name}' for user {auth_context.user_id}")
|
||||
|
||||
return await original_func(*args, **kwargs)
|
||||
|
||||
return auth_wrapper
|
||||
|
||||
def add_oauth_endpoints(self):
|
||||
"""Add OAuth endpoints to the MCP server"""
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Initiate OAuth 2.1 authorization flow with PKCE",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_authorize(redirect_uri: str, scopes: Optional[str] = None):
|
||||
"""OAuth authorization endpoint"""
|
||||
scope_list = scopes.split(" ") if scopes else None
|
||||
auth_url, pkce = self.oauth_provider.generate_authorization_url(
|
||||
redirect_uri=redirect_uri, scopes=scope_list
|
||||
)
|
||||
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
|
||||
return {
|
||||
"authorization_url": auth_url,
|
||||
"code_verifier": pkce.verifier, # For PKCE flow
|
||||
"code_challenge": pkce.challenge,
|
||||
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
|
||||
}
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Exchange OAuth authorization code for access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_token(
|
||||
code: str,
|
||||
state: str,
|
||||
redirect_uri: str
|
||||
):
|
||||
"""OAuth token exchange endpoint"""
|
||||
try:
|
||||
result = await self.oauth_provider.exchange_code_for_token(
|
||||
code=code, state=state, redirect_uri=redirect_uri
|
||||
)
|
||||
logger.info("Successfully exchanged authorization code for token")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
raise
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Validate and introspect OAuth access token",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||
)
|
||||
async def oauth_introspect(token: str):
|
||||
"""Token introspection endpoint"""
|
||||
result = self.oauth_provider.introspect_token(token)
|
||||
logger.debug(f"Token introspection: active={result.get('active', False)}")
|
||||
return result
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Revoke OAuth access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_revoke(token: str):
|
||||
"""Token revocation endpoint"""
|
||||
success = self.oauth_provider.revoke_token(token)
|
||||
logger.info(f"Token revocation: success={success}")
|
||||
return {"revoked": success}
|
||||
|
||||
@self.mcp_server.tool(
|
||||
description="Get list of tools available to authenticated user",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||
)
|
||||
async def oauth_user_tools(authorization: str):
|
||||
"""Get user's allowed tools based on scopes"""
|
||||
auth_context = self.middleware.authenticate_request(authorization)
|
||||
if not auth_context:
|
||||
raise PermissionError("Invalid token")
|
||||
|
||||
allowed_patterns = self.middleware.policy_engine.get_allowed_tools(auth_context.scopes)
|
||||
|
||||
return {
|
||||
"user_id": auth_context.user_id,
|
||||
"scopes": auth_context.scopes,
|
||||
"allowed_tool_patterns": allowed_patterns,
|
||||
"message": "Use these patterns to determine which tools you can access"
|
||||
}
|
||||
|
||||
logger.info("Added OAuth endpoints: oauth_authorize, oauth_token, oauth_introspect, oauth_revoke, oauth_user_tools")
|
||||
@@ -1,304 +0,0 @@
|
||||
"""
|
||||
OAuth 2.1 + PKCE implementation for MCP servers with Clerk integration
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError, InvalidTokenError
|
||||
|
||||
from .storage import PersistentStorage
|
||||
|
||||
# Try to import Clerk SDK
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_AVAILABLE = True
|
||||
except ImportError:
|
||||
CLERK_AVAILABLE = False
|
||||
Clerk = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthConfig:
|
||||
"""OAuth provider configuration for Clerk"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
authorization_endpoint: str
|
||||
token_endpoint: str
|
||||
jwks_uri: str | None = None
|
||||
issuer: str = "mcp-auth"
|
||||
scopes: list[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.scopes is None:
|
||||
self.scopes = ["mcp:tools:read", "mcp:tools:write"]
|
||||
|
||||
|
||||
class PKCEChallenge:
|
||||
"""PKCE challenge/verifier pair for OAuth 2.1"""
|
||||
|
||||
def __init__(self):
|
||||
self.verifier = (
|
||||
base64.urlsafe_b64encode(secrets.token_bytes(32))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
challenge_bytes = hashlib.sha256(self.verifier.encode("utf-8")).digest()
|
||||
self.challenge = (
|
||||
base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=")
|
||||
)
|
||||
|
||||
|
||||
class OAuthProvider:
|
||||
"""OAuth 2.1 provider with PKCE support and Clerk integration"""
|
||||
|
||||
def __init__(self, config: OAuthConfig, jwt_secret: str):
|
||||
self.config = config
|
||||
self.jwt_secret = jwt_secret
|
||||
# Use persistent storage instead of memory
|
||||
self.storage = PersistentStorage()
|
||||
|
||||
# Initialize Clerk SDK if available
|
||||
self.clerk = None
|
||||
if CLERK_AVAILABLE and config.client_secret:
|
||||
try:
|
||||
self.clerk = Clerk(bearer_auth=config.client_secret)
|
||||
logger.info("Clerk SDK initialized successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize Clerk SDK: {e}")
|
||||
|
||||
logger.info("OAuth provider initialized with persistent storage")
|
||||
|
||||
def generate_authorization_url(
|
||||
self,
|
||||
redirect_uri: str,
|
||||
state: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> tuple[str, PKCEChallenge]:
|
||||
"""Generate OAuth authorization URL with PKCE for Clerk"""
|
||||
|
||||
pkce = PKCEChallenge()
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
|
||||
if state is None:
|
||||
state = secrets.token_urlsafe(16)
|
||||
|
||||
if scopes is None:
|
||||
scopes = self.config.scopes
|
||||
|
||||
# Store session data with expiration
|
||||
session_data = {
|
||||
"pkce_verifier": pkce.verifier,
|
||||
"state": state,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scopes": scopes,
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
}
|
||||
self.storage.set_session(session_id, session_data)
|
||||
|
||||
# Build Clerk OAuth URL
|
||||
# Check if this is a custom domain (sign-in endpoint)
|
||||
if self.config.authorization_endpoint.endswith('/sign-in'):
|
||||
# For custom domains, Clerk expects redirect_url parameter
|
||||
params = {
|
||||
"redirect_url": redirect_uri,
|
||||
"state": f"{state}:{session_id}",
|
||||
}
|
||||
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
|
||||
else:
|
||||
# Standard OAuth flow with PKCE
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.config.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": f"{state}:{session_id}", # Combine state with session ID
|
||||
"code_challenge": pkce.challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
auth_url = f"{self.config.authorization_endpoint}?{urlencode(params)}"
|
||||
|
||||
logger.info(f"Generated OAuth URL with session {session_id[:8]}...")
|
||||
logger.debug(f"Auth URL: {auth_url}")
|
||||
return auth_url, pkce
|
||||
|
||||
async def exchange_code_for_token(
|
||||
self, code: str, state: str, redirect_uri: str
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange authorization code for access token with Clerk"""
|
||||
|
||||
try:
|
||||
original_state, session_id = state.split(":", 1)
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid state format: {state}")
|
||||
raise ValueError("Invalid state format") from e
|
||||
|
||||
session = self.storage.get_session(session_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found")
|
||||
raise ValueError("Invalid session")
|
||||
|
||||
# Check session expiration
|
||||
if datetime.utcnow().timestamp() > session.get("expires_at", 0):
|
||||
self.storage.delete_session(session_id)
|
||||
logger.error(f"Session {session_id} expired")
|
||||
raise ValueError("Session expired")
|
||||
|
||||
if session["state"] != original_state:
|
||||
logger.error(f"State mismatch: expected {session['state']}, got {original_state}")
|
||||
raise ValueError("State mismatch")
|
||||
|
||||
if session["redirect_uri"] != redirect_uri:
|
||||
logger.error(f"Redirect URI mismatch: expected {session['redirect_uri']}, got {redirect_uri}")
|
||||
raise ValueError("Redirect URI mismatch")
|
||||
|
||||
# Prepare token exchange request for Clerk
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.config.client_id,
|
||||
"client_secret": self.config.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": session["pkce_verifier"],
|
||||
}
|
||||
|
||||
logger.info(f"Exchanging code with Clerk for session {session_id[:8]}...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.config.token_endpoint,
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Clerk token exchange failed: {response.status_code} - {response.text}")
|
||||
raise ValueError(f"Token exchange failed: {response.text}")
|
||||
|
||||
token_response = response.json()
|
||||
logger.info("Successfully exchanged code for Clerk token")
|
||||
|
||||
# Create MCP-scoped JWT token
|
||||
access_token = self._create_mcp_token(
|
||||
session["scopes"], token_response.get("access_token"), session_id
|
||||
)
|
||||
|
||||
# Store token for introspection
|
||||
token_id = secrets.token_urlsafe(16)
|
||||
token_data = {
|
||||
"access_token": access_token,
|
||||
"scopes": session["scopes"],
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(hours=1)).timestamp(),
|
||||
"session_id": session_id,
|
||||
"clerk_token": token_response.get("access_token"),
|
||||
}
|
||||
self.storage.set_token(token_id, token_data)
|
||||
|
||||
# Clean up session
|
||||
self.storage.delete_session(session_id)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": " ".join(session["scopes"]),
|
||||
}
|
||||
|
||||
def validate_pkce(self, code_verifier: str, code_challenge: str) -> bool:
|
||||
"""Validate PKCE code challenge (RFC 7636)"""
|
||||
# S256 method
|
||||
verifier_hash = hashlib.sha256(code_verifier.encode()).digest()
|
||||
expected_challenge = base64.urlsafe_b64encode(verifier_hash).decode().rstrip('=')
|
||||
return expected_challenge == code_challenge
|
||||
|
||||
def _create_mcp_token(
|
||||
self, scopes: list[str], upstream_token: str, session_id: str
|
||||
) -> str:
|
||||
"""Create MCP-scoped JWT token with Clerk token embedded"""
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"iss": self.config.issuer,
|
||||
"sub": session_id,
|
||||
"aud": "mcp-server",
|
||||
"iat": now,
|
||||
"exp": now + 3600, # 1 hour expiration
|
||||
"mcp_tool_scopes": scopes,
|
||||
"upstream_token": upstream_token,
|
||||
"clerk_integration": True,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, self.jwt_secret, algorithm="HS256")
|
||||
|
||||
def introspect_token(self, token: str) -> dict[str, Any]:
|
||||
"""Introspect and validate MCP token"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
|
||||
# Check if token is expired
|
||||
if payload.get("exp", 0) < time.time():
|
||||
return {"active": False, "error": "token_expired"}
|
||||
|
||||
return {
|
||||
"active": True,
|
||||
"sub": payload.get("sub"),
|
||||
"aud": payload.get("aud"),
|
||||
"iss": payload.get("iss"),
|
||||
"exp": payload.get("exp"),
|
||||
"iat": payload.get("iat"),
|
||||
"mcp_tool_scopes": payload.get("mcp_tool_scopes", []),
|
||||
"upstream_token": payload.get("upstream_token"),
|
||||
"clerk_integration": payload.get("clerk_integration", False),
|
||||
}
|
||||
|
||||
except PyJWTError as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
return {"active": False, "error": "invalid_token"}
|
||||
|
||||
def revoke_token(self, token: str) -> bool:
|
||||
"""Revoke a token"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
session_id = payload.get("sub")
|
||||
|
||||
# Remove all tokens associated with this session
|
||||
all_tokens = self.storage.get_tokens()
|
||||
tokens_to_remove = [
|
||||
token_id
|
||||
for token_id, token_data in all_tokens.items()
|
||||
if token_data.get("session_id") == session_id
|
||||
]
|
||||
|
||||
for token_id in tokens_to_remove:
|
||||
self.storage.delete_token(token_id)
|
||||
|
||||
logger.info(f"Revoked {len(tokens_to_remove)} tokens for session {session_id}")
|
||||
return True
|
||||
|
||||
except InvalidTokenError as e:
|
||||
logger.warning(f"Token revocation failed: {e}")
|
||||
return False
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Clean up expired sessions and tokens"""
|
||||
# This is now handled automatically by persistent storage
|
||||
self.storage.cleanup_expired_sessions()
|
||||
logger.debug("Cleanup completed via persistent storage")
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
Authorization policy engine for MCP tools
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PolicyAction(Enum):
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPolicy:
|
||||
"""Policy rule for MCP tool access"""
|
||||
|
||||
tool_pattern: str # regex pattern for tool names
|
||||
required_scopes: list[str]
|
||||
action: PolicyAction = PolicyAction.ALLOW
|
||||
conditions: dict[str, Any] | None = None
|
||||
|
||||
def matches_tool(self, tool_name: str) -> bool:
|
||||
"""Check if the policy applies to given tool"""
|
||||
return bool(re.match(self.tool_pattern, tool_name))
|
||||
|
||||
def evaluate_scopes(self, user_scopes: list[str]) -> bool:
|
||||
"""Check if user has required scopes"""
|
||||
return all(scope in user_scopes for scope in self.required_scopes)
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Authorization policy engine for Turkish legal database tools"""
|
||||
|
||||
def __init__(self):
|
||||
self.policies: list[ToolPolicy] = []
|
||||
self.default_action = PolicyAction.DENY
|
||||
|
||||
def add_policy(self, policy: ToolPolicy):
|
||||
"""Add a policy rule"""
|
||||
self.policies.append(policy)
|
||||
logger.debug(f"Added policy: {policy.tool_pattern} -> {policy.required_scopes}")
|
||||
|
||||
def add_tool_scope_policy(
|
||||
self,
|
||||
tool_pattern: str,
|
||||
required_scopes: str | list[str],
|
||||
action: PolicyAction = PolicyAction.ALLOW,
|
||||
):
|
||||
"""Convenience method to add tool-scope policy"""
|
||||
if isinstance(required_scopes, str):
|
||||
required_scopes = [required_scopes]
|
||||
|
||||
policy = ToolPolicy(
|
||||
tool_pattern=tool_pattern, required_scopes=required_scopes, action=action
|
||||
)
|
||||
self.add_policy(policy)
|
||||
|
||||
def authorize_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
user_scopes: list[str],
|
||||
user_claims: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Authorize a tool call
|
||||
|
||||
Returns:
|
||||
(authorized: bool, reason: Optional[str])
|
||||
"""
|
||||
|
||||
logger.debug(f"Authorizing tool '{tool_name}' for user with scopes: {user_scopes}")
|
||||
|
||||
matching_policies = [
|
||||
policy for policy in self.policies if policy.matches_tool(tool_name)
|
||||
]
|
||||
|
||||
if not matching_policies:
|
||||
if self.default_action == PolicyAction.ALLOW:
|
||||
logger.debug(f"No policies found for '{tool_name}', allowing by default")
|
||||
return True, None
|
||||
else:
|
||||
logger.warning(f"No policies found for '{tool_name}', denying by default")
|
||||
return False, f"No policy found for tool '{tool_name}', default deny"
|
||||
|
||||
# Check for explicit deny policies first
|
||||
for policy in matching_policies:
|
||||
if policy.action == PolicyAction.DENY:
|
||||
if policy.evaluate_scopes(user_scopes):
|
||||
logger.warning(f"Explicit deny policy matched for '{tool_name}'")
|
||||
return False, f"Explicit deny policy for tool '{tool_name}'"
|
||||
|
||||
# Check allow policies
|
||||
allow_policies = [
|
||||
p for p in matching_policies if p.action == PolicyAction.ALLOW
|
||||
]
|
||||
|
||||
if not allow_policies:
|
||||
logger.warning(f"No allow policies found for '{tool_name}'")
|
||||
return False, f"No allow policies found for tool '{tool_name}'"
|
||||
|
||||
for policy in allow_policies:
|
||||
if policy.evaluate_scopes(user_scopes):
|
||||
if self._evaluate_conditions(policy.conditions, user_claims):
|
||||
logger.debug(f"Authorization granted for '{tool_name}'")
|
||||
return True, None
|
||||
|
||||
logger.warning(f"Insufficient scopes for '{tool_name}'. Required: {[p.required_scopes for p in allow_policies]}, User has: {user_scopes}")
|
||||
return False, f"Insufficient scopes for tool '{tool_name}'"
|
||||
|
||||
def _evaluate_conditions(
|
||||
self,
|
||||
conditions: dict[str, Any] | None,
|
||||
user_claims: dict[str, Any] | None,
|
||||
) -> bool:
|
||||
"""Evaluate additional policy conditions"""
|
||||
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
if not user_claims:
|
||||
logger.debug("No user claims provided, conditions evaluation failed")
|
||||
return False
|
||||
|
||||
for key, expected_value in conditions.items():
|
||||
user_value = user_claims.get(key)
|
||||
|
||||
if isinstance(expected_value, list):
|
||||
if user_value not in expected_value:
|
||||
logger.debug(f"Condition failed: {key} = {user_value} not in {expected_value}")
|
||||
return False
|
||||
elif user_value != expected_value:
|
||||
logger.debug(f"Condition failed: {key} = {user_value} != {expected_value}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_allowed_tools(self, user_scopes: list[str]) -> list[str]:
|
||||
"""Get list of tool patterns user is allowed to call"""
|
||||
|
||||
allowed_tools = []
|
||||
|
||||
for policy in self.policies:
|
||||
if policy.action == PolicyAction.ALLOW and policy.evaluate_scopes(
|
||||
user_scopes
|
||||
):
|
||||
allowed_tools.append(policy.tool_pattern)
|
||||
|
||||
return allowed_tools
|
||||
|
||||
|
||||
def create_turkish_legal_policies() -> PolicyEngine:
|
||||
"""Create policy set for Turkish legal database MCP server"""
|
||||
|
||||
engine = PolicyEngine()
|
||||
|
||||
# Administrative tools (full access)
|
||||
engine.add_tool_scope_policy(".*", ["mcp:tools:admin"])
|
||||
|
||||
# Search tools - require read access
|
||||
engine.add_tool_scope_policy("search.*", ["mcp:tools:read"])
|
||||
|
||||
# Fetch/get document tools - require read access
|
||||
engine.add_tool_scope_policy("get_.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("fetch.*", ["mcp:tools:read"])
|
||||
|
||||
# Specific Turkish legal database tools
|
||||
engine.add_tool_scope_policy("search_yargitay.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_danistay.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_anayasa.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_rekabet.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_kik.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_emsal.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_uyusmazlik.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_sayistay.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_.*_bedesten", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_yerel_hukuk.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_istinaf_hukuk.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("search_kyb.*", ["mcp:tools:read"])
|
||||
|
||||
# Document retrieval tools
|
||||
engine.add_tool_scope_policy("get_.*_document.*", ["mcp:tools:read"])
|
||||
engine.add_tool_scope_policy("get_.*_markdown", ["mcp:tools:read"])
|
||||
|
||||
# Write operations (if any future tools need them)
|
||||
engine.add_tool_scope_policy("create_.*", ["mcp:tools:write"])
|
||||
engine.add_tool_scope_policy("update_.*", ["mcp:tools:write"])
|
||||
engine.add_tool_scope_policy("delete_.*", ["mcp:tools:write"])
|
||||
|
||||
logger.info("Created Turkish legal database policy engine")
|
||||
return engine
|
||||
|
||||
|
||||
def create_default_policies() -> PolicyEngine:
|
||||
"""Create a default policy set for MCP servers (backwards compatibility)"""
|
||||
return create_turkish_legal_policies()
|
||||
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
Persistent storage for OAuth sessions and tokens
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersistentStorage:
|
||||
"""File-based persistent storage for OAuth data"""
|
||||
|
||||
def __init__(self, storage_dir: str = None):
|
||||
if storage_dir is None:
|
||||
# Use system temp directory or environment variable
|
||||
storage_dir = os.environ.get('TEMP', tempfile.gettempdir())
|
||||
|
||||
self.storage_dir = os.path.join(storage_dir, 'mcp_oauth_storage')
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
|
||||
self.sessions_file = os.path.join(self.storage_dir, 'oauth_sessions.json')
|
||||
self.tokens_file = os.path.join(self.storage_dir, 'oauth_tokens.json')
|
||||
|
||||
logger.info(f"Persistent OAuth storage initialized at: {self.storage_dir}")
|
||||
|
||||
def _load_json(self, filepath: str) -> Dict:
|
||||
"""Load JSON data from file"""
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading {filepath}: {e}")
|
||||
return {}
|
||||
|
||||
def _save_json(self, filepath: str, data: Dict):
|
||||
"""Save JSON data to file"""
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving {filepath}: {e}")
|
||||
|
||||
def get_sessions(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all OAuth sessions"""
|
||||
data = self._load_json(self.sessions_file)
|
||||
# Clean expired sessions
|
||||
now = datetime.utcnow().timestamp()
|
||||
valid_sessions = {k: v for k, v in data.items()
|
||||
if v.get('expires_at', 0) > now}
|
||||
if len(valid_sessions) != len(data):
|
||||
self._save_json(self.sessions_file, valid_sessions)
|
||||
return valid_sessions
|
||||
|
||||
def set_session(self, session_id: str, data: Dict[str, Any]):
|
||||
"""Set OAuth session data"""
|
||||
sessions = self.get_sessions()
|
||||
sessions[session_id] = data
|
||||
self._save_json(self.sessions_file, sessions)
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific OAuth session data"""
|
||||
sessions = self.get_sessions()
|
||||
return sessions.get(session_id)
|
||||
|
||||
def delete_session(self, session_id: str):
|
||||
"""Delete OAuth session"""
|
||||
sessions = self.get_sessions()
|
||||
if session_id in sessions:
|
||||
del sessions[session_id]
|
||||
self._save_json(self.sessions_file, sessions)
|
||||
|
||||
def get_tokens(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all OAuth tokens"""
|
||||
data = self._load_json(self.tokens_file)
|
||||
# Clean expired tokens
|
||||
now = datetime.utcnow().timestamp()
|
||||
valid_tokens = {k: v for k, v in data.items()
|
||||
if v.get('expires_at', 0) > now}
|
||||
if len(valid_tokens) != len(data):
|
||||
self._save_json(self.tokens_file, valid_tokens)
|
||||
return valid_tokens
|
||||
|
||||
def set_token(self, token_id: str, token_data: Dict[str, Any]):
|
||||
"""Set OAuth token data"""
|
||||
tokens = self.get_tokens()
|
||||
tokens[token_id] = token_data
|
||||
self._save_json(self.tokens_file, tokens)
|
||||
|
||||
def get_token(self, token_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific OAuth token data"""
|
||||
tokens = self.get_tokens()
|
||||
return tokens.get(token_id)
|
||||
|
||||
def delete_token(self, token_id: str):
|
||||
"""Delete OAuth token"""
|
||||
tokens = self.get_tokens()
|
||||
if token_id in tokens:
|
||||
del tokens[token_id]
|
||||
self._save_json(self.tokens_file, tokens)
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Clean up expired sessions and tokens"""
|
||||
# This is handled automatically in get_sessions() and get_tokens()
|
||||
sessions = self.get_sessions()
|
||||
tokens = self.get_tokens()
|
||||
logger.debug(f"Cleanup: {len(sessions)} active sessions, {len(tokens)} active tokens")
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Factory for creating FastMCP app with MCP Auth Toolkit integration
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
FASTMCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
FASTMCP_AVAILABLE = False
|
||||
FastMCP = None
|
||||
|
||||
from mcp_auth import (
|
||||
OAuthProvider,
|
||||
PolicyEngine,
|
||||
FastMCPAuthWrapper,
|
||||
create_default_policies
|
||||
)
|
||||
from mcp_auth.clerk_config import create_mcp_server_config
|
||||
|
||||
|
||||
def create_auth_enabled_app(app_name: str = "Yargı MCP Server") -> FastMCP:
|
||||
"""Create FastMCP app with authentication enabled"""
|
||||
|
||||
if not FASTMCP_AVAILABLE:
|
||||
raise ImportError("FastMCP is required for authenticated MCP server")
|
||||
|
||||
logger.info("Creating FastMCP app with MCP Auth Toolkit integration")
|
||||
|
||||
# Create base FastMCP app
|
||||
app = FastMCP(app_name)
|
||||
|
||||
# Check if authentication is enabled
|
||||
auth_enabled = os.getenv("ENABLE_AUTH", "true").lower() == "true"
|
||||
|
||||
if not auth_enabled:
|
||||
logger.info("Authentication disabled, returning basic FastMCP app")
|
||||
return app
|
||||
|
||||
try:
|
||||
# Get configuration
|
||||
logger.info("Getting MCP server configuration...")
|
||||
config = create_mcp_server_config()
|
||||
logger.info("Configuration loaded successfully")
|
||||
|
||||
# Create OAuth provider with Clerk config
|
||||
logger.info("Creating OAuth provider...")
|
||||
oauth_provider = OAuthProvider(
|
||||
config=config["oauth_config"],
|
||||
jwt_secret=config["jwt_secret"]
|
||||
)
|
||||
logger.info("OAuth provider created successfully")
|
||||
|
||||
# Create policy engine for Turkish legal database
|
||||
policy_engine = create_default_policies()
|
||||
|
||||
# Store auth components for later wrapping (after tools are defined)
|
||||
app._oauth_provider = oauth_provider
|
||||
app._policy_engine = policy_engine
|
||||
app._auth_config = config
|
||||
|
||||
# Add OAuth endpoints immediately
|
||||
@app.tool(
|
||||
description="Initiate OAuth 2.1 authorization flow with PKCE",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_authorize(redirect_uri: str, scopes: str = None):
|
||||
"""OAuth authorization endpoint"""
|
||||
scope_list = scopes.split(" ") if scopes else ["mcp:tools:read", "mcp:tools:write"]
|
||||
auth_url, pkce = oauth_provider.generate_authorization_url(
|
||||
redirect_uri=redirect_uri, scopes=scope_list
|
||||
)
|
||||
logger.info(f"Generated authorization URL for redirect_uri: {redirect_uri}")
|
||||
return {
|
||||
"authorization_url": auth_url,
|
||||
"code_verifier": pkce.verifier,
|
||||
"code_challenge": pkce.challenge,
|
||||
"instructions": "Use the authorization_url to complete OAuth flow, then exchange the returned code using oauth_token tool"
|
||||
}
|
||||
|
||||
@app.tool(
|
||||
description="Exchange OAuth authorization code for access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_token(code: str, state: str, redirect_uri: str):
|
||||
"""OAuth token exchange endpoint"""
|
||||
try:
|
||||
result = await oauth_provider.exchange_code_for_token(
|
||||
code=code, state=state, redirect_uri=redirect_uri
|
||||
)
|
||||
logger.info("Successfully exchanged authorization code for token")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
raise
|
||||
|
||||
@app.tool(
|
||||
description="Validate and introspect OAuth access token",
|
||||
annotations={"readOnlyHint": True, "idempotentHint": True}
|
||||
)
|
||||
async def oauth_introspect(token: str):
|
||||
"""Token introspection endpoint"""
|
||||
result = oauth_provider.introspect_token(token)
|
||||
logger.debug(f"Token introspection: active={result.get('active', False)}")
|
||||
return result
|
||||
|
||||
@app.tool(
|
||||
description="Revoke OAuth access token",
|
||||
annotations={"readOnlyHint": False, "idempotentHint": False}
|
||||
)
|
||||
async def oauth_revoke(token: str):
|
||||
"""Token revocation endpoint"""
|
||||
success = oauth_provider.revoke_token(token)
|
||||
logger.info(f"Token revocation: success={success}")
|
||||
return {"revoked": success}
|
||||
|
||||
logger.info("Successfully created authenticated FastMCP app")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create authenticated app: {e}")
|
||||
logger.info("Falling back to non-authenticated FastMCP app")
|
||||
# Return basic app if auth setup fails
|
||||
return app
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_app() -> FastMCP:
|
||||
"""Create FastMCP app (backwards compatible with mcp_factory.py)"""
|
||||
return create_auth_enabled_app()
|
||||
|
||||
|
||||
def get_auth_wrapper(app: FastMCP) -> Optional[FastMCPAuthWrapper]:
|
||||
"""Get auth wrapper from app if available"""
|
||||
return getattr(app, '_auth_wrapper', None)
|
||||
|
||||
|
||||
def get_oauth_provider(app: FastMCP) -> Optional[OAuthProvider]:
|
||||
"""Get OAuth provider from app if available"""
|
||||
return getattr(app, '_oauth_provider', None)
|
||||
|
||||
|
||||
def get_policy_engine(app: FastMCP) -> Optional[PolicyEngine]:
|
||||
"""Get policy engine from app if available"""
|
||||
return getattr(app, '_policy_engine', None)
|
||||
|
||||
|
||||
def is_auth_enabled(app: FastMCP) -> bool:
|
||||
"""Check if authentication is enabled for the app"""
|
||||
return hasattr(app, '_oauth_provider') or hasattr(app, '_auth_wrapper')
|
||||
|
||||
|
||||
def enable_tool_authentication(app: FastMCP):
|
||||
"""Enable authentication on all existing tools (call after tools are defined)"""
|
||||
if not is_auth_enabled(app):
|
||||
logger.debug("Authentication not enabled, skipping tool authentication")
|
||||
return
|
||||
|
||||
oauth_provider = get_oauth_provider(app)
|
||||
policy_engine = get_policy_engine(app)
|
||||
|
||||
if not oauth_provider or not policy_engine:
|
||||
logger.warning("OAuth provider or policy engine not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Create auth wrapper and wrap tools
|
||||
auth_wrapper = FastMCPAuthWrapper(
|
||||
mcp_server=app,
|
||||
oauth_provider=oauth_provider,
|
||||
policy_engine=policy_engine
|
||||
)
|
||||
|
||||
# Store wrapper for reference
|
||||
app._auth_wrapper = auth_wrapper
|
||||
|
||||
logger.info("Tool authentication enabled successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enable tool authentication: {e}")
|
||||
|
||||
|
||||
def cleanup_auth_sessions(app: FastMCP):
|
||||
"""Clean up expired auth sessions and tokens"""
|
||||
oauth_provider = get_oauth_provider(app)
|
||||
if oauth_provider:
|
||||
oauth_provider.cleanup_expired_sessions()
|
||||
logger.debug("Cleaned up expired OAuth sessions")
|
||||
@@ -1,383 +0,0 @@
|
||||
"""
|
||||
HTTP adapter for MCP Auth Toolkit OAuth endpoints
|
||||
Exposes MCP OAuth tools as HTTP endpoints for Claude.ai integration
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, quote
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
|
||||
# Try to import Clerk SDK
|
||||
try:
|
||||
from clerk_backend_api import Clerk
|
||||
CLERK_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
CLERK_AVAILABLE = False
|
||||
Clerk = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# OAuth configuration
|
||||
BASE_URL = os.getenv("BASE_URL", "https://yargimcp.com")
|
||||
|
||||
|
||||
@router.get("/.well-known/oauth-authorization-server")
|
||||
async def get_oauth_metadata():
|
||||
"""OAuth 2.0 Authorization Server Metadata (RFC 8414)"""
|
||||
return JSONResponse({
|
||||
"issuer": BASE_URL,
|
||||
"authorization_endpoint": f"{BASE_URL}/authorize",
|
||||
"token_endpoint": f"{BASE_URL}/token",
|
||||
"registration_endpoint": f"{BASE_URL}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"scopes_supported": ["mcp:tools:read", "mcp:tools:write", "openid", "profile", "email"],
|
||||
"service_documentation": f"{BASE_URL}/mcp/"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/.well-known/oauth-protected-resource")
|
||||
async def get_protected_resource_metadata():
|
||||
"""OAuth Protected Resource Metadata (RFC 9728)"""
|
||||
return JSONResponse({
|
||||
"resource": BASE_URL,
|
||||
"authorization_servers": [BASE_URL],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"scopes_supported": ["mcp:tools:read", "mcp:tools:write"],
|
||||
"resource_documentation": f"{BASE_URL}/docs"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/authorize")
|
||||
async def authorize_endpoint(
|
||||
response_type: str = Query(...),
|
||||
client_id: str = Query(...),
|
||||
redirect_uri: str = Query(...),
|
||||
code_challenge: str = Query(...),
|
||||
code_challenge_method: str = Query("S256"),
|
||||
state: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None)
|
||||
):
|
||||
"""OAuth 2.1 Authorization Endpoint - Uses Clerk SDK for custom domains"""
|
||||
|
||||
logger.info(f"OAuth authorize request - client_id: {client_id}, redirect_uri: {redirect_uri}")
|
||||
|
||||
if not CLERK_AVAILABLE:
|
||||
logger.error("Clerk SDK not available")
|
||||
raise HTTPException(status_code=500, detail="Clerk SDK not available")
|
||||
|
||||
# Store OAuth session for later validation
|
||||
try:
|
||||
from mcp_server_main import app as mcp_app
|
||||
from mcp_auth_factory import get_oauth_provider
|
||||
|
||||
oauth_provider = get_oauth_provider(mcp_app)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(status_code=500, detail="OAuth provider not configured")
|
||||
|
||||
# Generate session and store PKCE
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
if state is None:
|
||||
state = secrets.token_urlsafe(16)
|
||||
|
||||
# Create PKCE challenge
|
||||
from mcp_auth.oauth import PKCEChallenge
|
||||
pkce = PKCEChallenge()
|
||||
|
||||
# Store session data
|
||||
session_data = {
|
||||
"pkce_verifier": pkce.verifier,
|
||||
"pkce_challenge": code_challenge, # Store the client's challenge
|
||||
"state": state,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"scopes": scope.split(" ") if scope else ["mcp:tools:read", "mcp:tools:write"],
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=10)).timestamp(),
|
||||
}
|
||||
oauth_provider.storage.set_session(session_id, session_data)
|
||||
|
||||
# For Clerk with custom domains, we need to use their hosted sign-in page
|
||||
# We'll pass our callback URL and session info in the state
|
||||
callback_url = f"{BASE_URL}/auth/callback"
|
||||
|
||||
# Encode session info in state for retrieval after Clerk auth
|
||||
combined_state = f"{state}:{session_id}"
|
||||
|
||||
# Use Clerk's sign-in URL with proper parameters
|
||||
clerk_domain = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
|
||||
sign_in_params = {
|
||||
"redirect_url": f"{callback_url}?state={quote(combined_state)}",
|
||||
}
|
||||
|
||||
sign_in_url = f"https://{clerk_domain}/sign-in?{urlencode(sign_in_params)}"
|
||||
|
||||
logger.info(f"Redirecting to Clerk sign-in: {sign_in_url}")
|
||||
|
||||
return RedirectResponse(url=sign_in_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Authorization failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/auth/callback")
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
state: Optional[str] = Query(None),
|
||||
clerk_token: Optional[str] = Query(None)
|
||||
):
|
||||
"""Handle OAuth callback from Clerk - supports both JWT token and cookie auth"""
|
||||
|
||||
logger.info(f"OAuth callback received - state: {state}")
|
||||
logger.info(f"Query params: {dict(request.query_params)}")
|
||||
logger.info(f"Cookies: {dict(request.cookies)}")
|
||||
logger.info(f"Clerk JWT token provided: {bool(clerk_token)}")
|
||||
|
||||
# Support both JWT token (for cross-domain) and cookie auth (for subdomain)
|
||||
|
||||
try:
|
||||
if not state:
|
||||
logger.error("No state parameter provided")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Missing state parameter"}
|
||||
)
|
||||
|
||||
# Parse state to get original state and session ID
|
||||
try:
|
||||
if ":" in state:
|
||||
original_state, session_id = state.rsplit(":", 1)
|
||||
else:
|
||||
original_state = state
|
||||
session_id = state # Fallback
|
||||
except ValueError:
|
||||
logger.error(f"Invalid state format: {state}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Invalid state format"}
|
||||
)
|
||||
|
||||
# Get OAuth provider
|
||||
from mcp_server_main import app as mcp_app
|
||||
from mcp_auth_factory import get_oauth_provider
|
||||
|
||||
oauth_provider = get_oauth_provider(mcp_app)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(status_code=500, detail="OAuth provider not configured")
|
||||
|
||||
# Get stored session
|
||||
oauth_session = oauth_provider.storage.get_session(session_id)
|
||||
|
||||
if not oauth_session:
|
||||
logger.error(f"OAuth session not found for ID: {session_id}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "OAuth session expired or not found"}
|
||||
)
|
||||
|
||||
# Check if we have a JWT token (for cross-domain auth)
|
||||
user_authenticated = False
|
||||
auth_method = "none"
|
||||
|
||||
if clerk_token:
|
||||
logger.info("Attempting JWT token validation")
|
||||
try:
|
||||
# Validate JWT token with Clerk
|
||||
from clerk_backend_api import Clerk
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
|
||||
# Extract session_id from JWT token and verify with Clerk
|
||||
import jwt
|
||||
decoded_token = jwt.decode(clerk_token, options={"verify_signature": False})
|
||||
session_id = decoded_token.get("sid") or decoded_token.get("session_id")
|
||||
|
||||
if session_id:
|
||||
# Verify with Clerk using session_id
|
||||
session = clerk.sessions.verify(session_id=session_id, token=clerk_token)
|
||||
user_id = session.user_id if session else None
|
||||
else:
|
||||
user_id = None
|
||||
|
||||
if user_id:
|
||||
logger.info(f"JWT token validation successful - user_id: {user_id}")
|
||||
user_authenticated = True
|
||||
auth_method = "jwt_token"
|
||||
# Store user info in session for token exchange
|
||||
oauth_session["user_id"] = user_id
|
||||
oauth_session["auth_method"] = "jwt_token"
|
||||
else:
|
||||
logger.error("JWT token validation failed - no user_id in claims")
|
||||
except Exception as e:
|
||||
logger.error(f"JWT token validation failed: {str(e)}")
|
||||
# Fall through to cookie validation
|
||||
|
||||
# If no JWT token or validation failed, check cookies
|
||||
if not user_authenticated:
|
||||
logger.info("Checking for Clerk session cookies")
|
||||
# Check for Clerk session cookies (for subdomain auth)
|
||||
clerk_session_cookie = request.cookies.get("__session")
|
||||
if clerk_session_cookie:
|
||||
logger.info("Found Clerk session cookie, assuming authenticated")
|
||||
user_authenticated = True
|
||||
auth_method = "cookie"
|
||||
oauth_session["auth_method"] = "cookie"
|
||||
else:
|
||||
logger.info("No Clerk session cookie found")
|
||||
|
||||
# For custom domains, we'll also trust that Clerk redirected here
|
||||
if not user_authenticated:
|
||||
logger.info("Trusting Clerk redirect for custom domain flow")
|
||||
user_authenticated = True
|
||||
auth_method = "trusted_redirect"
|
||||
oauth_session["auth_method"] = "trusted_redirect"
|
||||
|
||||
logger.info(f"User authenticated: {user_authenticated}, method: {auth_method}")
|
||||
|
||||
# Generate simple authorization code for custom domain flow
|
||||
auth_code = f"clerk_custom_{session_id}_{int(time.time())}"
|
||||
|
||||
# Store the code mapping for token exchange
|
||||
code_data = {
|
||||
"session_id": session_id,
|
||||
"clerk_authenticated": user_authenticated,
|
||||
"auth_method": auth_method,
|
||||
"custom_domain_flow": True,
|
||||
"created_at": time.time(),
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=5)).timestamp(),
|
||||
}
|
||||
if "user_id" in oauth_session:
|
||||
code_data["user_id"] = oauth_session["user_id"]
|
||||
|
||||
oauth_provider.storage.set_session(f"code_{auth_code}", code_data)
|
||||
|
||||
# Build redirect URL back to Claude
|
||||
redirect_params = {
|
||||
"code": auth_code,
|
||||
"state": original_state
|
||||
}
|
||||
|
||||
redirect_url = f"{oauth_session['redirect_uri']}?{urlencode(redirect_params)}"
|
||||
logger.info(f"Redirecting back to Claude: {redirect_url}")
|
||||
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Callback processing failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_client(request: Request):
|
||||
"""Dynamic Client Registration (RFC 7591)"""
|
||||
|
||||
data = await request.json()
|
||||
logger.info(f"Client registration request: {data}")
|
||||
|
||||
# Simple dynamic registration - accept any client
|
||||
client_id = f"mcp-client-{os.urandom(8).hex()}"
|
||||
|
||||
return JSONResponse({
|
||||
"client_id": client_id,
|
||||
"client_secret": None, # Public client
|
||||
"redirect_uris": data.get("redirect_uris", []),
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"client_name": data.get("client_name", "MCP Client"),
|
||||
"token_endpoint_auth_method": "none",
|
||||
"client_id_issued_at": int(datetime.now().timestamp())
|
||||
})
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def token_endpoint(request: Request):
|
||||
"""OAuth 2.1 Token Endpoint"""
|
||||
|
||||
# Parse form data
|
||||
form_data = await request.form()
|
||||
grant_type = form_data.get("grant_type")
|
||||
code = form_data.get("code")
|
||||
redirect_uri = form_data.get("redirect_uri")
|
||||
client_id = form_data.get("client_id")
|
||||
code_verifier = form_data.get("code_verifier")
|
||||
|
||||
logger.info(f"Token exchange - grant_type: {grant_type}, code: {code[:20] if code else 'None'}...")
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "unsupported_grant_type"}
|
||||
)
|
||||
|
||||
try:
|
||||
# OAuth token exchange - validate code and return Clerk JWT
|
||||
# This supports proper OAuth flow while using Clerk JWT tokens
|
||||
|
||||
if not code or not redirect_uri:
|
||||
logger.error("Missing required parameters: code or redirect_uri")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
|
||||
)
|
||||
|
||||
# Validate OAuth code with Clerk
|
||||
if CLERK_AVAILABLE:
|
||||
try:
|
||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
||||
|
||||
# In a real implementation, you'd validate the code with Clerk
|
||||
# For now, we'll assume the code is valid if it looks like a Clerk code
|
||||
if len(code) > 10: # Basic validation
|
||||
# Create a mock session with the code
|
||||
# In practice, this would be validated with Clerk's OAuth flow
|
||||
|
||||
# Return Clerk JWT token format
|
||||
# This should be the actual Clerk JWT token from the OAuth flow
|
||||
return JSONResponse({
|
||||
"access_token": f"mock_clerk_jwt_{code}",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "yargi.read yargi.search"
|
||||
})
|
||||
else:
|
||||
logger.error(f"Invalid code format: {code}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Clerk validation failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "invalid_grant", "error_description": "Authorization code validation failed"}
|
||||
)
|
||||
else:
|
||||
logger.warning("Clerk SDK not available, using mock response")
|
||||
return JSONResponse({
|
||||
"access_token": "mock_jwt_token_for_development",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "yargi.read yargi.search"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Token exchange failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "server_error", "error_description": str(e)}
|
||||
)
|
||||
@@ -1,522 +0,0 @@
|
||||
"""
|
||||
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)}
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user