Remove auth and Fly.io deployment
This commit is contained in:
@@ -0,0 +1,278 @@
|
|||||||
|
# Constitutional Court (Anayasa Mahkemesi) Implementation - Architecture Analysis
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Anayasa Mahkemesi module provides comprehensive access to Turkish Constitutional Court decisions through two separate systems:
|
||||||
|
1. **Norm Denetimi** (Norm Control) - Judicial review of laws
|
||||||
|
2. **Bireysel Başvuru** (Individual Applications) - Individual constitutional complaints
|
||||||
|
|
||||||
|
Both systems have been **unified** into a single MCP interface (Phase 6 optimization - 361 tokens saved).
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
### 1. Module Structure
|
||||||
|
```
|
||||||
|
anayasa_mcp_module/
|
||||||
|
├── __init__.py # Empty
|
||||||
|
├── models.py # Pydantic data models (230 lines)
|
||||||
|
├── client.py # Norm Denetimi client (356 lines)
|
||||||
|
├── bireysel_client.py # Bireysel Başvuru client (355 lines)
|
||||||
|
└── unified_client.py # Unified routing logic (122 lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API Endpoints
|
||||||
|
|
||||||
|
**Norm Denetimi API:**
|
||||||
|
- Base: `https://normkararlarbilgibankasi.anayasa.gov.tr`
|
||||||
|
- Search: GET `/Ara` (with query parameters)
|
||||||
|
- Document: Dynamic URLs from search results
|
||||||
|
|
||||||
|
**Bireysel Başvuru API:**
|
||||||
|
- Base: `https://kararlarbilgibankasi.anayasa.gov.tr`
|
||||||
|
- Search: GET `/Ara?KararBulteni=1` (with query parameters for report-style results)
|
||||||
|
- Document: Dynamic paths like `/BB/YYYY/NNNN`
|
||||||
|
|
||||||
|
### 3. Current Search Implementation (Keyword-Based)
|
||||||
|
|
||||||
|
**Norm Denetimi Search Parameters (19 parameters):**
|
||||||
|
- Keyword logic: `keywords_all[]`, `keywords_any[]`, `keywords_exclude[]` (AND/OR/NOT)
|
||||||
|
- Identifiers: case_number_esas, decision_number_karar
|
||||||
|
- Dates: first_review_date_start/end, decision_date_start/end, official_gazette_date_start/end
|
||||||
|
- Structural filters: period, application_type, rapporteur_name, norm_type, review_outcomes, reason_for_final_outcome
|
||||||
|
- Boolean filters: has_press_release, has_dissenting_opinion, has_different_reasoning
|
||||||
|
- Other: basis_constitution_article_numbers, attending_members_names
|
||||||
|
- Pagination: results_per_page (1-10), page_to_fetch, sort_by_criteria
|
||||||
|
|
||||||
|
**Bireysel Başvuru Search Parameters (simple):**
|
||||||
|
- keywords[] (AND logic only)
|
||||||
|
- page_to_fetch for pagination
|
||||||
|
|
||||||
|
**Search Architecture (client.py):**
|
||||||
|
- `_build_search_query_params_for_aym()`: Converts Pydantic model to URL query parameters (tuples list)
|
||||||
|
- `search_norm_denetimi_decisions()`: Makes HTTP GET request with params, parses HTML response
|
||||||
|
- Uses BeautifulSoup to find:
|
||||||
|
- Decision count: div.bulunankararsayisi (regex: "(\d+)\s*Karar Bulundu")
|
||||||
|
- Individual decisions: div.birkarar (contains reference number, metadata, keyword count)
|
||||||
|
- Decision details: Next sibling div.col-sm-12 with table containing norm information
|
||||||
|
- Returns AnayasaSearchResult with parsed decisions list
|
||||||
|
|
||||||
|
### 4. Document Retrieval (Full Text Conversion)
|
||||||
|
|
||||||
|
**HTML to Markdown Conversion Process:**
|
||||||
|
1. Fetch document from URL
|
||||||
|
2. Parse HTML with BeautifulSoup
|
||||||
|
3. Extract main content:
|
||||||
|
- Find div#Karar (decision tab) or fallback to div.KararMetni or div.WordSection1
|
||||||
|
- Remove: scripts, styles, .item.col-sm-12 divs, .modal.fade divs
|
||||||
|
4. Convert to Markdown using MarkItDown with BytesIO stream (no temp files)
|
||||||
|
5. Extract metadata during fetch:
|
||||||
|
- Esas No./Karar No.: Find bold text in <p> tags containing "Esas No.:" and "Karar No.:"
|
||||||
|
- Karar Tarihi: Find bold text containing "Karar tarihi:" or regex "Karar Tarihi\s*:\s*([\d\.]+)"
|
||||||
|
- Resmi Gazete: Find text containing "Resmî Gazete tarih ve sayısı:" or "Resmi Gazete tarih/sayı:"
|
||||||
|
|
||||||
|
**Pagination & Chunking:**
|
||||||
|
- Split markdown into 5,000 character chunks
|
||||||
|
- Calculate: total_pages = ceil(len(markdown) / 5000)
|
||||||
|
- Return current_page_clamped (max 1, min total_pages)
|
||||||
|
- Include pagination metadata: current_page, total_pages, is_paginated flag
|
||||||
|
|
||||||
|
### 5. Data Models (models.py - 230 lines)
|
||||||
|
|
||||||
|
**Norm Denetimi Models:**
|
||||||
|
- `AnayasaNormDenetimiSearchRequest`: 19 search parameters
|
||||||
|
- `AnayasaReviewedNormInfo`: norm_name_or_number, article_number, review_type_and_outcome, outcome_reason, basis_constitution_articles_cited[], postponement_period
|
||||||
|
- `AnayasaDecisionSummary`: decision_reference_no, decision_page_url, keywords_found_count, application_type_summary, applicant_summary, decision_outcome_summary, decision_date_summary, reviewed_norms[]
|
||||||
|
- `AnayasaSearchResult`: decisions[], total_records_found, retrieved_page_number
|
||||||
|
- `AnayasaDocumentMarkdown`: source_url, decision_reference_no_from_page, decision_date_from_page, official_gazette_info_from_page, markdown_chunk, current_page, total_pages, is_paginated
|
||||||
|
|
||||||
|
**Bireysel Başvuru Models:**
|
||||||
|
- `AnayasaBireyselReportSearchRequest`: keywords[], page_to_fetch
|
||||||
|
- `AnayasaBireyselReportDecisionDetail`: hak, mudahale_iddiası, sonuç, giderim (4 fields per right examined)
|
||||||
|
- `AnayasaBireyselReportDecisionSummary`: title, decision_reference_no, decision_page_url, decision_type_summary, decision_making_body, application_date_summary, decision_date_summary, application_subject_summary, details[]
|
||||||
|
- `AnayasaBireyselReportSearchResult`: decisions[], total_records_found, retrieved_page_number
|
||||||
|
- `AnayasaBireyselBasvuruDocumentMarkdown`: source_url, basvuru_no_from_page, karar_tarihi_from_page, basvuru_tarihi_from_page, karari_veren_birim_from_page, karar_turu_from_page, resmi_gazete_info_from_page, markdown_chunk, current_page, total_pages, is_paginated
|
||||||
|
|
||||||
|
**Unified Models:**
|
||||||
|
- `AnayasaUnifiedSearchRequest`: decision_type (norm_denetimi|bireysel_basvuru), keywords[], page_to_fetch, results_per_page, + type-specific parameters
|
||||||
|
- `AnayasaUnifiedSearchResult`: decision_type, decisions[] (Dict[str, Any]), total_records_found, retrieved_page_number
|
||||||
|
- `AnayasaUnifiedDocumentMarkdown`: decision_type, source_url, document_data (Dict), markdown_chunk, current_page, total_pages, is_paginated
|
||||||
|
|
||||||
|
### 6. Unified Client Routing (unified_client.py - 122 lines)
|
||||||
|
|
||||||
|
**AnayasaUnifiedClient class:**
|
||||||
|
- Maintains instances of both norm_client and bireysel_client
|
||||||
|
- `search_unified()`: Routes based on decision_type parameter
|
||||||
|
- norm_denetimi: Converts to AnayasaNormDenetimiSearchRequest, calls norm_client.search_norm_denetimi_decisions()
|
||||||
|
- bireysel_basvuru: Converts to AnayasaBireyselReportSearchRequest, calls bireysel_client.search_bireysel_basvuru_report()
|
||||||
|
- Returns unified AnayasaUnifiedSearchResult
|
||||||
|
- `get_document_unified()`: Auto-detects decision type from URL
|
||||||
|
- Checks for "normkararlarbilgibankasi" in netloc or "/ND/" in path → norm_denetimi
|
||||||
|
- Checks for "kararlarbilgibankasi" in netloc or "/BB/" in path → bireysel_basvuru
|
||||||
|
- Calls appropriate client, wraps result in unified model
|
||||||
|
|
||||||
|
### 7. MCP Tool Integration (mcp_server_main.py)
|
||||||
|
|
||||||
|
**Active Tools (2 tools - Phase 6 optimization):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.tool(
|
||||||
|
description="Search Constitutional Court decisions from either Norm Control or Individual Applications",
|
||||||
|
annotations={"readOnlyHint": True, "openWorldHint": True, "idempotentHint": True}
|
||||||
|
)
|
||||||
|
async def search_anayasa_unified(
|
||||||
|
decision_type: Literal["norm_denetimi", "bireysel_basvuru"],
|
||||||
|
keywords: List[str],
|
||||||
|
page_to_fetch: int (1-100),
|
||||||
|
# Norm Denetimi specific (ignored for bireysel_basvuru)
|
||||||
|
keywords_all: List[str],
|
||||||
|
keywords_any: List[str],
|
||||||
|
decision_type_norm: Literal["ALL", "1", "2", "3"],
|
||||||
|
application_date_start: str,
|
||||||
|
application_date_end: str,
|
||||||
|
# Bireysel Başvuru specific (ignored for norm_denetimi)
|
||||||
|
decision_start_date: str,
|
||||||
|
decision_end_date: str,
|
||||||
|
norm_type: Literal["ALL", "1", "2", ...],
|
||||||
|
subject_category: str
|
||||||
|
) -> str (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.tool(
|
||||||
|
description="Retrieve full text of Constitutional Court decision. Auto-detects decision type from URL",
|
||||||
|
annotations={"readOnlyHint": True, "openWorldHint": False, "idempotentHint": True}
|
||||||
|
)
|
||||||
|
async def get_anayasa_document_unified(
|
||||||
|
document_url: str,
|
||||||
|
page_number: int (1-indexed)
|
||||||
|
) -> str (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deactivated Tools (4 tools - Phase 6 optimization, marked with DEACTIVATED):**
|
||||||
|
- search_anayasa_norm_denetimi_decisions
|
||||||
|
- get_anayasa_norm_denetimi_document_markdown
|
||||||
|
- search_anayasa_bireysel_basvuru_report
|
||||||
|
- get_anayasa_bireysel_basvuru_document_markdown
|
||||||
|
|
||||||
|
## Search Capabilities Analysis
|
||||||
|
|
||||||
|
### Current Keyword-Based Search Strengths
|
||||||
|
|
||||||
|
**Norm Denetimi - Rich Structural Filtering:**
|
||||||
|
1. Multi-keyword logic with AND/OR/NOT operators
|
||||||
|
2. Case/decision number search (exact matching)
|
||||||
|
3. Date range filtering (review, decision, gazette dates)
|
||||||
|
4. Norm categorization (14 norm types)
|
||||||
|
5. Application type filtering (3 categories)
|
||||||
|
6. Constitutional period selection (1961 vs 1982 constitutions)
|
||||||
|
7. Decision outcome filtering (8 outcome types)
|
||||||
|
8. Reasoning/grounds filtering (30 different grounds)
|
||||||
|
9. Member/rapporteur filtering
|
||||||
|
10. Constitutional articles cited filtering
|
||||||
|
|
||||||
|
**Bireysel Başvuru - Report Format:**
|
||||||
|
1. Simple keyword search
|
||||||
|
2. Rights/claims detailed in structured table format
|
||||||
|
3. Remedy/solution tracking
|
||||||
|
|
||||||
|
### Limitations of Current Keyword Search
|
||||||
|
|
||||||
|
1. **No semantic understanding**: Different words for same concept ("mülkiyet hakkı" vs "property rights")
|
||||||
|
2. **No concept hierarchy**: Can't find related legal principles
|
||||||
|
3. **No cross-language**: Turkish-only, no English queries
|
||||||
|
4. **No abbreviation matching**: "HADD" vs "Hukuk Alanında Değerli Dosya Denetimi"
|
||||||
|
5. **No synonym support**: Formal vs informal terminology
|
||||||
|
6. **No semantic similarity**: Can't find similar cases with different terminology
|
||||||
|
7. **No legal concept graph**: Can't traverse related principles or doctrines
|
||||||
|
8. **No fuzzy matching**: Typos or spelling variations fail completely
|
||||||
|
9. **No legal reasoning search**: Can't query by legal arguments or doctrinal approaches
|
||||||
|
10. **No cross-system semantic linking**: Norm Denetimi and Bireysel Başvuru not semantically linked
|
||||||
|
11. **Order dependency**: Query order may affect results
|
||||||
|
12. **No ranking by relevance**: Just keyword presence/absence
|
||||||
|
13. **No query expansion**: No automatic synonym/related term expansion
|
||||||
|
|
||||||
|
### HTML Document Structure
|
||||||
|
|
||||||
|
**Norm Denetimi Search Results HTML:**
|
||||||
|
```
|
||||||
|
div.birkarar (repeated for each decision)
|
||||||
|
├── div.bkararbaslik (header with E./K. numbers)
|
||||||
|
│ └── div.BulunanKelimeSayisi (keyword count)
|
||||||
|
└── div.kararbilgileri (metadata with | separators: application_type|applicant|outcome|date)
|
||||||
|
|
||||||
|
Next sibling:
|
||||||
|
div.col-sm-12
|
||||||
|
└── table.table > tbody > tr (one row per reviewed norm with 6 columns)
|
||||||
|
├── td: norm name/number
|
||||||
|
├── td: article number
|
||||||
|
├── td: review type and outcome
|
||||||
|
├── td: outcome reason
|
||||||
|
├── td: constitutional articles cited (comma-separated)
|
||||||
|
└── td: postponement period
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Decision Content (both types):**
|
||||||
|
```
|
||||||
|
div#Karar (decision tab)
|
||||||
|
└── div.KararMetni or div.WordSection1
|
||||||
|
└── HTML content in MS Word format (many nested divs with styles)
|
||||||
|
|
||||||
|
Metadata extracted from:
|
||||||
|
<p><b>Esas No.:</b> [number]</p>
|
||||||
|
<p><b>Karar No.:</b> [number]</p>
|
||||||
|
<p><b>Karar tarihi:</b> [date]</p>
|
||||||
|
<p>Resmî Gazete tarih ve sayısı: [info]</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Document Content Characteristics
|
||||||
|
|
||||||
|
- **Language**: Turkish legal language (specialized terminology)
|
||||||
|
- **Format**: Microsoft Word-generated HTML (nested divs, complex styles)
|
||||||
|
- **Content types**:
|
||||||
|
- Norm Denetimi: Constitutional principle analysis, legal reasoning, comparison with challenged norm
|
||||||
|
- Bireysel Başvuru: Right violated, remedy granted, procedural requirements
|
||||||
|
- **Typical length**: 5,000-50,000+ characters
|
||||||
|
- **Citations**: Internal cross-references to constitutional articles
|
||||||
|
- **Structure**: Formal legal document with sections, subsections, reasoning
|
||||||
|
|
||||||
|
## Key Technical Insights for Semantic Search
|
||||||
|
|
||||||
|
### Content Encoding
|
||||||
|
- Currently: HTML → BeautifulSoup parsing → MarkItDown → Markdown
|
||||||
|
- Extraction: Specific div/class/id selectors
|
||||||
|
- Metadata: Regex patterns and text parsing
|
||||||
|
|
||||||
|
### Search Query Flow
|
||||||
|
1. User provides keywords/filters
|
||||||
|
2. Convert Pydantic model to URL query parameters
|
||||||
|
3. HTTP GET request to Constitutional Court API
|
||||||
|
4. HTML response parsed with BeautifulSoup
|
||||||
|
5. Decision summaries extracted and validated
|
||||||
|
6. Results returned as JSON
|
||||||
|
|
||||||
|
### Document Retrieval Flow
|
||||||
|
1. Get document URL from search results
|
||||||
|
2. HTTP GET request to URL
|
||||||
|
3. Parse HTML for metadata extraction
|
||||||
|
4. MarkItDown converts HTML to Markdown
|
||||||
|
5. Chunk by 5,000 characters
|
||||||
|
6. Return paginated Markdown with metadata
|
||||||
|
|
||||||
|
## Performance Baseline
|
||||||
|
|
||||||
|
- **Search**: ~1-5 seconds (HTML parsing + regex extraction)
|
||||||
|
- **Document**: ~2-10 seconds (fetch + parse + MarkItDown + chunking)
|
||||||
|
- **Memory**: Minimal (5,000 char chunks, no full document in memory)
|
||||||
|
- **API Response Size**: Typically 50-500 KB HTML for search, 100-1000 KB for full decision
|
||||||
|
|
||||||
|
## Next Steps for Semantic Search Integration
|
||||||
|
|
||||||
|
1. **Vector Embeddings**: Embed decisions using Turkish legal model
|
||||||
|
2. **Concept Extraction**: Identify and tag legal concepts (rights, procedures, principles)
|
||||||
|
3. **Semantic Queries**: Convert natural language questions to embeddings
|
||||||
|
4. **Hybrid Search**: Combine keyword + semantic similarity
|
||||||
|
5. **Legal Ontology**: Map Turkish Constitutional Court concepts and relationships
|
||||||
|
6. **Cross-system Linking**: Semantically link Norm Denetimi and Bireysel Başvuru decisions
|
||||||
|
7. **Precedent Graph**: Extract citations and create legal precedent relationships
|
||||||
|
8. **Fine-tuned Embeddings**: Train embeddings specifically on Turkish Constitutional law
|
||||||
|
9. **Ranking**: Re-rank results by semantic relevance to user's legal intent
|
||||||
|
10. **Explanation**: Provide semantic reasoning for why result is relevant
|
||||||
+56
-1
@@ -79,6 +79,61 @@ excluded_tools: []
|
|||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
# (contrary to the memories, which are loaded on demand).
|
# (contrary to the memories, which are loaded on demand).
|
||||||
initial_prompt: ""
|
initial_prompt: ""
|
||||||
|
# the name by which the project can be referenced within Serena
|
||||||
project_name: "yargi-mcp"
|
project_name: "yargi-mcp"
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||||
included_optional_tools: []
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||||
|
# If null or missing, the value from the global config is used.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||||
|
# No documentation on options means no options are available.
|
||||||
|
ls_specific_settings: {}
|
||||||
|
|||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
# -------- BASE IMAGE ---------------------------------------------------------
|
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
# -------- Runtime setup ----------------------------------------------------
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy dependency manifests first for layer-cache
|
|
||||||
COPY pyproject.toml poetry.lock* requirements*.txt* ./
|
|
||||||
|
|
||||||
# Fast, deterministic install with `uv`
|
|
||||||
RUN pip install --no-cache-dir uv && \
|
|
||||||
uv pip install --system --no-cache-dir . && \
|
|
||||||
uv pip install --system --no-cache-dir .[asgi,saas]
|
|
||||||
|
|
||||||
# Cache buster - force rebuild
|
|
||||||
ARG CACHE_BUST=202510061202
|
|
||||||
RUN echo "Cache bust: $CACHE_BUST"
|
|
||||||
|
|
||||||
# Copy application source
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# -------- Environment ------------------------------------------------------
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV ENABLE_AUTH=true
|
|
||||||
ENV PORT=8000
|
|
||||||
|
|
||||||
# -------- Health check -----------------------------------------------------
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
|
||||||
CMD python -c "import httpx, os, sys; r=httpx.get(f'http://localhost:{os.getenv(\"PORT\",\"8000\")}/health'); sys.exit(0 if r.status_code==200 else 1)"
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# -------- Entrypoint -------------------------------------------------------
|
|
||||||
CMD ["uvicorn", "asgi_app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
|
||||||
+22
-476
@@ -2,112 +2,32 @@
|
|||||||
ASGI application for Yargı MCP Server
|
ASGI application for Yargı MCP Server
|
||||||
|
|
||||||
This module provides ASGI/HTTP access to the Yargı MCP server,
|
This module provides ASGI/HTTP access to the Yargı MCP server,
|
||||||
allowing it to be deployed as a web service with FastAPI wrapper
|
allowing it to be deployed as a web service with FastAPI wrapper.
|
||||||
for OAuth integration and proper middleware support.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta
|
import logging
|
||||||
from fastapi import FastAPI, Request, HTTPException, Query
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse, HTMLResponse, Response
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.exception_handlers import http_exception_handler
|
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
|
|
||||||
# Import the proper create_app function that includes all middleware
|
|
||||||
from mcp_server_main import create_app
|
from mcp_server_main import create_app
|
||||||
|
|
||||||
# Conditional auth-related imports (only if auth enabled)
|
|
||||||
_auth_check = os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
|
||||||
|
|
||||||
if _auth_check:
|
|
||||||
mcp_auth_router = None
|
|
||||||
|
|
||||||
# Import Stripe webhook router
|
|
||||||
try:
|
|
||||||
from stripe_webhook import router as stripe_router
|
|
||||||
except ImportError:
|
|
||||||
stripe_router = None
|
|
||||||
else:
|
|
||||||
mcp_auth_router = None
|
|
||||||
stripe_router = None
|
|
||||||
|
|
||||||
# OAuth configuration from environment variables
|
|
||||||
CLERK_ISSUER = os.getenv("CLERK_ISSUER", "https://clerk.yargimcp.com")
|
|
||||||
BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com")
|
|
||||||
CLERK_SECRET_KEY = os.getenv("CLERK_SECRET_KEY")
|
|
||||||
CLERK_PUBLISHABLE_KEY = os.getenv("CLERK_PUBLISHABLE_KEY")
|
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Configure CORS and Auth middleware
|
# Configure CORS
|
||||||
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
||||||
|
|
||||||
# Configure Bearer token authentication based on ENABLE_AUTH
|
# Create MCP app
|
||||||
auth_enabled = os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
mcp_server = create_app()
|
||||||
bearer_auth = None
|
|
||||||
|
|
||||||
# Only import and configure auth if enabled
|
# Create MCP Starlette sub-application
|
||||||
if auth_enabled:
|
|
||||||
# Import FastMCP JWT Verifier (handles both old and new FastMCP versions)
|
|
||||||
try:
|
|
||||||
# FastMCP 2.12+ uses JWTVerifier
|
|
||||||
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
|
|
||||||
AuthProviderClass = JWTVerifier
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
# Older FastMCP versions used BearerAuthProvider
|
|
||||||
from fastmcp.server.auth import BearerAuthProvider
|
|
||||||
from fastmcp.server.auth.providers.bearer import RSAKeyPair
|
|
||||||
AuthProviderClass = BearerAuthProvider
|
|
||||||
except ImportError:
|
|
||||||
logger.error("No compatible auth provider found in FastMCP")
|
|
||||||
AuthProviderClass = None
|
|
||||||
RSAKeyPair = None
|
|
||||||
|
|
||||||
# Import Clerk SDK at module level for performance
|
|
||||||
try:
|
|
||||||
from clerk_backend_api import Clerk
|
|
||||||
CLERK_SDK_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
CLERK_SDK_AVAILABLE = False
|
|
||||||
logger.warning("Clerk SDK not available - falling back to development mode")
|
|
||||||
|
|
||||||
if AuthProviderClass:
|
|
||||||
if CLERK_SECRET_KEY and CLERK_ISSUER:
|
|
||||||
# Production: Use Clerk JWKS endpoint for token validation
|
|
||||||
bearer_auth = AuthProviderClass(
|
|
||||||
jwks_uri=f"{CLERK_ISSUER}/.well-known/jwks.json",
|
|
||||||
issuer=None,
|
|
||||||
algorithm="RS256",
|
|
||||||
audience=None,
|
|
||||||
required_scopes=[]
|
|
||||||
)
|
|
||||||
elif RSAKeyPair:
|
|
||||||
# Development: Generate RSA key pair for testing
|
|
||||||
dev_key_pair = RSAKeyPair.generate()
|
|
||||||
bearer_auth = AuthProviderClass(
|
|
||||||
public_key=dev_key_pair.public_key,
|
|
||||||
issuer="https://dev.yargimcp.com",
|
|
||||||
audience="dev-mcp-server",
|
|
||||||
required_scopes=["yargi.read"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
CLERK_SDK_AVAILABLE = False
|
|
||||||
logger.info("Authentication disabled (ENABLE_AUTH=false)")
|
|
||||||
|
|
||||||
# Create MCP app with Bearer authentication (None if auth disabled)
|
|
||||||
mcp_server = create_app(auth=bearer_auth)
|
|
||||||
|
|
||||||
# Create MCP Starlette sub-application with root path - mount will add /mcp prefix
|
|
||||||
mcp_app = mcp_server.http_app(path="/")
|
mcp_app = mcp_server.http_app(path="/")
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +38,7 @@ class UTF8JSONResponse(JSONResponse):
|
|||||||
headers = {}
|
headers = {}
|
||||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
super().__init__(content, status_code, headers, **kwargs)
|
super().__init__(content, status_code, headers, **kwargs)
|
||||||
|
|
||||||
def render(self, content) -> bytes:
|
def render(self, content) -> bytes:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
content,
|
content,
|
||||||
@@ -133,43 +53,22 @@ custom_middleware = [
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=cors_origins,
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
allow_headers=["Content-Type", "Authorization", "X-Request-ID", "X-Session-ID"],
|
allow_headers=["Content-Type", "X-Request-ID", "X-Session-ID"],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create FastAPI wrapper application
|
# Create FastAPI wrapper application
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Yargı MCP Server",
|
title="Yargı MCP Server",
|
||||||
description="MCP server for Turkish legal databases with OAuth authentication",
|
description="MCP server for Turkish legal databases",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
middleware=custom_middleware,
|
middleware=custom_middleware,
|
||||||
default_response_class=UTF8JSONResponse, # Use UTF-8 JSON encoder
|
default_response_class=UTF8JSONResponse,
|
||||||
redirect_slashes=False # Disable to prevent 307 redirects on /mcp endpoint
|
redirect_slashes=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add auth-related routers to FastAPI (only if available)
|
|
||||||
if stripe_router:
|
|
||||||
app.include_router(stripe_router, prefix="/api/stripe")
|
|
||||||
|
|
||||||
# Custom 401 exception handler for MCP spec compliance
|
|
||||||
@app.exception_handler(401)
|
|
||||||
async def custom_401_handler(request: Request, exc: HTTPException):
|
|
||||||
"""Custom 401 handler that adds WWW-Authenticate header as required by MCP spec"""
|
|
||||||
response = await http_exception_handler(request, exc)
|
|
||||||
|
|
||||||
# Add WWW-Authenticate header pointing to protected resource metadata
|
|
||||||
# as required by RFC 9728 Section 5.1 and MCP Authorization spec
|
|
||||||
response.headers["WWW-Authenticate"] = (
|
|
||||||
'Bearer '
|
|
||||||
'error="invalid_token", '
|
|
||||||
'error_description="The access token is missing or invalid", '
|
|
||||||
f'resource="{BASE_URL}/.well-known/oauth-protected-resource"'
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
# FastAPI health check endpoint - BEFORE mounting MCP app
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint for monitoring"""
|
"""Health check endpoint for monitoring"""
|
||||||
@@ -178,119 +77,33 @@ async def health_check():
|
|||||||
"service": "Yargı MCP Server",
|
"service": "Yargı MCP Server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"tools_count": len(mcp_server._tool_manager._tools),
|
"tools_count": len(mcp_server._tool_manager._tools),
|
||||||
"auth_enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add explicit redirect for /mcp to /mcp/ with method preservation
|
|
||||||
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
|
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
|
||||||
async def redirect_to_slash(request: Request):
|
async def redirect_to_slash(request: Request):
|
||||||
"""Redirect /mcp to /mcp/ preserving HTTP method with 308"""
|
"""Redirect /mcp to /mcp/ preserving HTTP method with 308"""
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
return RedirectResponse(url="/mcp/", status_code=308)
|
return RedirectResponse(url="/mcp/", status_code=308)
|
||||||
|
|
||||||
# MCP mount at /mcp handles path routing correctly
|
|
||||||
|
|
||||||
# IMPORTANT: Add FastAPI endpoints BEFORE mounting MCP app
|
|
||||||
# Otherwise mount at root will catch all requests
|
|
||||||
|
|
||||||
# Debug endpoint to test routing
|
|
||||||
@app.get("/debug/test")
|
|
||||||
async def debug_test():
|
|
||||||
"""Debug endpoint to test if FastAPI routes work"""
|
|
||||||
return {"message": "FastAPI routes working", "debug": True}
|
|
||||||
|
|
||||||
# Clerk CORS proxy endpoints
|
|
||||||
@app.api_route("/clerk-proxy/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def clerk_cors_proxy(request: Request, path: str):
|
|
||||||
"""
|
|
||||||
Proxy requests to Clerk to bypass CORS restrictions.
|
|
||||||
Forwards requests from Claude AI to clerk.yargimcp.com with proper CORS headers.
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
# Build target URL
|
|
||||||
clerk_url = f"https://clerk.yargimcp.com/{path}"
|
|
||||||
|
|
||||||
# Forward query parameters
|
|
||||||
if request.url.query:
|
|
||||||
clerk_url += f"?{request.url.query}"
|
|
||||||
|
|
||||||
# Copy headers (exclude host/origin)
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop('host', None)
|
|
||||||
headers.pop('origin', None)
|
|
||||||
headers['origin'] = 'https://yargimcp.com' # Use our frontend domain
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
# Forward the request to Clerk
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
# Handle preflight
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=clerk_url,
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Forward body for POST/PUT requests
|
|
||||||
body = None
|
|
||||||
if request.method in ["POST", "PUT", "PATCH"]:
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=clerk_url,
|
|
||||||
headers=headers,
|
|
||||||
content=body
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create response with CORS headers
|
|
||||||
response_headers = dict(response.headers)
|
|
||||||
response_headers.update({
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, Origin, X-Requested-With",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
"Access-Control-Max-Age": "86400"
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
content=response.content,
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=response_headers,
|
|
||||||
media_type=response.headers.get("content-type")
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "proxy_error", "message": str(e)},
|
|
||||||
status_code=500,
|
|
||||||
headers={"Access-Control-Allow-Origin": "*"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# FastAPI root endpoint
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint with service information"""
|
"""Root endpoint with service information"""
|
||||||
return {
|
return {
|
||||||
"service": "Yargı MCP Server",
|
"service": "Yargı MCP Server",
|
||||||
"description": "MCP server for Turkish legal databases with OAuth authentication",
|
"description": "MCP server for Turkish legal databases",
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"mcp": "/mcp",
|
"mcp": "/mcp",
|
||||||
"health": "/health",
|
"health": "/health",
|
||||||
"status": "/status",
|
"status": "/status",
|
||||||
"stripe_webhook": "/api/stripe/webhook",
|
|
||||||
"oauth_login": "/auth/login",
|
|
||||||
"oauth_callback": "/auth/callback",
|
|
||||||
"oauth_google": "/auth/google/login",
|
|
||||||
"user_info": "/auth/user"
|
|
||||||
},
|
},
|
||||||
"transports": {
|
"transports": {
|
||||||
"http": "/mcp"
|
"http": "/mcp"
|
||||||
},
|
},
|
||||||
"supported_databases": [
|
"supported_databases": [
|
||||||
"Yargıtay (Court of Cassation)",
|
"Yargıtay (Court of Cassation)",
|
||||||
"Danıştay (Council of State)",
|
"Danıştay (Council of State)",
|
||||||
"Emsal (Precedent)",
|
"Emsal (Precedent)",
|
||||||
"Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)",
|
"Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)",
|
||||||
"Anayasa Mahkemesi (Constitutional Court)",
|
"Anayasa Mahkemesi (Constitutional Court)",
|
||||||
@@ -299,140 +112,12 @@ async def root():
|
|||||||
"Sayıştay (Court of Accounts)",
|
"Sayıştay (Court of Accounts)",
|
||||||
"KVKK (Personal Data Protection Authority)",
|
"KVKK (Personal Data Protection Authority)",
|
||||||
"BDDK (Banking Regulation and Supervision Agency)",
|
"BDDK (Banking Regulation and Supervision Agency)",
|
||||||
"Bedesten API (Multiple courts)"
|
"Bedesten API (Multiple courts)",
|
||||||
|
"Sigorta Tahkim Komisyonu (Insurance Arbitration Commission)",
|
||||||
],
|
],
|
||||||
"authentication": {
|
|
||||||
"enabled": os.getenv("ENABLE_AUTH", "false").lower() == "true",
|
|
||||||
"type": "OAuth 2.0 via Clerk",
|
|
||||||
"issuer": CLERK_ISSUER,
|
|
||||||
"providers": ["google"],
|
|
||||||
"flow": "authorization_code"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# OAuth 2.0 Authorization Server Metadata - MCP standard location
|
|
||||||
@app.get("/.well-known/oauth-authorization-server")
|
|
||||||
async def oauth_authorization_server_root():
|
|
||||||
"""OAuth 2.0 Authorization Server Metadata - root level for compatibility"""
|
|
||||||
return {
|
|
||||||
"issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration
|
|
||||||
"authorization_endpoint": f"{BASE_URL}/auth/login",
|
|
||||||
"token_endpoint": f"{BASE_URL}/token",
|
|
||||||
"jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json",
|
|
||||||
"response_types_supported": ["code"],
|
|
||||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
||||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
|
|
||||||
"scopes_supported": ["read", "search", "openid", "profile", "email"],
|
|
||||||
"subject_types_supported": ["public"],
|
|
||||||
"id_token_signing_alg_values_supported": ["RS256"],
|
|
||||||
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
|
||||||
"code_challenge_methods_supported": ["S256"],
|
|
||||||
"service_documentation": f"{BASE_URL}/mcp",
|
|
||||||
"registration_endpoint": f"{BASE_URL}/register",
|
|
||||||
"resource_documentation": f"{BASE_URL}/mcp"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Claude AI MCP specific endpoint format - suffix versions
|
|
||||||
@app.get("/.well-known/oauth-authorization-server/mcp")
|
|
||||||
async def oauth_authorization_server_mcp_suffix():
|
|
||||||
"""OAuth 2.0 Authorization Server Metadata - Claude AI MCP specific format"""
|
|
||||||
return {
|
|
||||||
"issuer": BASE_URL, # Use BASE_URL as issuer for MCP integration
|
|
||||||
"authorization_endpoint": f"{BASE_URL}/auth/login",
|
|
||||||
"token_endpoint": f"{BASE_URL}/token",
|
|
||||||
"jwks_uri": f"{CLERK_ISSUER}/.well-known/jwks.json",
|
|
||||||
"response_types_supported": ["code"],
|
|
||||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
||||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
|
|
||||||
"scopes_supported": ["read", "search", "openid", "profile", "email"],
|
|
||||||
"subject_types_supported": ["public"],
|
|
||||||
"id_token_signing_alg_values_supported": ["RS256"],
|
|
||||||
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
|
||||||
"code_challenge_methods_supported": ["S256"],
|
|
||||||
"service_documentation": f"{BASE_URL}/mcp",
|
|
||||||
"registration_endpoint": f"{BASE_URL}/register",
|
|
||||||
"resource_documentation": f"{BASE_URL}/mcp"
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/.well-known/oauth-protected-resource/mcp")
|
|
||||||
async def oauth_protected_resource_mcp_suffix():
|
|
||||||
"""OAuth 2.0 Protected Resource Metadata - Claude AI MCP specific format"""
|
|
||||||
return {
|
|
||||||
"resource": BASE_URL,
|
|
||||||
"authorization_servers": [
|
|
||||||
BASE_URL
|
|
||||||
],
|
|
||||||
"scopes_supported": ["read", "search"],
|
|
||||||
"bearer_methods_supported": ["header"],
|
|
||||||
"resource_documentation": f"{BASE_URL}/mcp",
|
|
||||||
"resource_policy_uri": f"{BASE_URL}/privacy"
|
|
||||||
}
|
|
||||||
|
|
||||||
# OAuth 2.0 Protected Resource Metadata (RFC 9728) - MCP Spec Required
|
|
||||||
@app.get("/.well-known/oauth-protected-resource")
|
|
||||||
async def oauth_protected_resource():
|
|
||||||
"""OAuth 2.0 Protected Resource Metadata as required by MCP spec"""
|
|
||||||
return {
|
|
||||||
"resource": BASE_URL,
|
|
||||||
"authorization_servers": [
|
|
||||||
BASE_URL
|
|
||||||
],
|
|
||||||
"scopes_supported": ["read", "search"],
|
|
||||||
"bearer_methods_supported": ["header"],
|
|
||||||
"resource_documentation": f"{BASE_URL}/mcp",
|
|
||||||
"resource_policy_uri": f"{BASE_URL}/privacy"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Standard well-known discovery endpoint
|
|
||||||
@app.get("/.well-known/mcp")
|
|
||||||
async def well_known_mcp():
|
|
||||||
"""Standard MCP discovery endpoint"""
|
|
||||||
return {
|
|
||||||
"mcp_server": {
|
|
||||||
"name": "Yargı MCP Server",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"endpoint": f"{BASE_URL}/mcp",
|
|
||||||
"authentication": {
|
|
||||||
"type": "oauth2",
|
|
||||||
"authorization_url": f"{BASE_URL}/auth/login",
|
|
||||||
"scopes": ["read", "search"]
|
|
||||||
},
|
|
||||||
"capabilities": ["tools", "resources"],
|
|
||||||
"tools_count": len(mcp_server._tool_manager._tools)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# MCP Discovery endpoint for ChatGPT integration
|
|
||||||
@app.get("/mcp/discovery")
|
|
||||||
async def mcp_discovery():
|
|
||||||
"""MCP Discovery endpoint for ChatGPT and other MCP clients"""
|
|
||||||
return {
|
|
||||||
"name": "Yargı MCP Server",
|
|
||||||
"description": "MCP server for Turkish legal databases",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"protocol": "mcp",
|
|
||||||
"transport": "http",
|
|
||||||
"endpoint": "/mcp",
|
|
||||||
"authentication": {
|
|
||||||
"type": "oauth2",
|
|
||||||
"authorization_url": "/auth/login",
|
|
||||||
"token_url": "/token",
|
|
||||||
"scopes": ["read", "search"],
|
|
||||||
"provider": "clerk"
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"tools": True,
|
|
||||||
"resources": True,
|
|
||||||
"prompts": False
|
|
||||||
},
|
|
||||||
"tools_count": len(mcp_server._tool_manager._tools),
|
|
||||||
"contact": {
|
|
||||||
"url": BASE_URL,
|
|
||||||
"email": "support@yargi-mcp.dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# FastAPI status endpoint
|
|
||||||
@app.get("/status")
|
@app.get("/status")
|
||||||
async def status():
|
async def status():
|
||||||
"""Status endpoint with detailed information"""
|
"""Status endpoint with detailed information"""
|
||||||
@@ -442,159 +127,20 @@ async def status():
|
|||||||
"name": tool.name,
|
"name": tool.name,
|
||||||
"description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description
|
"description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "operational",
|
"status": "operational",
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
"total_tools": len(tools),
|
"total_tools": len(tools),
|
||||||
"transport": "streamable_http",
|
"transport": "streamable_http",
|
||||||
"architecture": "FastAPI wrapper + MCP Starlette sub-app",
|
|
||||||
"auth_status": "enabled" if os.getenv("ENABLE_AUTH", "false").lower() == "true" else "disabled"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Simplified OAuth session validation for callback endpoints only
|
|
||||||
async def validate_clerk_session_for_oauth(request: Request, clerk_token: str = None) -> str:
|
|
||||||
"""Validate Clerk session for OAuth callback endpoints only (not for MCP endpoints)"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use Clerk SDK if available
|
|
||||||
if not CLERK_SDK_AVAILABLE:
|
|
||||||
raise ImportError("Clerk SDK not available")
|
|
||||||
clerk = Clerk(bearer_auth=CLERK_SECRET_KEY)
|
|
||||||
|
|
||||||
# Try JWT token first (from URL parameter)
|
|
||||||
if clerk_token:
|
|
||||||
try:
|
|
||||||
return "oauth_user_from_token"
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback to cookie validation
|
# Mount MCP app at /mcp/
|
||||||
clerk_session = request.cookies.get("__session")
|
|
||||||
if not clerk_session:
|
|
||||||
raise HTTPException(status_code=401, detail="No Clerk session found")
|
|
||||||
|
|
||||||
# Validate session with Clerk
|
|
||||||
session = clerk.sessions.verify_session(clerk_session)
|
|
||||||
return session.user_id
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
return "dev_user_123"
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=401, detail=f"OAuth session validation failed: {str(e)}")
|
|
||||||
|
|
||||||
# MCP OAuth Callback Endpoint
|
|
||||||
@app.get("/auth/mcp-callback")
|
|
||||||
async def mcp_oauth_callback(request: Request, clerk_token: str = Query(None)):
|
|
||||||
"""Handle OAuth callback for MCP token generation"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Validate Clerk session with JWT token support
|
|
||||||
user_id = await validate_clerk_session_for_oauth(request, clerk_token)
|
|
||||||
|
|
||||||
# Return success response
|
|
||||||
return HTMLResponse(f"""
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MCP Connection Successful</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
|
||||||
.success {{ color: #28a745; }}
|
|
||||||
.token {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; word-break: break-all; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 class="success">✅ MCP Connection Successful!</h1>
|
|
||||||
<p>Your Yargı MCP integration is now active.</p>
|
|
||||||
<div class="token">
|
|
||||||
<strong>Authentication:</strong><br>
|
|
||||||
<code>Use your Clerk JWT token directly with Bearer authentication</code>
|
|
||||||
</div>
|
|
||||||
<p>You can now close this window and return to your MCP client.</p>
|
|
||||||
<script>
|
|
||||||
// Try to close the popup if opened as such
|
|
||||||
if (window.opener) {{
|
|
||||||
window.opener.postMessage({{
|
|
||||||
type: 'MCP_AUTH_SUCCESS',
|
|
||||||
token: 'use_clerk_jwt_token'
|
|
||||||
}}, '*');
|
|
||||||
setTimeout(() => window.close(), 3000);
|
|
||||||
}}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""")
|
|
||||||
|
|
||||||
except HTTPException as e:
|
|
||||||
return HTMLResponse(f"""
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MCP Connection Failed</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
|
||||||
.error {{ color: #dc3545; }}
|
|
||||||
.debug {{ background: #f8f9fa; padding: 10px; margin: 20px 0; border-radius: 5px; font-family: monospace; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 class="error">❌ MCP Connection Failed</h1>
|
|
||||||
<p>{e.detail}</p>
|
|
||||||
<div class="debug">
|
|
||||||
<strong>Debug Info:</strong><br>
|
|
||||||
Clerk Token: {'✅ Provided' if clerk_token else '❌ Missing'}<br>
|
|
||||||
Error: {e.detail}<br>
|
|
||||||
Status: {e.status_code}
|
|
||||||
</div>
|
|
||||||
<p>Please try again or contact support.</p>
|
|
||||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""", status_code=e.status_code)
|
|
||||||
except Exception as e:
|
|
||||||
return HTMLResponse(f"""
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MCP Connection Error</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
|
|
||||||
.error {{ color: #dc3545; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 class="error">❌ Unexpected Error</h1>
|
|
||||||
<p>An unexpected error occurred during authentication.</p>
|
|
||||||
<p>Error: {str(e)}</p>
|
|
||||||
<a href="https://yargimcp.com/sign-in">Return to Sign In</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""", status_code=500)
|
|
||||||
|
|
||||||
# OAuth2 Token Endpoint - Now uses Clerk JWT tokens directly
|
|
||||||
@app.post("/auth/mcp-token")
|
|
||||||
async def mcp_token_endpoint(request: Request):
|
|
||||||
"""OAuth2 token endpoint for MCP clients - returns Clerk JWT token info"""
|
|
||||||
try:
|
|
||||||
# Validate Clerk session
|
|
||||||
user_id = await validate_clerk_session_for_oauth(request)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Use your Clerk JWT token directly with Bearer authentication",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"scope": "yargi.read",
|
|
||||||
"user_id": user_id,
|
|
||||||
"instructions": "Include 'Authorization: Bearer YOUR_CLERK_JWT_TOKEN' in your requests"
|
|
||||||
}
|
|
||||||
except HTTPException as e:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=e.status_code,
|
|
||||||
content={"error": "invalid_request", "error_description": e.detail}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mount MCP app at /mcp/ with trailing slash
|
|
||||||
app.mount("/mcp/", mcp_app)
|
app.mount("/mcp/", mcp_app)
|
||||||
|
|
||||||
# Set the lifespan context after mounting
|
# Set the lifespan context after mounting
|
||||||
app.router.lifespan_context = mcp_app.lifespan
|
app.router.lifespan_context = mcp_app.lifespan
|
||||||
|
|
||||||
# Export for uvicorn
|
# Export for uvicorn
|
||||||
__all__ = ["app"]
|
__all__ = ["app"]
|
||||||
|
|||||||
@@ -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,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"
|
|
||||||
@@ -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)}
|
|
||||||
)
|
|
||||||
+3
-26
@@ -31,7 +31,6 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
TIKTOKEN_AVAILABLE = False
|
TIKTOKEN_AVAILABLE = False
|
||||||
tiktoken = None
|
tiktoken = None
|
||||||
from fastmcp.server.dependencies import get_access_token, AccessToken
|
|
||||||
from fastmcp import Context
|
from fastmcp import Context
|
||||||
|
|
||||||
# Use standard exception for tool errors
|
# Use standard exception for tool errors
|
||||||
@@ -241,14 +240,10 @@ class TokenCountingMiddleware(Middleware):
|
|||||||
# Create FastMCP app directly without authentication wrapper
|
# Create FastMCP app directly without authentication wrapper
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
def create_app(auth=None):
|
def create_app():
|
||||||
"""Create FastMCP app with standard capabilities and optional auth."""
|
"""Create FastMCP app with standard capabilities."""
|
||||||
global app
|
global app
|
||||||
if auth:
|
logger.info("MCP server created with standard capabilities...")
|
||||||
app.auth = auth
|
|
||||||
logger.info("MCP server created with Bearer authentication enabled")
|
|
||||||
else:
|
|
||||||
logger.info("MCP server created with standard capabilities...")
|
|
||||||
|
|
||||||
# Add token counting middleware only if tiktoken is available
|
# Add token counting middleware only if tiktoken is available
|
||||||
if TIKTOKEN_AVAILABLE:
|
if TIKTOKEN_AVAILABLE:
|
||||||
@@ -1125,24 +1120,6 @@ For best results, use exact phrases with quotes for legal terms."""),
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Search Turkish legal databases via unified Bedesten API."""
|
"""Search Turkish legal databases via unified Bedesten API."""
|
||||||
|
|
||||||
# Get Bearer token information for access control and logging
|
|
||||||
try:
|
|
||||||
access_token: AccessToken = get_access_token()
|
|
||||||
user_id = access_token.client_id
|
|
||||||
user_scopes = access_token.scopes
|
|
||||||
|
|
||||||
# Check for required scopes - DISABLED: Already handled by Bearer auth provider
|
|
||||||
# if "yargi.read" not in user_scopes and "yargi.search" not in user_scopes:
|
|
||||||
# raise ToolError(f"Insufficient permissions: 'yargi.read' or 'yargi.search' scope required. Current scopes: {user_scopes}")
|
|
||||||
|
|
||||||
logger.info(f"Tool 'search_bedesten_unified' called by user '{user_id}' with scopes {user_scopes}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Development mode fallback - allow access without strict token validation
|
|
||||||
logger.warning(f"Bearer token validation failed, using development mode: {str(e)}")
|
|
||||||
user_id = "dev-user"
|
|
||||||
user_scopes = ["yargi.read", "yargi.search"]
|
|
||||||
|
|
||||||
pageSize = 10 # Default value
|
pageSize = 10 # Default value
|
||||||
|
|
||||||
# Convert date formats if provided
|
# Convert date formats if provided
|
||||||
|
|||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
upstream yargi_mcp {
|
|
||||||
server yargi-mcp:8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
|
||||||
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=100r/s;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS in production
|
|
||||||
# return 301 https://$server_name$request_uri;
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-Frame-Options DENY;
|
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
|
||||||
|
|
||||||
# API endpoints
|
|
||||||
location /api/ {
|
|
||||||
limit_req zone=api_limit burst=20 nodelay;
|
|
||||||
|
|
||||||
proxy_pass http://yargi_mcp;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Timeouts
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# MCP endpoint (higher rate limit)
|
|
||||||
location /mcp-server/mcp/ {
|
|
||||||
limit_req zone=mcp_limit burst=50 nodelay;
|
|
||||||
|
|
||||||
proxy_pass http://yargi_mcp;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket support
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
# Longer timeouts for MCP operations
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check (no rate limit)
|
|
||||||
location /health {
|
|
||||||
proxy_pass http://yargi_mcp;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Root and other paths
|
|
||||||
location / {
|
|
||||||
limit_req zone=api_limit burst=10 nodelay;
|
|
||||||
|
|
||||||
proxy_pass http://yargi_mcp;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# SSL configuration (uncomment for production)
|
|
||||||
# server {
|
|
||||||
# listen 443 ssl http2;
|
|
||||||
# server_name your-domain.com;
|
|
||||||
#
|
|
||||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
|
||||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
|
||||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
#
|
|
||||||
# # Include all location blocks from above
|
|
||||||
# }
|
|
||||||
}
|
|
||||||
+2
-9
@@ -46,22 +46,15 @@ production = [
|
|||||||
"gunicorn>=22.0.0",
|
"gunicorn>=22.0.0",
|
||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
]
|
]
|
||||||
saas = [
|
|
||||||
"clerk-backend-api>=3.0.0",
|
|
||||||
"stripe>=9.1.0",
|
|
||||||
"upstash-redis>=1.1.0",
|
|
||||||
"tiktoken>=0.5.0",
|
|
||||||
"PyJWT>=2.8.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
yargi-mcp = "mcp_server_main:main"
|
yargi-mcp = "mcp_server_main:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["mcp_server_main", "mcp_auth_factory", "mcp_auth_http_adapter", "asgi_app", "fastapi_app", "starlette_app", "run_asgi", "stripe_webhook"]
|
py-modules = ["mcp_server_main", "asgi_app"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["*_mcp_module", "mcp_auth", "semantic_search"]
|
include = ["*_mcp_module", "semantic_search"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=65.0", "wheel"]
|
requires = ["setuptools>=65.0", "wheel"]
|
||||||
|
|||||||
-119
@@ -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,159 +0,0 @@
|
|||||||
"""
|
|
||||||
Starlette integration example for Yargı MCP Server
|
|
||||||
|
|
||||||
This module demonstrates how to integrate the Yargı MCP server
|
|
||||||
with a Starlette application, including authentication middleware
|
|
||||||
and custom routing.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uvicorn starlette_app:app --host 0.0.0.0 --port 8000
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from starlette.applications import Starlette
|
|
||||||
from starlette.routing import Mount, Route
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse
|
|
||||||
from starlette.middleware import Middleware
|
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
||||||
from starlette.authentication import (
|
|
||||||
AuthenticationBackend, AuthCredentials, SimpleUser, AuthenticationError
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import the main MCP app
|
|
||||||
from mcp_server_main import app as mcp_server
|
|
||||||
|
|
||||||
# Simple token authentication backend
|
|
||||||
class TokenAuthBackend(AuthenticationBackend):
|
|
||||||
async def authenticate(self, request):
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
expected_token = os.getenv("API_TOKEN")
|
|
||||||
|
|
||||||
# Skip auth for health check and public endpoints
|
|
||||||
if request.url.path in ["/health", "/", "/login"]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not expected_token:
|
|
||||||
# No token configured, allow all
|
|
||||||
return AuthCredentials(["authenticated"]), SimpleUser("anonymous")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
raise AuthenticationError("Authorization header required")
|
|
||||||
|
|
||||||
try:
|
|
||||||
scheme, token = auth_header.split()
|
|
||||||
if scheme.lower() != "bearer":
|
|
||||||
raise AuthenticationError("Invalid authentication scheme")
|
|
||||||
|
|
||||||
if token != expected_token:
|
|
||||||
raise AuthenticationError("Invalid token")
|
|
||||||
|
|
||||||
return AuthCredentials(["authenticated"]), SimpleUser("user")
|
|
||||||
except ValueError:
|
|
||||||
raise AuthenticationError("Invalid authorization header format")
|
|
||||||
|
|
||||||
# Homepage
|
|
||||||
async def homepage(request: Request):
|
|
||||||
return JSONResponse({
|
|
||||||
"service": "Yargı MCP Server",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"endpoints": {
|
|
||||||
"mcp": "/mcp-server/mcp/",
|
|
||||||
"api": "/api/",
|
|
||||||
"health": "/health"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# API info endpoint
|
|
||||||
async def api_info(request: Request):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
|
||||||
|
|
||||||
return JSONResponse({
|
|
||||||
"authenticated_as": request.user.display_name,
|
|
||||||
"available_tools": len(mcp_server._tool_manager._tools),
|
|
||||||
"databases": [
|
|
||||||
"Yargıtay", "Danıştay", "Emsal", "Uyuşmazlık",
|
|
||||||
"Anayasa", "KIK", "Rekabet", "Bedesten"
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
async def health_check(request: Request):
|
|
||||||
return JSONResponse({
|
|
||||||
"status": "healthy",
|
|
||||||
"service": "Yargı MCP Server"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Login example (returns token for demo)
|
|
||||||
async def login(request: Request):
|
|
||||||
token = os.getenv("API_TOKEN", "demo-token")
|
|
||||||
return JSONResponse({
|
|
||||||
"message": "Use this token in Authorization header",
|
|
||||||
"example": f"Authorization: Bearer {token}",
|
|
||||||
"note": "Set API_TOKEN environment variable to change token"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create MCP ASGI app
|
|
||||||
mcp_app = mcp_server.http_app(path='/mcp')
|
|
||||||
|
|
||||||
# Configure middleware
|
|
||||||
middleware = [
|
|
||||||
Middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
),
|
|
||||||
Middleware(AuthenticationMiddleware, backend=TokenAuthBackend()),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create routes
|
|
||||||
routes = [
|
|
||||||
Route("/", homepage),
|
|
||||||
Route("/health", health_check),
|
|
||||||
Route("/login", login),
|
|
||||||
Route("/api/info", api_info),
|
|
||||||
Mount("/mcp-server", app=mcp_app),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create Starlette app
|
|
||||||
app = Starlette(
|
|
||||||
routes=routes,
|
|
||||||
middleware=middleware,
|
|
||||||
lifespan=mcp_app.lifespan
|
|
||||||
)
|
|
||||||
|
|
||||||
# Nested mount example
|
|
||||||
def create_nested_app():
|
|
||||||
"""Example of nested mounting for complex routing structures"""
|
|
||||||
|
|
||||||
# Create inner app with MCP
|
|
||||||
inner_app = Starlette(
|
|
||||||
routes=[Mount("/services", app=mcp_app)],
|
|
||||||
middleware=middleware
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create outer app
|
|
||||||
outer_app = Starlette(
|
|
||||||
routes=[
|
|
||||||
Route("/", homepage),
|
|
||||||
Mount("/v1", app=inner_app),
|
|
||||||
],
|
|
||||||
lifespan=mcp_app.lifespan
|
|
||||||
)
|
|
||||||
|
|
||||||
# MCP would be available at /v1/services/mcp/
|
|
||||||
return outer_app
|
|
||||||
|
|
||||||
# Export both apps
|
|
||||||
nested_app = create_nested_app()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
print("Starting Starlette app with authentication...")
|
|
||||||
print("Set API_TOKEN environment variable to enable authentication")
|
|
||||||
print("Example: API_TOKEN=secret-token python starlette_app.py")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import os, stripe
|
|
||||||
from clerk_backend_api import Clerk # Clerk backend SDK
|
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
stripe.api_key = os.getenv("STRIPE_SECRET")
|
|
||||||
clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
|
|
||||||
|
|
||||||
@router.post("/stripe/webhook")
|
|
||||||
async def stripe_hook(req: Request):
|
|
||||||
payload, sig = await req.body(), req.headers["stripe-signature"]
|
|
||||||
try:
|
|
||||||
event = stripe.Webhook.construct_event( # Stripe-recommended verify
|
|
||||||
payload, sig, os.getenv("STRIPE_WEBHOOK_SECRET"))
|
|
||||||
except stripe.error.SignatureVerificationError:
|
|
||||||
raise HTTPException(400, "Bad sig")
|
|
||||||
|
|
||||||
if event["type"] == "customer.subscription.updated":
|
|
||||||
item = event["data"]["object"]["items"]["data"][0]
|
|
||||||
plan = item["price"]["nickname"] # "Pro", "Enterprise"…
|
|
||||||
userID = event["data"]["object"]["metadata"]["clerk_user_id"]
|
|
||||||
clerk.users.update_user_metadata( # merge into unsafe_metadata
|
|
||||||
userID, unsafe_metadata={"plan": plan})
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user