303 Commits
Author SHA1 Message Date
saidsurucuandClaude Opus 4.8 a062237474 docs: add local uv copy-paste install for Antigravity; bump to 0.2.1
README'ye Antigravity için lokal uvx kurulumunu otomatik yapan
kopyala-yapıştır komutu eklendi (~/.gemini/config/mcp_config.json).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:51:19 +03:00
Said Sürücü b69eda77af Merge pull request #28 from hburaktasyurek/tool-limit-alignment
Tool Routing: align descriptions with runtime behavior and reduce Bedesten request overhead
2026-06-16 11:53:09 +03:00
Hasan Burak Taşyürek 3927dcee8f feat(deep-research): reduce Bedesten request overhead
Use search-result metadata for Deep Research previews instead of fetching every candidate document.

This keeps the compatibility tools within upstream Bedesten rate limits and updates the README to match the current active tool set.
2026-06-13 00:07:03 +03:00
Said Sürücü 3768104679 Merge pull request #27 from Baijack-star/docs-remote-mcp-troubleshooting
Document remote MCP troubleshooting
2026-06-08 18:53:18 +03:00
saidsurucuandClaude Opus 4.7 aa580ffafc docs: update Pro version URL to yargi.betaspacestudio.com
Replace https://yargi-mcp-pro-production.up.railway.app with
https://yargi.betaspacestudio.com in:
- README.md Pro announcement at the top
- mcp_server_main.py rate-limit messages in search_bedesten_unified
  and get_bedesten_document_markdown (4 occurrences)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:02:50 +03:00
saidsurucuandClaude Opus 4.7 931eb3ca8f docs(readme): announce Yargı MCP Pro at the top
Add a top-of-README callout pointing to the professional version that
combines mevzuat and içtihat in a single MCP server:
https://yargi-mcp-pro-production.up.railway.app

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 16:53:39 +03:00
saidsurucuandClaude Opus 4.7 d258ad2375 Merge fix/anayasa-document-url-host: force correct host for AYM document URLs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:06:24 +03:00
saidsurucuandClaude Opus 4.7 061887f870 fix(anayasa): force correct host for AYM document URLs by path
get_anayasa_document_unified 404'd when a /ND/ (Norm Denetimi) path was
supplied on the bireysel host (kararlarbilgibankasi) instead of the norm
host. Detection was netloc-first and passed the wrong-domain URL through
unchanged.

Add normalize_anayasa_document_url(): classify by path (/ND/ vs /BB/) and
re-key the host to the canonical domain, preserving query/fragment. Route
get_document_unified() off the normalized result. Verified end-to-end: the
previously-404 URL now returns the decision markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:06:09 +03:00
saidsurucu ac611f840c Merge fix/kik-v2-dynamic-security-headers: KİK v2 dynamic request signing 2026-05-26 12:19:52 +03:00
saidsurucuandClaude Opus 4.7 c938f10ba2 fix(kik): generate v2 request-signing headers per-request
The KİK v2 API (ekapv2.kik.gov.tr) validates a timestamp embedded in the
X-Custom-Request-Ts header and rejects stale values with HTTP 401
"İstek zaman aşımına uğradı." The client previously sent hardcoded, captured
header values, so once that timestamp aged out every search 401'd across all
three decision types (uyusmazlik/duzenleyici/mahkeme).

Replicate the Angular HTTP interceptor: AES-192-CBC/PKCS7 encrypt a fresh uuid4
GUID and the current epoch-millis timestamp with the environment.r8fact key and
a random IV, regenerated on every request.

Verified live: all three decision types return results with hataKodu "0".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:19:48 +03:00
Baijack-star 1356c4d020 Document remote MCP troubleshooting 2026-05-22 07:39:44 +08:00
saidsurucuandClaude Opus 4.7 5392435c7a docs(bedesten): point rate-limit message to Yargı MCP Pro beta
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:44:22 +03:00
saidsurucuandClaude Opus 4.7 96a5a538b2 perf(server): unblock event loop on rate-limit waits and markitdown
Two complementary changes to mitigate intermittent TLS handshake
timeouts and "notifications/cancelled: Bad Request" seen against the
single-worker uvicorn deployment.

1. bedesten rate-limiter back-pressure
   - Add optional ``max_wait`` to ``_TokenBucket.acquire``: if the next
     wait would exceed it, raise ``BedestenRateLimited`` immediately
     instead of sleeping. After a server-side 429 the bucket pauses for
     up to 30s; previously a queued request sat in ``asyncio.sleep``
     for that whole window, holding the worker slot and pushing the
     MCP client past its cancellation timeout.
   - ``search_bedesten_unified`` / ``get_bedesten_document_markdown``
     catch ``BedestenRateLimited`` and reuse the existing structured
     429-style response, so callers get a fast, clean retry signal.
   - Tunable via ``BEDESTEN_RATE_MAX_WAIT_S`` (default 8.0s).

2. Offload sync markitdown conversions to a thread
   - Every ``markitdown.convert*`` call site is now wrapped in
     ``asyncio.to_thread(...)`` across 14 modules (bedesten, yargitay,
     danistay, anayasa norm + bireysel, uyusmazlik, emsal, rekabet,
     gib, kvkk, sayistay, bddk, sigorta_tahkim, kik_v2). PDF / large
     HTML parsing was stalling the event loop for seconds, which on a
     single-worker deployment delayed every other in-flight request
     and queued new TLS handshakes until they timed out.

Verified locally:
- ``ast.parse`` + ``importlib.import_module`` on all 15 modified files
- ``mcp_server_main.create_app()`` constructs successfully
- New ``_TokenBucket.acquire(max_wait=...)`` smoke-tested across 6
  paths: capacity-available, no-arg backward compat, max_wait raise,
  max_wait wait+succeed, ``penalize_until`` + max_wait fast-raise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:31:23 +03:00
saidsurucu 26aa3dacc6 fix(bedesten): include itemTypeList in fetch metadata search
The fetch tool's metadata lookup constructed BedestenSearchData without
the required itemTypeList field, causing a Pydantic validation error and
losing the chance to enrich the response with a proper title.
2026-05-08 21:58:30 +03:00
saidsurucuandClaude Opus 4.7 58457b076f feat(bedesten): client-side rate limiter with 429 back-pressure
Probed the live API (2026-05-08): the per-IP limit is 10 requests in a
rolling 30s window, with HTTP 429 + Retry-After: 30 on the 11th call.

Add a token bucket inside BedestenApiClient (default capacity=1, refill
1 token / 3.5s — strict serialization, no burst) so we stay below the
threshold by default. When the server still returns 429 (e.g. the egress
IP is shared with other clients), pause the whole bucket for the
Retry-After window so queued in-flight requests wait gracefully instead
of hammering. Tunable via BEDESTEN_RATE_CAPACITY / BEDESTEN_RATE_REFILL_S.

Verified: 14 concurrent requests after a clean cooldown -> 13 OK,
1 stray 429 (bucket auto-paused 22.5s, then drained cleanly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:10:37 +03:00
saidsurucuandClaude Opus 4.7 4521e1de85 fix(bedesten): suggest yargi-cli as fallback in 429 message
When the Bedesten API rate-limits us, point the model at the local
yargi-cli tool (https://github.com/saidsurucu/yargi-cli) so the user
has a working alternative while waiting out the limit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:30:49 +03:00
saidsurucuandClaude Opus 4.7 8f04010c57 fix(bedesten): return structured 429 response instead of raising
Bedesten API can intermittently return HTTP 429 Too Many Requests.
Previously the tool raised, leaving the LLM with an unhandled error.
Now search_bedesten_unified returns a dict with error="rate_limit_exceeded"
and get_bedesten_document_markdown returns a BedestenDocumentMarkdown
whose markdown_content describes the rate limit, so the model can
inform the user and retry. Non-429 errors still propagate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:18:12 +03:00
saidsurucuandClaude Opus 4.7 7ed9c25687 docs(readme): announce migration to yargimcp.surucu.dev
Add a prominent banner at the top of the README and inline notices
near the connection instructions stating the server has moved to
https://yargimcp.surucu.dev/mcp. The old https://yargimcp.fastmcp.app/mcp
endpoint is now a migration stub that returns only a notice tool.
Update Claude Desktop and Google Antigravity config URLs to the new host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:43:32 +03:00
saidsurucuandClaude Opus 4.7 4fdc7a3689 fix(deploy): make migration_app entrypoint a FastMCP instance
The Dokploy FastMCP build pipeline runs `fastmcp inspect <module>:app`
and expects `app` to be a FastMCP instance, not a Starlette ASGI app.
Drop the `mcp.http_app()` wrapper and bind the FastMCP instance to
`app` directly so `fastmcp inspect` and `fastmcp run --transport http`
both work. Verified locally with `fastmcp inspect` and end-to-end MCP
initialize over `fastmcp run`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:39:02 +03:00
saidsurucuandClaude Opus 4.7 1538a4c145 feat(deploy): add migration stub MCP app pointing to new URL
migration_app.py is a minimal FastMCP server with a single
migration_notice tool. Intended for the deprecated endpoint so
existing MCP clients learn the server has moved to
https://yargimcp.surucu.dev/mcp and instruct the user to update
their client configuration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:29:32 +03:00
saidsurucuandClaude Opus 4.7 a24def2e66 feat(deploy): add minimal ASGI app and Dockerfile for simple deploys
Mirrors the mevzuat-mcp pattern: a thin app.py exposing
mcp.http_app() with a /health route, no FastAPI/CORS/OAuth wrapper.
Dockerfile builds on python:3.12-slim and runs uvicorn directly.
Removes Dockerfile/fly.toml entries from .dockerignore so the new
Dockerfile is included in the build context.

Existing asgi_app.py (api.yargimcp.com production) is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:13:22 +03:00
saidsurucuandClaude Opus 4.7 6b781b61d2 docs(semantic_search): recommend multilingual-e5-large for Turkish (#22)
Different embedding model families need different prompt prefixes —
Gemini wants "task: ... | query: ..." and "title: ... | text: ...",
e5 wants "query: ..." / "passage: ...", and using the wrong one
silently degrades retrieval quality. Add EMBEDDING_PROMPT_STYLE
(gemini/e5/raw) so the prefix matches the chosen model.

Defaults: gemini for OpenRouter (matches the existing default
google/gemini-embedding-001), e5 for the local provider (matches
the recommended multilingual-e5-large setup). Both override via
env var or constructor.

Update README and .env.example to recommend intfloat/multilingual-
e5-large served by HuggingFace Text Embeddings Inference (one
docker run) as the Turkish-optimized local setup, with a clear env
var reference table. Ollama and OpenRouter remain documented as
alternatives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:55:42 +03:00
saidsurucuandClaude Opus 4.7 fb29146755 feat(semantic_search): support local OpenAI-compatible embedding servers (#22)
Adds a LocalEmbedder that targets any OpenAI-compatible embedding
endpoint (Ollama, llama.cpp, vLLM, LM Studio, ...). Zero new
Python dependencies — reuses the existing openai SDK with a
custom base_url. Defaults to Ollama at http://localhost:11434/v1
with nomic-embed-text @ 768 dims; override via env vars for other
servers/models (e.g. bge-m3 @ 1024 dims for better Turkish).

Refactors the shared encode/similarity logic into a private base
class so OpenRouterEmbedder and LocalEmbedder don't duplicate ~50
lines. OpenRouter keeps its ranking headers; local sends none.

Adds get_embedder() factory selecting the provider based on
EMBEDDING_PROVIDER (local) or OPENROUTER_API_KEY presence, and
is_semantic_search_available() that returns True for either path.
mcp_server_main now uses these so the semantic_search tool is
exposed when only a local server is configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:50:47 +03:00
saidsurucuandClaude Opus 4.7 42731a2c03 feat(semantic_search): make embedding model configurable (#22)
google/gemini-embedding-001 became paid on OpenRouter, leaving
users without credit unable to run the semantic_search tool. The
old code hardcoded the model and 3072 dimensions in three places.

Make OpenRouterEmbedder accept model/dimension via constructor
args or OPENROUTER_EMBEDDING_MODEL / OPENROUTER_EMBEDDING_DIMENSION
env vars, with the previous values as backward-compatible defaults.
Switch the VectorStore and the response payload in mcp_server_main
to read embedder.dimension instead of the hardcoded 3072 so a
configured non-Gemini model does not produce shape mismatches.

Bad dimension input (non-int or non-positive) now raises a clear
ValueError instead of a downstream shape error.

Documented the new env vars in .env.example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:46:33 +03:00
saidsurucuandClaude Opus 4.7 ae5d590cca fix(sayistay): surface clear error when upstream WAF returns 418 (#23)
Verified 2026-05-03 against a real Chrome browser: POSTs to
/KararlarGenelKurul/DataTablesList consistently return HTTP 418
with the WAF block page "Bilgi Güvenliği Politikaları Gereği
Kısıtlanmıştır", regardless of headers, cookies, CSRF token, or
form payload. The block is server-side at sayistay.gov.tr and
cannot be worked around client-side. The Temyiz Kurulu and Daire
endpoints are unaffected (29k/22k records still return normally).

Detect the 418 + WAF marker in all three search methods and raise
a clear RuntimeError explaining it is an upstream restriction,
instead of the cryptic "Client error '418 I'm a teapot'" that
hides the real situation from MCP clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:41:35 +03:00
saidsurucuandClaude Opus 4.7 ee544dc603 fix(rekabet): return parsed decisions instead of empty array (#24)
Token-optimization commit e34d81b tightened RekabetDecisionSummary
fields from Optional[str]/Optional[HttpUrl] to plain str with ""
defaults, but client.py kept passing None for unparsed cells and
HttpUrl(...) for URLs. Pydantic v2 rejected both, the broad
except Exception swallowed every row, and decisions came back []
while total_records_found stayed populated.

Default unparsed string fields to "" and pass URL strings directly
to the model. Verified against the live API for empty args,
PdfText filter, and KararTuru filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:33:52 +03:00
saidsurucuandClaude Opus 4.7 355f505da9 docs: Update README for GİB özelge module
- Intro paragraph: add GİB Özelgeleri to institution list
- Feature bullets: add GİB Özelgeleri entry with supported filters
- Tool list: new "GİB Özelge Araçları" subsection covering
  search_gib_ozelge and get_gib_ozelge_document_markdown
- Counts: tool total 22 → 24, institution total 14 → 15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:01:57 +03:00
saidsurucuandClaude Opus 4.7 4c06a5926b Add GİB özelge (tax rulings) MCP module
Introduces two tools backed by the gib.gov.tr public JSON API
(reverse-engineered from the Next.js SPA chunks):

- search_gib_ozelge: keyword, ozelgeNo, kanunNo, date-range, paging
  over 18k+ Revenue Administration tax rulings. Simple YYYY-MM-DD
  dates are auto-expanded to ISO 8601 to satisfy the backend.
- get_gib_ozelge_document_markdown: fetch a single ruling by numeric
  id and return 5000-char paginated Markdown with a metadata header
  block (title, ozelgeNo, tarih, kanun, kaynak).

Also prunes stale auth/Fly.io-era entries from uv.lock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:55:14 +03:00
saidsurucu 2cec4dccd6 Fix user_id not defined error in bedesten search 2026-04-03 15:28:48 +03:00
saidsurucu 5c2e9cc92b Add .serena to gitignore and remove from repo 2026-04-03 02:10:18 +03:00
saidsurucu a66a3f2053 Remove auth and Fly.io deployment 2026-04-03 02:08:56 +03:00
saidsurucu a4d9e2e53d Remove mcp_auth_http_simple.py 2026-04-03 02:02:44 +03:00
saidsurucu 036e49a928 docs: Update README with Sigorta Tahkim Komisyonu (14 institutions, 22 tools) 2026-03-09 22:40:10 +03:00
saidsurucu 7f78f87508 feat: Add Sigorta Tahkim Komisyonu MCP module (3 tools)
Add Insurance Arbitration Commission integration with Tavily search
and direct PDF download for 64 quarterly journal issues (2010-2025).

Tools:
- search_sigorta_tahkim_decisions: Search via Tavily API
- get_sigorta_tahkim_document_markdown: PDF download + paginated markdown
- search_within_sigorta_tahkim_issue: Keyword search within individual
  decisions of a journal issue, with Turkish İ/I case folding support

Total tools: 25 (was 22)
2026-03-09 22:17:27 +03:00
saidsurucu d8805cb93b fix: Reject null JSON-RPC IDs per MCP spec 2025-11-25
Monkey-patch JSONRPCNotification to use extra="forbid" so that
requests with "id": null are no longer misclassified as notifications
(202 Accepted). They now correctly fail validation and return a
-32600 Invalid Request error.
2026-02-16 01:29:17 +03:00
saidsurucuandClaude Opus 4.5 28ff2e39a5 fix: Update Bedesten document source_url to mevzuat.adalet.gov.tr format
Changed source_url from API endpoint (bedesten.adalet.gov.tr/document/{id})
to user-facing URL (mevzuat.adalet.gov.tr/ictihat/{id}) for direct browser access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:02:41 +03:00
Said Sürücü fd08637ca2 Update README.md 2026-01-15 11:00:21 +03:00
saidsurucu a5e6baeec8 fix: FastMCP 2.12+ auth import compatibility 2026-01-15 00:59:54 +03:00
saidsurucu 8818a7809a docs: Add Google Antigravity setup instructions 2025-12-27 11:05:52 +03:00
saidsurucu 12d51e3735 fix: Emsal API null safety 2025-12-26 21:07:42 +03:00
Said Sürücü efe962abf1 Update README with new application udfcevir.com
Added a new application for professional conversion from Word to UDF.
2025-12-26 14:29:07 +03:00
saidsurucu 1d73265f10 feat: ChatGPT App compliance updates 2025-12-25 17:42:22 +03:00
saidsurucu f1d3b60efb feat: Add semantic search documentation and bump version to 0.2.0
- Add OpenRouter API configuration guide for Claude Desktop, 5ire, Gemini CLI
- Document semantic search workflow (initial_keyword + query)
- Update tool count to reflect optional semantic search tool
- Bump version to 0.2.0 for semantic search feature release
2025-12-13 19:51:13 +03:00
saidsurucu 93e64bc1fc docs: Improve search_bedesten_semantic parameter descriptions for LLM usage 2025-12-13 19:44:59 +03:00
saidsurucu 77e2748ade feat(semantic-search): Replace local embedding model with OpenRouter API
- Replace EmbeddingGemma local model with OpenRouter API integration
- Use google/gemini-embedding-001 model via OpenRouter (3072 dimensions)
- Add conditional tool registration: auto-disable if OPENROUTER_API_KEY not set
- Add openai and numpy dependencies to pyproject.toml
- Update .env.example with OPENROUTER_API_KEY configuration
- Fix ruff lint issues in semantic_search module
2025-12-13 18:33:47 +03:00
saidsurucuandClaude da146cf3ec feat: Add semantic search tool (search_bedesten_semantic)
Add semantic search capabilities to MCP server:
- Import semantic_search module components
- Add search_bedesten_semantic tool with EmbeddingGemma integration
- Supports intelligent re-ranking of legal decisions
- 5-step process: keyword search → fetch docs → embed → vector search → format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 17:20:27 +03:00
saidsurucuandClaude e771c5b3c5 feat: Add semantic search module
Add semantic search capabilities with:
- embedder.py: Text embedding operations
- processor.py: Document processing
- vector_store.py: Vector storage and retrieval

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 17:15:12 +03:00
saidsurucu 1223b37adb refactor: Rename KİK document parameter to gundemMaddesiId 2025-12-04 15:58:25 +03:00
saidsurucu e26f09aced refactor: Remove Playwright dependency completely from project
- Replace Playwright base image with python:3.12-slim in Dockerfile
- Remove playwright from pyproject.toml dependencies
- Remove ensure_playwright_browsers() function from mcp_server_main.py
- Delete KİK v1 client files (client.py, models.py) - v2 uses httpx
- Delete postinstall.sh Playwright installation script
- Delete obsolete setup.py and requirements.txt.bak
- Remove saidsurucu-yargi-mcp-f5fa007 snapshot directory
- Update client_v2.py docstring to reflect httpx usage
- Regenerate uv.lock without playwright

KİK v2 now uses pure httpx for all HTTP operations with SSL legacy support.
2025-12-04 15:50:20 +03:00
saidsurucu ae5bae2f4a refactor(kik): Replace Playwright with httpx for document retrieval
- Remove Playwright dependency from KİK v2 client
- Use httpx with legacy SSL context for document fetching
- Remove unused imports (requests, base64, subprocess, shutil)
- Simpler and faster implementation
- Tested: 52,856 chars retrieved successfully
2025-12-04 15:43:22 +03:00
saidsurucu a2b50951e9 feat(kik): Implement document ID encryption for KİK v2 API
Reverse engineered the AES-256-CBC encryption used by KİK's Angular web
application to generate document URL hashes from numeric IDs.

Key findings:
- Algorithm: AES-256-CBC with PKCS7 padding
- Key location: ekapv2.kik.gov.tr module 21554 (environment config)
- Output format: IV (16 bytes hex) + Ciphertext (16 bytes hex) = 64 chars

Changes:
- Added encrypt_document_id() static method to KikV2ApiClient
- Updated get_document_markdown() to auto-encrypt numeric gundemMaddesiId
- Added cryptography>=44.0.0 dependency for AES encryption
- Both primary and fallback URL paths now support encryption

This enables direct document retrieval from numeric search result IDs
without requiring the pre-encrypted hash from the web interface.
2025-12-04 15:17:13 +03:00
saidsurucu 91ad04cf09 Fix: Catch all Playwright errors for curl fallback
ImportError only catches import failures. Browser launch errors
(executable not found) are runtime exceptions. Changed to catch
all Exception types to properly fallback to curl.
2025-12-04 14:44:39 +03:00
saidsurucu 5cec0df785 Add curl fallback for KİK document retrieval
Python SSL libraries (httpx, requests, urllib) fail with SSL handshake
errors against ekap.kik.gov.tr legacy server. curl uses different SSL
implementation (LibreSSL) that works.

Changes:
- Add subprocess + shutil imports
- Replace httpx fallback with curl fallback in get_document_markdown
- curl uses -k (insecure), -s (silent), -L (follow redirects) flags
- Enables KİK document retrieval on FastMCP Cloud without Playwright
2025-12-04 14:36:07 +03:00
saidsurucu d51f11c7ba Use setup.py post-install hook for Playwright Chromium installation
- Remove runtime subprocess install (FastMCP Cloud doesn't allow disk writes)
- Add setup.py with cmdclass hooks to install Chromium at build time
- Rename requirements.txt so FastMCP Cloud uses pyproject.toml instead
2025-12-04 13:34:10 +03:00
saidsurucu b207b16ef7 Auto-install Playwright Chromium at server startup for cloud deployments 2025-12-04 13:25:53 +03:00
saidsurucu f47147ba44 Add postinstall.sh for Playwright Chromium installation 2025-12-04 13:20:37 +03:00
saidsurucu 4d57a3939f Bump version to 0.1.9 2025-12-02 12:41:09 +03:00
saidsurucu 50c6963eee Fix undefined get_or_create_health_check_client function
- Add global _health_check_client variable for singleton pattern
- Define get_or_create_health_check_client() function for health checks
- Add cleanup for health check client in perform_cleanup()

Fixes Bedesten health check error: "name 'get_or_create_health_check_client' is not defined"
2025-12-02 12:37:38 +03:00
saidsurucu def7e7d65e Add Remote MCP quick start section to README 2025-11-27 11:37:26 +03:00
saidsurucu 82a0d13d25 Bump version to 0.1.8 for Gemini CLI compatibility fixes 2025-11-21 22:21:19 +03:00
saidsurucu 18b552ca2f Fix SearchResultItem reference error for Gemini CLI
- Fixed search() function to return Dict[str, Any] instead of SearchResponse
- Converted return statements to plain dictionaries
- Deleted unused Pydantic models (SearchResultItem, SearchResponse)
- Eliminates /SearchResultItem references that Gemini CLI cannot resolve
- All MCP tools now compatible with Gemini CLI schema validation
2025-11-21 22:18:36 +03:00
saidsurucu 1e96b1888e Fix Gemini CLI compatibility: Convert all Pydantic return types to Dict[str, Any]
- Fixed 10 tools to avoid / patterns in JSON schemas
- All tools now return Dict[str, Any] with .model_dump() applied
- Affected tools:
  * search_emsal_detailed_decisions
  * get_emsal_document_markdown
  * search_uyusmazlik_decisions
  * get_uyusmazlik_document_markdown_from_url
  * search_rekabet_kurumu_decisions
  * get_rekabet_kurumu_document
  * search_sayistay_unified
  * get_sayistay_document_unified
  * search_kvkk_decisions
  * get_kvkk_document_markdown
- Gemini CLI should now be able to load and use all MCP tools without schema validation errors
2025-11-21 22:13:51 +03:00
saidsurucu 815786a09d Comment out undefined LOG_FILE_PATH reference 2025-11-21 21:57:21 +03:00
saidsurucu 260adb3ac9 Fix Python 3.11 compatibility in KİK v2 client 2025-11-21 21:56:10 +03:00
saidsurucu 7164205425 Fix Gemini CLI schema error in search tool 2025-11-21 21:33:39 +03:00
saidsurucu 3961a23d3a Make tiktoken and PyJWT optional (saas group only) 2025-10-06 15:45:16 +03:00
saidsurucu 25723f070f Remove file logging - console only 2025-10-06 15:21:03 +03:00
saidsurucu 6376037ccf Add tiktoken and PyJWT to requirements.txt 2025-10-06 15:04:18 +03:00
saidsurucu d1728ce114 Fix dependency installation order 2025-10-06 15:02:34 +03:00
saidsurucu 69b5da5cef Add tiktoken to saas dependencies 2025-10-06 14:55:31 +03:00
saidsurucu 91564bf0a1 Remove logging statements from asgi_app 2025-10-06 14:49:28 +03:00
saidsurucu 6f94eca33c kik v2 update 2025-09-02 20:00:53 +03:00
saidsurucu 4122790821 Bump version to 0.1.7 - KİK v2 implementation with three decision types
- Add comprehensive KİK v2 MCP implementation
- Support for all three decision types: uyusmazlik, duzenleyici, mahkeme
- Tested with 826 total decisions across all types
- SSL legacy server support for compatibility
- Hash analysis and document ID encryption research completed
2025-09-02 19:58:40 +03:00
saidsurucuandClaude 0f5bae8bb1 Add yargi-mcp-free deployment without authentication
- Create fly-no-auth.toml configuration for free deployment
- Deploy to yargi-mcp-free.fly.dev with ENABLE_AUTH=false
- Single machine deployment for development/testing use
- Update CLAUDE.md with new deployment endpoints and usage info

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 13:41:00 +03:00
saidsurucu 4d7da0d3ba Fix null type issue in Bedesten document retrieval
Add comprehensive null safety checks for document API response fields
to prevent null type errors when accessing doc_response.data properties.

- Check if doc_response.data exists before accessing
- Validate content and mimeType fields before processing
- Add error handling for base64 decoding failures
- Provide descriptive error messages for debugging
- Prevents 'null type' errors in get_bedesten_document_markdown
2025-07-23 16:31:55 +03:00
saidsurucu 4f48681b09 Fix TypeError in Bedesten search tools - add null safety checks
Resolves 'cannot convert undefined or null to object' error in search_bedesten_unified
by adding proper null checking for response.data.emsalKararList and response.data.total
fields before accessing them.

- Add hasattr() and null checks for response.data fields
- Provide safe defaults: empty list for emsalKararList, 0 for total
- Prevents TypeError when API returns undefined/null fields
- Matches null safety pattern used in other search tools
2025-07-23 16:25:37 +03:00
saidsurucu b401bad890 Disable manual scope validation in tools
- Comment out scope check in search_bedesten_unified tool
- Authentication already handled by Bearer auth provider
- Eliminates development mode fallback due to empty scopes
- Fixes 'Insufficient permissions' error with Clerk JWT tokens

Resolves JWT token scope validation warning in logs
2025-07-23 16:08:45 +03:00
saidsurucu 0a80bc535b Add Docker cache buster to force rebuild
- Add ARG CACHE_BUST to force rebuild of code layer
- Ensures latest mcp_auth_http_simple.py syntax fix is deployed
- Resolves JSON syntax error in OAuth metadata endpoint

Forces fresh container build without cache
2025-07-22 13:15:38 +03:00
saidsurucu b1da034ea9 Fix syntax error - revert mcp_auth_http_simple.py to v0.1.6
- Copy clean v0.1.6 version without extra endpoints
- Fix JSON syntax error in OAuth metadata
- Remove all complex additional endpoint logic
- Keep only core OAuth flow endpoints

Fixes startup crash with SyntaxError
2025-07-22 13:02:21 +03:00
saidsurucu e900bc03dd Fix tools visibility - revert to v0.1.6 authentication approach
- Disable issuer validation in BearerAuthProvider (issuer=None)
- Simplify authentication condition (remove auth_enabled check)
- Revert CORS middleware to simple configuration
- Fix OAuth metadata endpoint to match v0.1.6
- Apply conditional auth only to MCP server creation

Critical fixes for Claude AI tools discovery
2025-07-22 12:38:20 +03:00
saidsurucuandClaude 54f81e18f0 Revert create_app to v0.1.6 - remove Redis session store
- Remove Redis session store initialization from create_app()
- Revert to simple token counting middleware only
- Fix session management issue causing tools to appear then disappear
- This matches the exact v0.1.6 implementation that was working

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 12:10:10 +03:00
saidsurucu 4e18e792c5 Fix MCP app creation: use exact v0.1.6 approach
- Use mcp_server.http_app(path='/') like v0.1.6
- Use redirect_to_slash function name like v0.1.6
- This should fix 'Not Found' error when accessing /mcp/ endpoint
2025-07-22 12:01:09 +03:00
saidsurucu 4a3edef287 Fix MCP redirect to support all HTTP methods
- Use api_route with all methods instead of just GET
- Claude AI makes POST/HEAD requests to /mcp endpoint
- This should fix 405 Method Not Allowed error
2025-07-22 11:51:31 +03:00
saidsurucu e2ca844ab9 Fix MCP mounting issue: revert to v0.1.6 approach
- Mount MCP app at /mcp/ with trailing slash (not at root)
- Simple GET redirect from /mcp to /mcp/ (not api_route)
- Set lifespan context after mounting (not in FastAPI constructor)
- This should fix Claude AI connection drops after OAuth
2025-07-22 11:48:33 +03:00
saidsurucu a49d0859ea Fix JWT issuer validation: use correct clerk.yargimcp.com domain
- JWT tokens are issued by clerk.yargimcp.com not accounts.yargimcp.com
- Enable issuer validation with correct domain for FastMCP Bearer auth
- This fixes tools not being visible after successful OAuth authentication
2025-07-22 11:41:32 +03:00
saidsurucu a7877f34f4 Fix v0.1.6 regression: revert shared httpx clients to individual clients
- Revert asgi_app.py to v0.1.6 approach with path='/' for MCP app
- Fix uyusmazlik client: use individual httpx.AsyncClient instead of shared
- Fix health check: use individual httpx.AsyncClient instead of shared
- Remove shared_health_check_client that was causing connection drops
2025-07-22 11:34:26 +03:00
saidsurucu 364f3761d7 fix no tool issue 2025-07-21 23:05:27 +03:00
saidsurucu 673f996f5f fix httpx efficiency 2025-07-21 22:38:52 +03:00
saidsurucu 90a7a23064 Update mcp_server_main.py 2025-07-21 22:19:44 +03:00
saidsurucu 443657f9e2 Update mcp_server_main.py 2025-07-21 21:26:39 +03:00
saidsurucu f5fa0076f8 Release v0.1.6: Production deployment with full Claude AI integration 2025-07-21 21:21:19 +03:00
saidsurucu 7a346ef3f6 Update mcp_server_main.py 2025-07-21 21:11:09 +03:00
saidsurucu 217103f0b6 fix tool count issue 2025-07-21 20:46:54 +03:00
saidsurucu 1fbcb65031 Update asgi_app.py 2025-07-21 19:58:44 +03:00
saidsurucu 9e40671798 Update asgi_app.py 2025-07-21 19:51:21 +03:00
saidsurucu 6c8a614872 Update asgi_app.py 2025-07-21 19:30:56 +03:00
saidsurucu 861d9e86ef Update asgi_app.py 2025-07-21 19:26:38 +03:00
saidsurucu c4b5d3608a Update asgi_app.py 2025-07-21 19:17:43 +03:00
saidsurucu 38e0cc032b Update asgi_app.py 2025-07-21 18:50:56 +03:00
saidsurucu 2c1b8c6f9d fix remote mcp 2025-07-21 18:45:20 +03:00
saidsurucu 92f04fbab6 Update asgi_app.py 2025-07-21 18:05:04 +03:00
saidsurucu 515347e29c Update asgi_app.py 2025-07-21 17:51:29 +03:00
saidsurucu ebefe22a4c Update mcp_server_main.py 2025-07-21 17:37:22 +03:00
saidsurucu c93244ee10 Update fly.toml 2025-07-21 16:44:48 +03:00
saidsurucu d84f8a2c88 update working operators 2025-07-21 15:33:16 +03:00
saidsurucu ec40b9d6a2 Add BDDK module and prepare v0.1.5 release 2025-07-19 17:20:38 +03:00
saidsurucu daa16cae99 Update README.md 2025-07-19 17:15:57 +03:00
saidsurucu c3bc9e17eb add bddk module 2025-07-19 17:11:33 +03:00
saidsurucu c9344fc538 Update README.md 2025-07-18 12:39:22 +03:00
saidsurucu d12ad7d900 Update mcp_server_main.py 2025-07-18 10:46:13 +03:00
saidsurucu 891751043c Update mcp_server_main.py 2025-07-18 10:39:59 +03:00
saidsurucu ba447502fe fix empty string 2025-07-18 10:22:58 +03:00
saidsurucu c092a7af45 fix bedesten 2025-07-18 09:56:22 +03:00
saidsurucu 34216a9557 Update mcp_server_main.py 2025-07-17 23:52:43 +03:00
saidsurucu 753283f0e8 shorten enum schema 2025-07-17 23:23:32 +03:00
saidsurucu 611456fd49 compress bedesten enum 2025-07-17 22:43:55 +03:00
saidsurucu 7a1ff0b9ed shorten sayıştay enums 2025-07-17 22:14:54 +03:00
saidsurucu 95620285d9 Update mcp_server_main.py 2025-07-17 21:27:12 +03:00
saidsurucu 8a148899e3 convert enum sayıştay 2025-07-17 21:20:23 +03:00
saidsurucu fa6c448afa convert enums to literal 2025-07-17 21:14:03 +03:00
saidsurucu cc363e7a6d unify sayıştay tools 2025-07-17 20:44:09 +03:00
saidsurucu f96c1a2e44 unify anayasa tools 2025-07-17 20:09:50 +03:00
saidsurucu 856ecdf13d Update mcp_server_main.py 2025-07-17 00:49:39 +03:00
saidsurucu f7dac9363a fix model issue 2025-07-16 23:34:41 +03:00
saidsurucu b32aabf541 Update mcp_server_main.py 2025-07-16 12:39:44 +03:00
saidsurucu 945ffe6267 Update mcp_server_main.py 2025-07-16 12:16:56 +03:00
saidsurucu 17f9b109a2 Update mcp_server_main.py 2025-07-15 14:11:24 +03:00
saidsurucu 2a24ce03ad Update client.py 2025-07-14 16:13:56 +03:00
saidsurucu e34d81be26 optimize token usage 2025-07-14 15:45:19 +03:00
saidsurucu 54e8d61f83 Sürüm v0.1.4: FastMCP 2.10.5 güncellemesi ve optimizasyonlar
- FastMCP bağımlılığı 2.10.5'e güncellendi
- Bedesten araçları birleştirildi (10 araç → 2 birleşik araç)
- KVKK dokümantasyonu optimize edildi (147 → 27 satır, %82 azalma)
- README 30 MCP aracının güncel listesi ile güncellendi
- Eski tekil Bedesten API araçları kaldırıldı
- Araç açıklamaları ve Türkçe örnekler iyileştirildi
- Resource dokümantasyon verimliliği artırıldı
2025-07-14 00:39:46 +03:00
saidsurucu 35382136c5 Update mcp_server_main.py 2025-07-14 00:19:27 +03:00
saidsurucu 59d81d4b03 Update mcp_server_main.py 2025-07-14 00:10:19 +03:00
saidsurucu 06a319c0ee Update README.md 2025-07-13 23:58:51 +03:00
saidsurucu a48b003121 unify bedesten tools 2025-07-13 23:34:34 +03:00
saidsurucu 1620d7a9b0 Update mcp_server_main.py 2025-07-13 22:51:35 +03:00
saidsurucu c10069bcc7 Update mcp_server_main.py 2025-07-13 22:46:30 +03:00
saidsurucu c0fe7e1305 Update mcp_server_main.py 2025-07-13 22:44:21 +03:00
saidsurucu f492109eb7 add health check tool 2025-07-13 22:31:34 +03:00
saidsurucu 35f3b739d4 Update mcp_server_main.py 2025-07-13 22:17:36 +03:00
saidsurucu 8f1ca6b854 Update mcp_server_main.py 2025-07-13 22:16:26 +03:00
saidsurucu 71218996fd optimize token usage 2025-07-13 22:11:35 +03:00
saidsurucu 6bac51dc19 token optimization 2025-07-13 22:00:35 +03:00
saidsurucu b898cad4f4 optimize descriptions 2025-07-13 18:26:16 +03:00
saidsurucu 3d172c508a Update mcp_server_main.py 2025-07-13 18:12:23 +03:00
saidsurucu 48efe74dc0 Update mcp_server_main.py 2025-07-13 17:59:28 +03:00
saidsurucu 8ae5772c2c Update mcp_server_main.py 2025-07-13 17:26:05 +03:00
saidsurucu 87cf4fd46d update docker, bugfix 2025-07-12 15:25:18 +03:00
saidsurucu d590702272 Bump version to 0.1.3
- Add auto-install Playwright browsers functionality
- Fix KIK tool page_number parameter descriptions
- Add 'accepts int' notes to all page_number parameters
- Improve PyPI deployment experience
2025-07-12 11:41:35 +03:00
saidsurucu 1ebe8847fb fix kik 2025-07-12 11:40:11 +03:00
saidsurucu cb318faeba add kvkk module, several bug fix 2025-07-11 23:50:56 +03:00
saidsurucu 83f54a86a8 Update mcp_server_main.py 2025-07-11 21:22:13 +03:00
saidsurucu f5f0f99678 add redis 2025-07-11 21:19:16 +03:00
saidsurucu b0d7151ba1 OAuth callback JWT token fix 2025-07-09 22:59:30 +03:00
saidsurucu de9e337163 fix auth 2025-07-09 22:37:26 +03:00
saidsurucu a7ebb8b27e fix auth 2025-07-09 21:36:05 +03:00
saidsurucu 01e58ab5ed Update mcp_auth_http_simple.py 2025-07-09 20:47:26 +03:00
saidsurucu 7081bbbd91 fix auth 2025-07-09 20:34:36 +03:00
saidsurucu ca89c9480d fix auth 2025-07-09 20:16:10 +03:00
saidsurucu b751e94847 Update mcp_server_main.py 2025-07-09 19:58:07 +03:00
saidsurucu 3ccb52e719 fix auth 2025-07-09 19:55:09 +03:00
saidsurucu e8b92e347c Update asgi_app.py 2025-07-09 19:44:31 +03:00
saidsurucu e65b42abc8 Update asgi_app.py 2025-07-09 19:26:24 +03:00
saidsurucu 71096ae67d Update asgi_app.py 2025-07-09 19:25:46 +03:00
saidsurucu 9fb23dadae Update asgi_app.py 2025-07-09 19:25:21 +03:00
saidsurucu d77781709a Update asgi_app.py 2025-07-09 19:15:19 +03:00
saidsurucu c059ec30a7 Update asgi_app.py 2025-07-09 19:09:37 +03:00
saidsurucu 6842ad207d Update asgi_app.py 2025-07-09 18:52:52 +03:00
saidsurucu bbc56d9409 Update asgi_app.py 2025-07-09 18:48:44 +03:00
saidsurucu 66b5e2631e Update asgi_app.py 2025-07-09 17:55:45 +03:00
saidsurucu a17853b918 Update asgi_app.py 2025-07-09 17:44:34 +03:00
saidsurucu 0a302bb13b Update asgi_app.py 2025-07-09 17:35:12 +03:00
saidsurucu 28e9a47464 Update asgi_app.py 2025-07-09 17:27:30 +03:00
saidsurucu 6c1efece31 Update mcp_server_main.py 2025-07-09 17:15:06 +03:00
saidsurucu eb9441a6f3 Update mcp_server_main.py 2025-07-09 16:27:14 +03:00
saidsurucu d006dc8a55 Update asgi_app.py 2025-07-09 15:44:09 +03:00
saidsurucu 55cee6933d upgrade fastmcp 2025-07-09 15:38:04 +03:00
saidsurucu 2f375d74e5 Update asgi_app.py 2025-07-09 14:54:39 +03:00
saidsurucu 82856b25e9 Update asgi_app.py 2025-07-09 14:24:31 +03:00
saidsurucu 8464aedceb Update asgi_app.py 2025-07-09 13:46:04 +03:00
saidsurucu 64c3a2c138 Update asgi_app.py 2025-07-08 23:52:08 +03:00
saidsurucu 318bedd4c5 add jwt support 2025-07-08 23:18:44 +03:00
saidsurucu 91895f6c1a Update asgi_app.py 2025-07-08 22:46:58 +03:00
saidsurucu b86c18c842 update auth logic 2025-07-08 22:34:02 +03:00
saidsurucu 5509380cef Rename fastapi_app.py to example_fastapi_app.py 2025-07-03 17:54:54 +03:00
saidsurucu 1c818756e4 Update README.md 2025-07-03 12:53:38 +03:00
saidsurucu 34ac65dc02 Update README.md 2025-07-03 12:52:40 +03:00
saidsurucu e6b7e645ce chore: Bump version to 0.1.2 for clean PyPI release 2025-07-03 12:42:52 +03:00
saidsurucu b4f8faf5eb fix: Remove invalid PyPI classifier 'Topic :: Legal' 2025-07-03 12:38:53 +03:00
saidsurucu a6a9201562 feat: Add PyPI publishing configuration
- Update pyproject.toml with PyPI metadata
- Add GitHub Actions workflow for automated publishing
- Bump version to 0.1.1
- Add proper classifiers and keywords for PyPI
- Configure build system with setuptools
2025-07-03 12:35:22 +03:00
saidsurucu a5e1ad1778 Update asgi_app.py 2025-07-02 03:58:19 +03:00
saidsurucu 698a9db0fb Update asgi_app.py 2025-07-02 03:44:23 +03:00
saidsurucuandClaude 732edba35c Add /mcp handler to forward to MCP app
- Create explicit FastAPI handlers for /mcp GET/POST
- Forward requests to mounted /mcp/ app via ASGI
- This should fix Claude's 405 Method Not Allowed errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 03:31:55 +03:00
saidsurucuandClaude 73c72a6039 Fix mount order for /mcp paths
- Mount /mcp before /mcp/ to fix routing
- FastAPI mount order is important for path matching

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 03:29:22 +03:00
saidsurucuandClaude 84b33f1f91 Add /mcp mount for Claude compatibility
- Mount MCP app on both /mcp/ and /mcp paths
- Claude is calling /mcp instead of /mcp/
- This should fix the 405 Method Not Allowed errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 03:28:22 +03:00
saidsurucuandClaude 4bcd95cc41 Add service_documentation to OAuth metadata
- Point Claude to correct MCP endpoint path /mcp/
- This should fix the 405 Method Not Allowed errors
- OAuth metadata now includes service_documentation field

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 03:24:45 +03:00
saidsurucu 2946a4863d Update asgi_app.py 2025-07-02 03:15:41 +03:00
saidsurucu 13ed5b3c67 Update mcp_auth_http_adapter.py 2025-07-02 03:07:33 +03:00
saidsurucuandClaude a29a624598 Fix UnprocessableEntityError import issue
- Remove UnprocessableEntityError import that doesn't exist in clerk-backend-api
- This was causing Clerk SDK to appear unavailable in HTTP adapter
- Keep only Clerk import which is sufficient for our needs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 03:02:35 +03:00
saidsurucu a094c4bf7f Add debug for Clerk SDK import 2025-07-02 03:00:10 +03:00
saidsurucu 9d41bdd30a Update mcp_auth_http_adapter.py 2025-07-02 02:50:39 +03:00
saidsurucu 8d91d2c764 improve clerk sdk use 2025-07-02 02:43:53 +03:00
saidsurucu e70554dcc9 Update pyproject.toml 2025-07-02 02:22:37 +03:00
saidsurucu cb6d6ee8df attempt to fix auth 2025-07-02 02:17:37 +03:00
saidsurucu 7e6819affa fix oauth session 2025-07-02 02:03:28 +03:00
saidsurucuandClaude 9880316fd2 Fix Clerk custom domain URL generation
- Support custom domains like clerk.yargimcp.com
- Auto-detect between custom domains and standard .accounts.dev subdomains
- Apply fix to both main OAuth and Google OAuth flows
- Maintain backward compatibility with standard Clerk domains

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 00:50:25 +03:00
saidsurucu 6919ac552b Update oauth_router.py 2025-07-02 00:00:03 +03:00
saidsurucu 7802395ef0 Update oauth_router.py 2025-07-01 23:54:33 +03:00
saidsurucuandClaude f25661c7da Use environment variables for Clerk domain
- Remove hardcoded domain fallbacks
- Always prefer CLERK_DOMAIN environment variable
- Extract domain from publishable key as secondary option
- Fallback to localhost only if no env var set

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 23:46:59 +03:00
saidsurucu dc57743939 Update mcp_server_main.py 2025-07-01 23:30:17 +03:00
saidsurucu 5d910274a4 Update mcp_server_main.py 2025-07-01 23:18:56 +03:00
saidsurucu bad36dd664 Update mcp_server_main.py 2025-07-01 23:15:29 +03:00
saidsurucu 0d8cd181b8 fix optional deps issue 2025-07-01 23:01:28 +03:00
saidsurucu 424960fafb add deep research 2025-07-01 22:53:46 +03:00
saidsurucuandClaude c6daebe924 Use environment variables for OAuth URLs
Remove hard-coded URLs from OAuth configuration and use environment
variables instead for better security and configurability:

- Add CLERK_ISSUER and BASE_URL environment variables
- Update asgi_app.py OAuth endpoints to use env vars
- Update oauth_router.py to use configurable URLs
- Update .env.example with new environment variables
- Fix fetch tool bug: doc.content → doc.markdown_content

Environment variables:
- CLERK_ISSUER: Clerk domain issuer URL
- BASE_URL: Base URL for OAuth callbacks and API URLs
- CLERK_DOMAIN: Clerk domain name

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 22:37:44 +03:00
saidsurucu 1aaf1e1bd4 improv for prod 2025-07-01 18:50:36 +03:00
saidsurucu 0f9f5da5b3 Update fly.toml 2025-07-01 18:15:39 +03:00
saidsurucu c79aacbb73 Update .gitignore 2025-07-01 18:09:23 +03:00
saidsurucu caf4fdcfa0 Update oauth_middleware.py 2025-07-01 17:55:56 +03:00
saidsurucu 2fe81b4298 fix clerk import 2025-07-01 17:47:40 +03:00
saidsurucu e0bba7625f add clerk oauth 2025-07-01 17:31:48 +03:00
saidsurucu bbf99870eb Update mcp_server_main.py 2025-07-01 13:29:25 +03:00
saidsurucu f23f74df80 add operator desc to bedesten 2025-07-01 12:34:16 +03:00
saidsurucu 1d289c6e9b fix yargitay ALL issue 2025-07-01 12:07:19 +03:00
saidsurucu 91f72bea47 Update pyproject.toml 2025-06-30 12:41:19 +03:00
saidsurucu f7500aa79d fix saas issues 2025-06-29 23:54:28 +03:00
saidsurucu 5ea1cf924a Update asgi_app.py 2025-06-29 21:49:02 +03:00
saidsurucu 346f891b5b Update asgi_app.py 2025-06-29 21:01:27 +03:00
saidsurucu dfb703d7a5 fix cleck sdk 2025-06-29 20:52:52 +03:00
saidsurucu 81f5f49b8a Update Dockerfile 2025-06-29 20:46:01 +03:00
saidsurucu 3fcb0cf773 Update Dockerfile 2025-06-29 20:45:02 +03:00
saidsurucu e34bbdc355 Update Dockerfile 2025-06-29 20:40:59 +03:00
saidsurucu 3746804b20 update Dockerfile 2025-06-29 20:39:22 +03:00
saidsurucu 553bf61106 Update stripe_webhook.py 2025-06-29 20:25:26 +03:00
saidsurucu 973a25980b add saas files 2025-06-29 14:31:37 +03:00
saidsurucu 352969deca add saas depen 2025-06-29 13:27:42 +03:00
saidsurucu 2e369304d8 add more desc to fastapi 2025-06-29 12:50:41 +03:00
saidsurucu 3cd6ba7d62 Update fastapi_app.py 2025-06-29 12:24:28 +03:00
saidsurucu f3e81e0701 Update fastapi_app.py 2025-06-29 01:06:36 +03:00
saidsurucu 2179582614 update fastapi app 2025-06-29 01:00:32 +03:00
saidsurucu a82be5979f Update client.py 2025-06-28 21:25:59 +03:00
saidsurucu 4c7da4fe10 add sayistay module 2025-06-28 21:12:41 +03:00
saidsurucu 5a930d5cea fix gemini empty enum issue 2025-06-28 20:42:10 +03:00
saidsurucu a95cc14de6 Update README.md 2025-06-27 17:07:11 +03:00
saidsurucu 425e2a4247 Update mcp_server_main.py 2025-06-27 05:28:54 +03:00
saidsurucu a8b45a7b1c update to fastmcp 2.9.2 2025-06-27 00:28:20 +03:00
saidsurucu fa78df94ce Update pyproject.toml 2025-06-27 00:14:02 +03:00
saidsurucu 262ef5bef5 add asgi support 2025-06-26 16:22:27 +03:00
saidsurucu ad0c9834e7 fix empty string gemini issue 2025-06-26 10:47:23 +03:00
saidsurucu 5accd34823 fix warning 2025-06-24 23:56:25 +03:00
saidsurucu 849cea2ca8 fix errors 2025-06-24 23:46:26 +03:00
saidsurucu 3fceb95b91 Update mcp_server_main.py 2025-06-24 23:41:35 +03:00
saidsurucu f9c23e1680 make descriptions more llm friendly 2025-06-24 21:54:10 +03:00
saidsurucu 1a190fbc3e add model descriptions 2025-06-24 21:36:16 +03:00
saidsurucu 4927f1adda Update mcp_server_main.py 2025-06-24 21:29:44 +03:00
saidsurucu b63de52910 improve llm docs 2025-06-24 19:01:23 +03:00
saidsurucu ddbbeb6c4e add exact match docs for bedesten 2025-06-24 18:52:01 +03:00
saidsurucu f755cdf438 add date filter to bedesten 2025-06-24 18:43:50 +03:00
saidsurucu 2a24f92702 add bedesten module 2025-06-24 18:24:19 +03:00
saidsurucu 98abb1d658 Update .gitignore 2025-06-24 17:06:57 +03:00
saidsurucu 7c89f2cf84 Merge branch 'main' of https://github.com/saidsurucu/yargi-mcp 2025-06-17 10:13:04 +03:00
saidsurucu 9bc9688439 remove install script 2025-06-17 10:13:02 +03:00
saidsurucu cf90036f99 Update README.md 2025-06-10 16:09:37 +03:00
saidsurucu b0847c789b Update README.md 2025-06-10 16:08:20 +03:00
saidsurucu 0d0a8b8f5e Merge branch 'main' of https://github.com/saidsurucu/yargi-mcp 2025-06-01 00:23:01 +03:00
saidsurucu a47c46ae83 Update install.py 2025-06-01 00:22:59 +03:00
saidsurucu bca7c8f99a Update README.md 2025-05-30 15:55:08 +03:00
saidsurucu 48fe348468 Update README.md 2025-05-30 15:40:29 +03:00
saidsurucu acacef5bad Update README.md 2025-05-30 13:35:17 +03:00
saidsurucu c77cc3f8c1 Update README.md 2025-05-30 12:52:59 +03:00
saidsurucu 48927a809a Create 5ire-settings.png 2025-05-30 12:52:05 +03:00
saidsurucu 017f15c785 Update README.md 2025-05-30 12:49:52 +03:00
saidsurucu 062298c005 Update README.md 2025-05-30 12:48:52 +03:00
saidsurucu c2c83b0f3f Update README.md 2025-05-30 12:48:19 +03:00
saidsurucu f13d99b36e Update README.md 2025-05-30 12:47:00 +03:00
saidsurucu ae47904ce1 Update README.md 2025-05-30 12:21:19 +03:00
saidsurucu cde3a60d82 Update mcp_server_main.py 2025-05-29 23:14:16 +03:00
saidsurucu 41e4742346 add rekabet module 2025-05-29 23:05:20 +03:00
saidsurucu 9d8fd52e99 optimize for dumb models 2025-05-28 20:52:27 +03:00
saidsurucu e703978e0e Update mcp_server_main.py 2025-05-28 20:27:55 +03:00
saidsurucu 3bcf2bbf2a flatten parameters 2025-05-28 18:42:16 +03:00
saidsurucu 12bb62cd9c Update install.py 2025-05-27 22:48:18 +03:00
saidsurucu 48adc4159e Update requirements.txt 2025-05-27 22:44:30 +03:00
saidsurucu 47e11dc3be Merge pull request #4 from saidsurucu/kik
Update README.md
2025-05-27 22:42:30 +03:00
saidsurucu 180c29e82e Update README.md 2025-05-27 22:40:33 +03:00
saidsurucu e29178e25a Merge pull request #3 from saidsurucu/kik
add kik module
2025-05-27 22:16:41 +03:00
saidsurucu 957df46b7d add kik module 2025-05-27 22:16:05 +03:00
saidsurucu 9df6eba37c Update client.py 2025-05-24 02:37:24 +03:00
saidsurucu 94553c3b78 Update pyproject.toml 2025-05-24 02:33:04 +03:00
saidsurucu 539b0c7041 Update mcp_server_main.py 2025-05-24 02:12:20 +03:00
saidsurucu 4a2af434ab Update pyproject.toml 2025-05-24 02:09:32 +03:00
saidsurucu c1352c868d Create __main__.py 2025-05-24 02:07:36 +03:00
saidsurucu 2271423c91 Update pyproject.toml 2025-05-24 02:06:40 +03:00
saidsurucu d59b498be7 Update mcp_server_main.py 2025-05-24 02:04:56 +03:00
saidsurucu 3c10913680 Update pyproject.toml 2025-05-24 02:01:58 +03:00
saidsurucu 32c4d06eb6 Update mcp_server_main.py 2025-05-24 01:29:08 +03:00
saidsurucu f892d430cb Update pyproject.toml 2025-05-24 01:21:55 +03:00
saidsurucu b19ccc60d7 add pyproject.toml 2025-05-24 01:19:53 +03:00
saidsurucu f6e95140cf Update mcp_server_main.py 2025-05-24 01:18:45 +03:00
saidsurucu 4d86833fd6 Merge branch 'main' of https://github.com/saidsurucu/yargi-mcp 2025-05-23 16:51:27 +03:00
saidsurucu 81d82369da Update install.sh 2025-05-23 16:51:23 +03:00
saidsurucu ae9bdebdad Update README.md 2025-05-23 16:02:49 +03:00
saidsurucu f82dc9f493 Update README.md 2025-05-23 16:01:46 +03:00
saidsurucu 970dd65bce Merge pull request #1 from saidsurucu/install.bat
Install.bat
2025-05-23 15:44:25 +03:00
67 changed files with 16763 additions and 1105 deletions
+183
View File
@@ -0,0 +1,183 @@
# flyctl launch added from .gitignore
# Byte-compiled / optimized / DLL files
**/__pycache__
**/*.py[cod]
**/*$py.class
# C extensions
**/*.so
# Distribution / packaging
**/.Python
**/build
**/develop-eggs
**/dist
**/downloads
**/eggs
**/.eggs
**/lib
**/lib64
**/parts
**/sdist
**/var
**/wheels
**/share/python-wheels
**/*.egg-info
**/.installed.cfg
**/*.egg
**/MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
**/*.manifest
**/*.spec
# Installer logs
**/pip-log.txt
**/pip-delete-this-directory.txt
# Unit test / coverage reports
**/htmlcov
**/.tox
**/.nox
**/.coverage
**/.coverage.*
**/.cache
**/nosetests.xml
**/coverage.xml
**/*.cover
**/*.py,cover
**/.hypothesis
**/.pytest_cache
**/cover
# Translations
**/*.mo
**/*.pot
# Django stuff:
**/*.log
**/local_settings.py
**/db.sqlite3
**/db.sqlite3-journal
# Flask stuff:
**/instance
**/.webassets-cache
# Scrapy stuff:
**/.scrapy
# Sphinx documentation
**/docs/_build
# PyBuilder
**/.pybuilder
**/target
# Jupyter Notebook
**/.ipynb_checkpoints
# IPython
**/profile_default
**/ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
**/.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
**/__pypackages__
# Celery stuff
**/celerybeat-schedule
**/celerybeat.pid
# SageMath parsed files
**/*.sage.py
# Environments
**/.env
**/.venv
**/env
**/venv
**/ENV
**/env.bak
**/venv.bak
# Spyder project settings
**/.spyderproject
**/.spyproject
# Rope project settings
**/.ropeproject
# mkdocs documentation
site
# mypy
**/.mypy_cache
**/.dmypy.json
**/dmypy.json
# Pyre type checker
**/.pyre
# pytype static type analyzer
**/.pytype
# Cython debug symbols
**/cython_debug
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
**/.DS_Store
**/hello.py
**/*.html
**/fast-mcp-docs.md
# Debug and test files
**/debug_*
**/test_*
**/CLAUDE.md
# ASGI/Deployment files
**/ssl
**/*.pem
**/*.key
**/*.crt
# Docker volumes
**/redis-data
# Production logs
**/logs/*.log.*
+147
View File
@@ -0,0 +1,147 @@
# OAuth Configuration for Clerk + Google
# Copy this file to .env and fill in your actual values
# =============================================================================
# AUTHENTICATION SETTINGS
# =============================================================================
# Enable/disable authentication (set to "true" to enable OAuth)
ENABLE_AUTH=false
# =============================================================================
# CLERK CONFIGURATION
# =============================================================================
# Clerk API keys (get from https://dashboard.clerk.com/)
CLERK_SECRET_KEY=sk_test_your_secret_key_here
CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
# OAuth Redirect URLs
CLERK_OAUTH_REDIRECT_URL=http://localhost:8000/auth/callback
CLERK_FRONTEND_URL=http://localhost:3000
# Clerk domain issuer (usually auto-configured)
CLERK_ISSUER=https://your-clerk-domain.clerk.accounts.dev
CLERK_DOMAIN=your-clerk-domain
# =============================================================================
# GOOGLE OAUTH SETTINGS
# =============================================================================
# Note: Google OAuth is configured through Clerk dashboard
# You need to:
# 1. Go to Clerk Dashboard > Social Connections
# 2. Enable Google provider
# 3. Add your Google OAuth client ID and secret
# 4. Configure redirect URIs in Google Console
# =============================================================================
# STRIPE CONFIGURATION (for payments/subscriptions)
# =============================================================================
STRIPE_SECRET=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# =============================================================================
# SERVER CONFIGURATION
# =============================================================================
# CORS origins (comma-separated list)
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,https://yourdomain.com
# Server settings
HOST=0.0.0.0
PORT=8000
LOG_LEVEL=info
# Base URL for the application (used for OAuth callbacks and API URLs)
BASE_URL=http://localhost:8000
# JWT Secret for MCP token generation
JWT_SECRET_KEY=your_jwt_secret_key_here
# =============================================================================
# MCP SERVER SETTINGS
# =============================================================================
# Additional MCP server configuration can go here
# For example, rate limiting, feature flags, etc.
# Example: Rate limiting
# MAX_REQUESTS_PER_MINUTE=60
# BURST_CAPACITY=20
# =============================================================================
# SEMANTIC SEARCH SETTINGS (Optional)
# =============================================================================
# Embedding provider for the semantic_search tool.
# Pick exactly one of: OpenRouter (hosted) or Local (your own server).
# --- Option A: OpenRouter (hosted, default) -----------------------------------
# Get your API key from: https://openrouter.ai/keys
# If neither this nor EMBEDDING_PROVIDER=local is set, semantic search is off.
OPENROUTER_API_KEY=sk-or-v1-your_openrouter_api_key_here
# Optional: override the OpenRouter embedding model and dimension.
# Defaults: google/gemini-embedding-001 at 3072 dims (paid on OpenRouter).
# Pick any model from https://openrouter.ai/models?modality=embedding
# and set the dimension to that model's output size — they must match.
# OPENROUTER_EMBEDDING_MODEL=google/gemini-embedding-001
# OPENROUTER_EMBEDDING_DIMENSION=3072
# --- Option B: Local OpenAI-compatible server (no API key required) ----------
# Recommended for Turkish: intfloat/multilingual-e5-large served by HuggingFace
# Text Embeddings Inference (TEI). One-line setup:
#
# docker run -p 8080:80 ghcr.io/huggingface/text-embeddings-inference:latest \
# --model-id intfloat/multilingual-e5-large
#
# Then uncomment the block below. Other model families work too — set
# EMBEDDING_PROMPT_STYLE to match: e5 / gemini / raw.
#
# EMBEDDING_PROVIDER=local
# LOCAL_EMBEDDING_BASE_URL=http://localhost:8080/v1
# LOCAL_EMBEDDING_MODEL=intfloat/multilingual-e5-large
# LOCAL_EMBEDDING_DIMENSION=1024
# EMBEDDING_PROMPT_STYLE=e5
# LOCAL_EMBEDDING_API_KEY= # most local servers ignore this
#
# Ollama fallback (if you prefer Ollama and don't need top Turkish quality):
# ollama serve && ollama pull nomic-embed-text
# EMBEDDING_PROVIDER=local
# LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1
# LOCAL_EMBEDDING_MODEL=nomic-embed-text
# LOCAL_EMBEDDING_DIMENSION=768
# EMBEDDING_PROMPT_STYLE=raw # nomic uses its own search_query/search_document
# =============================================================================
# USAGE INSTRUCTIONS
# =============================================================================
# 1. Copy this file to .env:
# cp .env.example .env
# 2. Get Clerk credentials:
# - Sign up at https://clerk.com/
# - Create a new application
# - Go to API Keys tab
# - Copy Secret Key and Publishable Key
# 3. Configure Google OAuth in Clerk:
# - In Clerk Dashboard, go to Social Connections
# - Enable Google provider
# - Get Google OAuth credentials from Google Console
# - Add redirect URI: http://localhost:8000/auth/callback
# 4. Update OAuth URLs:
# - Set CLERK_OAUTH_REDIRECT_URL to your callback URL
# - Set CLERK_FRONTEND_URL to your frontend application URL
# 5. Enable authentication:
# - Set ENABLE_AUTH=true
# 6. Test the OAuth flow:
# - Start server: uvicorn asgi_app:app --reload
# - Visit: http://localhost:8000/auth/login
# - Complete OAuth flow with Google
# - Check: http://localhost:8000/auth/user
+37
View File
@@ -0,0 +1,37 @@
name: Publish to PyPI
on:
release:
types: [published]
workflow_dispatch: # Manual trigger for testing
jobs:
pypi-publish:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/yargi-mcp
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
skip-existing: true
+57 -1
View File
@@ -1,3 +1,6 @@
# Serena
.serena/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -160,4 +163,57 @@ cython_debug/
#.idea/
.DS_Store
hello.py
*.toml
*.html
fast-mcp-docs.md
# Debug and test files
debug_*
test_*
CLAUDE.md
# ASGI/Deployment files
ssl/
*.pem
*.key
*.crt
# Docker volumes
redis-data/
# Production logs
logs/*.log.*
# Remove these lines - we need deployment files in git:
# Dockerfile - NEEDED for SaaS deployment
# fly.toml - NEEDED for Fly.io deployment
# .github/workflows/fly-deploy.yml - NEEDED for GitHub Actions
GEMINI.md
fly.toml
scripts/deploy-flyio.sh
docs/DEPLOYMENT_FLYIO.md
setup_jwt_template.py
mcp_server_main.py.backup
mcp_overhead_content.json
ANTHROPIC_TEST_README.md
extract_mcp_overhead.py
mcp_overhead_content.txt
mcp_overhead_summary.txt
run_http_server.py
run_local_test.py
# MCP overhead analysis files
mcp_overhead_*.json
mcp_overhead_*.txt
mcp_test_results_*.json
mcp_quick_test_*.json
# General text files (temporary notes, etc)
*.txt
analyze_playwright_mcp.py
measure_mcp_directly.py
playwright_mcp_overhead.json
simple_test.py
analyze_anayasa_html.py
CLAUDE.md
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+2177
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
# Use Python 3.12 slim image
FROM python:3.12-slim
# Set working directory
WORKDIR /app
# Install system dependencies (gcc/g++ kept in case any wheel falls back to source build)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy project metadata first for better Docker layer caching
COPY pyproject.toml ./
COPY README.md ./
# Copy entry points
COPY app.py ./
COPY asgi_app.py ./
COPY mcp_server_main.py ./
# Copy MCP modules and shared packages
COPY anayasa_mcp_module ./anayasa_mcp_module
COPY bddk_mcp_module ./bddk_mcp_module
COPY bedesten_mcp_module ./bedesten_mcp_module
COPY danistay_mcp_module ./danistay_mcp_module
COPY emsal_mcp_module ./emsal_mcp_module
COPY gib_mcp_module ./gib_mcp_module
COPY kik_mcp_module ./kik_mcp_module
COPY kvkk_mcp_module ./kvkk_mcp_module
COPY rekabet_mcp_module ./rekabet_mcp_module
COPY sayistay_mcp_module ./sayistay_mcp_module
COPY sigorta_tahkim_mcp_module ./sigorta_tahkim_mcp_module
COPY uyusmazlik_mcp_module ./uyusmazlik_mcp_module
COPY yargitay_mcp_module ./yargitay_mcp_module
COPY semantic_search ./semantic_search
# Install the package with ASGI extras (uvicorn + starlette)
RUN pip install --no-cache-dir -e ".[asgi]"
# Expose port
EXPOSE 8000
# Set environment variables
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5)" || exit 1
# Run the ASGI application
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
+431 -117
View File
@@ -1,164 +1,478 @@
# Yargı MCP: Türk Hukuk Kaynakları için MCP Sunucusu
> ## ✨ Profesyonel Sürüm Hazır: Yargı MCP Pro
>
> **Mevzuat ve içtihatı tek bir MCP sunucusunda birleştiren** profesyonel sürüm yayında:
>
> 👉 **https://yargi.betaspacestudio.com**
> ## 🚨 SUNUCU YENİ ADRESE TAŞINDI
>
> **Yeni Remote MCP adresi:** `https://yargimcp.surucu.dev/mcp`
>
> **Eski adres** (`https://yargimcp.fastmcp.app/mcp`) **artık kullanım dışıdır** — yalnızca taşındığını bildiren bir uyarı tool'u döner.
>
> **Yapmanız gereken:** MCP istemcinizdeki (Claude Desktop, 5ire, Google Antigravity, ChatGPT vb.) sunucu URL'sini yukarıdaki yeni adresle güncelleyin.
## Word'den UDF'ye profesyonel dönüşüm için yeni uygulamam [udfcevir.com](https://udfcevir.com) adresinde!
[![Star History Chart](https://api.star-history.com/svg?repos=saidsurucu/yargi-mcp&type=Date)](https://www.star-history.com/#saidsurucu/yargi-mcp&Date)
Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kararlar, Uyuşmazlık Mahkemesi ve Anayasa Mahkemesi - Norm Denetimi ile Bireysel Başvuru Kararları) erişimi kolaylaştıran bir [FastMCP](https://gofastmcp.com/) sunucusu oluşturur. Bu sayede, bu kaynaklardan veri arama ve belge getirme işlemleri, Model Context Protocol (MCP) destekleyen LLM (Büyük Dil Modeli) uygulamaları (örneğin Claude Desktop) ve diğer istemciler tarafından araç (tool) olarak kullanılabilir hale gelir.
Bu proje, çeşitli Türk hukuk kaynaklarına (Yargıtay, Danıştay, Emsal Kararlar, Uyuşmazlık Mahkemesi, Anayasa Mahkemesi - Norm Denetimi ile Bireysel Başvuru Kararları, Kamu İhale Kurulu Kararları, Rekabet Kurumu Kararları, Sayıştay Kararları, KVKK Kararları, BDDK Kararları, GİB Özelgeleri ve Sigorta Tahkim Komisyonu Kararları) erişimi kolaylaştıran bir [FastMCP](https://gofastmcp.com/) sunucusu oluşturur. Bu sayede, bu kaynaklardan veri arama ve belge getirme işlemleri, Model Context Protocol (MCP) destekleyen LLM (Büyük Dil Modeli) uygulamaları (örneğin Claude Desktop veya [5ire](https://5ire.app)) ve diğer istemciler tarafından araç (tool) olarak kullanılabilir hale gelir.
---
## 🚀 5 Dakikada Başla (Remote MCP)
### ✅ Kurulum Gerektirmez! Hemen Kullan!
🔗 **Remote MCP Adresi:** `https://yargimcp.surucu.dev/mcp`
> ⚠️ **Eski adres** `https://yargimcp.fastmcp.app/mcp` **artık kullanım dışıdır** — yalnızca taşındığını bildiren bir uyarı tool'u döner. Lütfen yukarıdaki yeni adresi kullanın.
### Claude Desktop ile Kullanım (Ücretli abonelik gerekir)
1. **Claude Desktop'ı açın**
2. **Settings → Connectors → Add Custom Connector**
3. **Bilgileri girin:**
- **Name:** `Yargı MCP`
- **URL:** `https://yargimcp.surucu.dev/mcp`
4. **Add** butonuna tıklayın
5. **Hemen kullanmaya başlayın!** 🎉
### Google Antigravity ile Kullanım (Lokal `uv` Kurulumu — Kopyala-Yapıştır)
> **Ön Gereksinimler:** Bilgisayarınızda **Python**, **`uv`** ([kurulum](https://docs.astral.sh/uv/getting-started/installation/)) ve **Node.js** ([indir](https://nodejs.org/en/download)) kurulu olmalı. (Node.js yalnızca aşağıdaki kurulum komutunu çalıştırmak için gerekir; MCP'yi `uvx` çalıştırır.)
Aşağıdaki **bloğun tamamını** terminale yapıştırın. Komut, Antigravity'nin okuduğu `~/.gemini/config/mcp_config.json` dosyasını sizin yerinize oluşturur/günceller (varsa diğer sunucularınız korunur):
**macOS / Linux** (Terminal):
```bash
node - <<'YARGI'
const fs=require("fs"),os=require("os"),path=require("path");
const dir=path.join(os.homedir(),".gemini","config"),file=path.join(dir,"mcp_config.json");
fs.mkdirSync(dir,{recursive:true});
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
console.log("yargi-mcp eklendi -> "+file);
YARGI
```
**Windows** (PowerShell):
```powershell
@'
const fs=require("fs"),os=require("os"),path=require("path");
const dir=path.join(os.homedir(),".gemini","config"),file=path.join(dir,"mcp_config.json");
fs.mkdirSync(dir,{recursive:true});
let cfg={};try{cfg=JSON.parse(fs.readFileSync(file,"utf8"))}catch{}
if(typeof cfg!=="object"||cfg===null||Array.isArray(cfg))cfg={};
if(typeof cfg.mcpServers!=="object"||cfg.mcpServers===null)cfg.mcpServers={};
cfg.mcpServers["yargi-mcp"]={command:"uvx",args:["yargi-mcp"]};
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
console.log("yargi-mcp eklendi -> "+file);
'@ | node -
```
Komut `yargi-mcp eklendi -> ...` çıktısını verdiğinde kurulum tamamlanmıştır. Antigravity'yi (açıksa kapatıp) yeniden başlatın; `yargi-mcp` araçları otomatik yüklenir.
> 💡 **İpucu:** Lokal kurulumda hukuk kaynaklarına erişim doğrudan bilgisayarınızda `uvx yargi-mcp` ile çalışır; uzaktan sunucuya ihtiyaç duymaz.
### Remote MCP Sorun Giderme
`https://yargimcp.surucu.dev/mcp` bir web sayfası değil, Streamable HTTP MCP uç noktasıdır. Tarayıcıda açınca veya düz `curl` ile GET isteği atınca `406 Not Acceptable` ve `Client must accept text/event-stream` benzeri bir yanıt görmek normaldir; bu, sunucunun kapalı olduğu anlamına gelmez. MCP istemcisi `Accept: application/json, text/event-stream` başlığıyla JSON-RPC isteği göndermelidir.
Hızlı sağlık kontrolü için tarayıcıda şu adresleri açabilirsiniz:
- `https://yargimcp.surucu.dev/health` — servis sağlık durumu
Claude.ai veya başka bir istemci "araç yok" gibi davranırsa:
1. Connector'ı kaldırıp yeniden ekleyin.
2. URL olarak önce `https://yargimcp.surucu.dev/mcp` deneyin; istemciniz yönlendirmeleri takip etmiyorsa `https://yargimcp.surucu.dev/mcp/` deneyin.
3. Eski `https://yargimcp.fastmcp.app/mcp` adresinin istemci ayarlarında veya önbellekte kalmadığından emin olun.
4. İstemcinin remote/Streamable HTTP MCP desteklediğini ve `text/event-stream` kabul ettiğini kontrol edin.
---
![örnek](./ornek.png)
🎯 **Temel Özellikler**
🚀 **YÜKSEK PERFORMANS OPTİMİZASYONU:** Bu MCP sunucusu **%61.8 token azaltma** ile optimize edilmiştir (8,692 token tasarrufu). Claude AI ile daha hızlı yanıt süreleri ve daha verimli etkileşim sağlar.
* Çeşitli Türk hukuk veritabanlarına programatik erişim için standart bir MCP arayüzü.
* **Kapsamlı Mahkeme Daire/Kurul Filtreleme:** 79 farklı daire/kurul filtreleme seçeneği
* **Dual/Triple API Desteği:** Her mahkeme için birden fazla API kaynağı ile maksimum kapsama
* **Kapsamlı Tarih Filtreleme:** Tüm Bedesten API araçlarında ISO 8601 formatında tarih aralığı filtreleme
* **Kesin Cümle Arama:** Tüm Bedesten API araçlarında çift tırnak ile tam cümle arama desteği
* Aşağıdaki kurumların kararlarını arama ve getirme yeteneği:
* **Yargıtay:** Detaylı kriterlerle karar arama ve karar metinlerini Markdown formatında getirme.
* **Danıştay:** Anahtar kelime bazlı ve detaylı kriterlerle karar arama; karar metinlerini Markdown formatında getirme.
* **Yargıtay:** Detaylı kriterlerle karar arama ve karar metinlerini Markdown formatında getirme. **Dual API** (Ana + Bedesten) + **52 Daire/Kurul Filtreleme** + **Tarih & Kesin Cümle Arama** (Hukuk/Ceza Daireleri, Genel Kurullar)
* **Danıştay:** Anahtar kelime bazlı ve detaylı kriterlerle karar arama; karar metinlerini Markdown formatında getirme. **Triple API** (Keyword + Detailed + Bedesten) + **27 Daire/Kurul Filtreleme** + **Tarih & Kesin Cümle Arama** (İdari Daireler, Vergi/İdare Kurulları, Askeri Yüksek İdare Mahkemesi)
* **Yerel Hukuk Mahkemeleri:** Bedesten API ile yerel hukuk mahkemesi kararlarına erişim + **Tarih & Kesin Cümle Arama**
* **İstinaf Hukuk Mahkemeleri:** Bedesten API ile istinaf mahkemesi kararlarına erişim + **Tarih & Kesin Cümle Arama**
* **Kanun Yararına Bozma (KYB):** Bedesten API ile olağanüstü kanun yoluna erişim + **Tarih & Kesin Cümle Arama**
* **Emsal (UYAP):** Detaylı kriterlerle emsal karar arama ve karar metinlerini Markdown formatında getirme.
* **Uyuşmazlık Mahkemesi:** Form tabanlı kriterlerle karar arama ve karar metinlerini (URL ile erişilen) Markdown formatında getirme.
* **Anayasa Mahkemesi (Norm Denetimi):** Kapsamlı kriterlerle norm denetimi kararlarını arama; uzun karar metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme.
* **Anayasa Mahkemesi (Bireysel Başvuru):** Kapsamlı kriterlerle bireysel başvuru "Karar Arama Raporu" oluşturma ve listedeki kararların metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme.
* **KİK (Kamu İhale Kurulu):** Çeşitli kriterlerle Kurul kararlarını arama; uzun karar metinlerini (varsayılan 5.000 karakterlik) sayfalanmış Markdown formatında getirme.
* **Rekabet Kurumu:** Çeşitli kriterlerle Kurul kararlarını arama; karar metinlerini Markdown formatında getirme.
* **Sayıştay:** 3 karar türü ile kapsamlı denetim kararlarına erişim + **8 Daire Filtreleme** + **Tarih Aralığı & İçerik Arama** (Genel Kurul yorumlayıcı kararları, Temyiz Kurulu itiraz kararları, Daire ilk derece denetim kararları)
* **KVKK (Kişisel Verilerin Korunması Kurulu):** Brave Search API ile veri koruma kararlarını arama; uzun karar metinlerini (5.000 karakterlik) sayfalanmış Markdown formatında getirme + **Türkçe Arama** + **Site Hedeflemeli Arama** (kvkk.gov.tr kararları)
* **BDDK (Bankacılık Düzenleme ve Denetleme Kurumu):** Bankacılık düzenleme kararlarını arama; karar metinlerini Markdown formatında getirme + **Optimized Search** + **"Karar Sayısı" Targeting** + **Spesifik URL Filtreleme** (bddk.org.tr/Mevzuat/DokumanGetir)
* **GİB (Gelir İdaresi Başkanlığı) Özelgeleri:** Resmi vergi özelgelerini arama (18.000+ özelge: KDV, Kurumlar, Gelir, ÖTV, Damga vb.); tam metni sayfalanmış Markdown formatında getirme + **Keyword + Özelge No + Kanun No + Tarih Aralığı** + **Otomatik ISO 8601 Dönüşümü** + **Metadata Başlık Bloğu**
* **Sigorta Tahkim Komisyonu:** Hakem Karar Dergisi (64 sayı, 2010-2025) içindeki sigorta tahkim kararlarını arama; dergi PDF'lerini Markdown formatında getirme + **Sayı İçi Karar Arama** + **Türkçe Büyük/Küçük Harf Desteği** + **Relevance Scoring**
* Karar metinlerinin daha kolay işlenebilmesi için Markdown formatına çevrilmesi.
* Claude Desktop uygulaması ile `fastmcp install` komutu kullanılarak kolay entegrasyon.
* Yargı MCP artık [5ire](https://5ire.app) gibi Claude Desktop haricindeki MCP istemcilerini de destekliyor!
---
<details>
<summary>🚀 <strong>Claude Haricindeki Modellerle Kullanmak İçin Çok Kolay Kurulum (Örnek: 5ire için)</strong></summary>
📋 **Ön Gereksinimler**
Bu bölüm, Yargı MCP aracını 5ire gibi Claude Desktop dışındaki MCP istemcileriyle kullanmak isteyenler içindir.
* **Python Sürümü:** Python 3.10 veya daha yeni bir sürümünün sisteminizde kurulu olması gerekmektedir. Python'ı [python.org](https://www.python.org/) adresinden indirebilirsiniz.
* **Paket Yöneticisi:** `pip` (Python ile birlikte gelir) veya tercihen `uv` ([Astral](https://astral.sh/uv) tarafından geliştirilen hızlı Python paket yöneticisi).
⚙️ **Kurulum Adımları (Claude Desktop Entegrasyonu Odaklı)**
Claude Desktop uygulamasına yükleme yapabilmek için öncelikle `uv` (önerilir) ve `fastmcp` komut satırı araçlarını kurmanız, ardından proje dosyalarını almanız gerekmektedir.
**1. `uv` Kurulumu (Önerilir)**
* **macOS ve Linux için:**
```bash
curl -LsSf [https://astral.sh/uv/install.sh](https://astral.sh/uv/install.sh) | sh
* **Python Kurulumu:** Sisteminizde Python 3.11 veya üzeri kurulu olmalıdır. Kurulum sırasında "**Add Python to PATH**" (Python'ı PATH'e ekle) seçeneğini işaretlemeyi unutmayın. [Buradan](https://www.python.org/downloads/) indirebilirsiniz.
* **Git Kurulumu (Windows):** Bilgisayarınıza [git](https://git-scm.com/downloads/win) yazılımını indirip kurun. "Git for Windows/x64 Setup" seçeneğini indirmelisiniz.
* **`uv` Kurulumu:**
* **Windows Kullanıcıları (PowerShell):** Bir CMD ekranı açın ve bu kodu çalıştırın: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
* **Mac/Linux Kullanıcıları (Terminal):** Bir Terminal ekranı açın ve bu kodu çalıştırın: `curl -LsSf https://astral.sh/uv/install.sh | sh`
* **Microsoft Visual C++ Redistributable (Windows):** Bazı Python paketlerinin doğru çalışması için gereklidir. [Buradan](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) indirip kurun.
* İşletim sisteminize uygun [5ire](https://5ire.app) MCP istemcisini indirip kurun.
* 5ire'ı açın. **Workspace -> Providers** menüsünden kullanmak istediğiniz LLM servisinin API anahtarını girin.
* **Tools** menüsüne girin. **+Local** veya **New** yazan butona basın.
* **Tool Key:** `yargimcp`
* **Name:** `Yargı MCP`
* **Command:**
```
* **Windows için (PowerShell kullanarak):**
```powershell
powershell -c "irm [https://astral.sh/uv/install.ps1](https://astral.sh/uv/install.ps1) | iex"
uvx yargi-mcp
```
* Kurulumdan sonra, `uv` komutunun sisteminiz tarafından tanınması için terminalinizi yeniden başlatmanız veya `PATH` ortam değişkeninizi güncellemeniz gerekebilir. `uv --version` komutu ile kurulumu doğrulayabilirsiniz.
* **Save** butonuna basarak kaydedin.
![5ire ayarları](./5ire-settings.png)
* Şimdi **Tools** altında **Yargı MCP**'yi görüyor olmalısınız. Üstüne geldiğinizde sağda çıkan butona tıklayıp etkinleştirin (yeşil ışık yanmalı).
* Artık Yargı MCP ile konuşabilirsiniz.
**2. `fastmcp` Komut Satırı Aracının (CLI) Kurulumu**
</details>
* **`uv` kullanarak (önerilir):**
```bash
uv pip install fastmcp
```
* **`pip` kullanarak (alternatif):**
```bash
pip install fastmcp
```
`fastmcp --version` komutu ile kurulumu doğrulayabilirsiniz.
---
<details>
<summary>⚙️ <strong>Claude Desktop Manuel Kurulumu</strong></summary>
**3. Proje Dosyalarını Alın**
Bu Yargı MCP sunucusunun kaynak kodlarını bilgisayarınıza indirin:
```bash
git clone https://github.com/saidsurucu/yargi-mcp.git
cd yargi-mcp
```
Bu README.md dosyasının ve `mcp_server_main.py` script'inin bulunduğu dizine `cd` komutu ile geçmiş olacaksınız.
**4. Sunucuya Özel Bağımlılıkların Bilinmesi**
Bu sunucunun (`mcp_server_main.py`) çalışması için aşağıdaki Python kütüphanelerine ihtiyacı vardır. Bu kütüphaneler `fastmcp install` sırasında `--with` parametreleriyle belirtilecektir:
```text
# requirements.txt
fastmcp
httpx
beautifulsoup4
markitdown
pydantic
aiohttp
```
(Eğer sunucuyu bağımsız olarak geliştirmek veya test etmek isterseniz, projenizin kök dizininde bir sanal ortam oluşturup örn: `uv venv` & `source .venv/bin/activate` bu bağımlılıkları `uv pip install -r requirements.txt` komutuyla kurabilirsiniz.)
🚀 **Claude Desktop Entegrasyonu (`fastmcp install` ile - Önerilen)**
Yukarıdaki kurulum adımlarını tamamladıktan sonra, bu sunucuyu Claude Desktop uygulamasına kalıcı bir araç olarak eklemenin en kolay yolu `fastmcp install` komutunu kullanmaktır:
1. Terminalde `mcp_server_main.py` dosyasının bulunduğu `yargi-mcp` dizininde olduğunuzdan emin olun.
2. Aşağıdaki komutu çalıştırın:
```bash
fastmcp install mcp_server_main.py \
--name "Yargı MCP" \
--with httpx \
--with beautifulsoup4 \
--with markitdown \
--with pydantic \
--with aiohttp
```
* `--name "Yargı MCP"`: Araç Claude Desktop'ta bu isimle görünecektir.
* `--with ...`: Sunucunun çalışması için gereken Python bağımlılıklarını belirtir.
Bu komut, `uv` kullanarak sunucunuz için izole bir Python ortamı oluşturacak, belirtilen bağımlılıkları kuracak ve aracı Claude Desktop uygulamasına kaydedecektir.
⚙️ **Claude Desktop Manuel Kurulumu (Yapılandırma Dosyası ile - Alternatif)**
1. **Claude Desktop Ayarları**'nı açın.
2. **Developer** sekmesine gidin ve **Edit Config** düğmesine tıklayın.
3. Açılan `claude_desktop_config.json` dosyasını bir metin düzenleyici ile açın.
4. `mcpServers` nesnesine aşağıdaki JSON bloğunu ekleyin:
1. **Ön Gereksinimler:** Python, `uv`, (Windows için) Microsoft Visual C++ Redistributable'ın sisteminizde kurulu olduğundan emin olun. Detaylı bilgi için yukarıdaki "5ire için Kurulum" bölümündeki ilgili adımlara bakabilirsiniz.
2. Claude Desktop **Settings -> Developer -> Edit Config**.
3. Açılan `claude_desktop_config.json` dosyasına `mcpServers` altına ekleyin:
```json
{
"mcpServers": {
// ... (varsa diğer sunucu tanımlamalarınız) ...
// ... (varsa diğer sunucularınız) ...
"Yargı MCP": {
"command": "uv",
"command": "uvx",
"args": [
"run",
"--with", "httpx",
"--with", "beautifulsoup4",
"--with", "markitdown",
"--with", "pydantic",
"--with", "aiohttp",
"--with", "fastmcp",
"fastmcp", "run",
"/TAM/PROJE/YOLUNUZ/yargi-mcp/mcp_server_main.py"
"yargi-mcp"
]
}
}
}
```
* **Önemli:** `/TAM/PROJE/YOLUNUZ/yargi-mcp/mcp_server_main.py` kısmını, `mcp_server_main.py` dosyasının sisteminizdeki **tam ve doğru yolu** ile değiştirmeyi unutmayın.
5. Claude Desktop'ı yeniden başlatın.
4. Claude Desktop'ı kapatıp yeniden başlatın.
🛠️ **Kullanılabilir Araçlar (MCP Tools)**
</details>
Bu FastMCP sunucusu aşağıdaki temel araçları sunar:
---
<details>
<summary>🌟 <strong>Gemini CLI ile Kullanım</strong></summary>
* **Yargıtay Araçları:**
* `search_yargitay_detailed(search_query: YargitayDetailedSearchRequest) -> CompactYargitaySearchResult`: Yargıtay kararlarını detaylı kriterlerle arar.
* `get_yargitay_document_markdown(document_id: str) -> YargitayDocumentMarkdown`: Belirli bir Yargıtay kararının metnini Markdown formatında getirir.
Yargı MCP'yi Gemini CLI ile kullanmak için:
* **Danıştay Araçları:**
* `search_danistay_by_keyword(search_query: DanistayKeywordSearchRequest) -> CompactDanistaySearchResult`: Danıştay kararlarını anahtar kelimelerle arar.
* `search_danistay_detailed(search_query: DanistayDetailedSearchRequest) -> CompactDanistaySearchResult`: Danıştay kararlarını detaylı kriterlerle arar.
* `get_danistay_document_markdown(document_id: str) -> DanistayDocumentMarkdown`: Belirli bir Danıştay kararının metnini Markdown formatında getirir.
1. **Ön Gereksinimler:** Python, `uv`, (Windows için) Microsoft Visual C++ Redistributable'ın sisteminizde kurulu olduğundan emin olun. Detaylı bilgi için yukarıdaki "5ire için Kurulum" bölümündeki ilgili adımlara bakabilirsiniz.
* **Emsal Karar Araçları:**
* `search_emsal_detailed_decisions(search_query: EmsalSearchRequest) -> CompactEmsalSearchResult`: Emsal (UYAP) kararlarını detaylı kriterlerle arar.
* `get_emsal_document_markdown(document_id: str) -> EmsalDocumentMarkdown`: Belirli bir Emsal kararının metnini Markdown formatında getirir.
2. **Gemini CLI ayarlarını yapılandırın:**
* **Uyuşmazlık Mahkemesi Araçları:**
* `search_uyusmazlik_decisions(search_params: UyusmazlikSearchRequest) -> UyusmazlikSearchResponse`: Uyuşmazlık Mahkemesi kararlarını çeşitli form kriterleriyle arar.
* `get_uyusmazlik_document_markdown_from_url(document_url: HttpUrl) -> UyusmazlikDocumentMarkdown`: Bir Uyuşmazlık kararını tam URL'sinden alıp Markdown formatında getirir.
Gemini CLI'ın ayar dosyasını düzenleyin:
- **macOS/Linux:** `~/.gemini/settings.json`
- **Windows:** `%USERPROFILE%\.gemini\settings.json`
* **Anayasa Mahkemesi (Norm Denetimi) Araçları:**
* `search_anayasa_norm_denetimi_decisions(search_query: AnayasaNormDenetimiSearchRequest) -> AnayasaSearchResult`: AYM Norm Denetimi kararlarını kapsamlı kriterlerle arar.
* `get_anayasa_norm_denetimi_document_markdown(document_url: str, page_number: Optional[int] = 1) -> AnayasaDocumentMarkdown`: Belirli bir AYM Norm Denetimi kararını URL'sinden alır ve 5.000 karakterlik sayfalanmış Markdown içeriğini getirir.
Aşağıdaki `mcpServers` bloğunu ekleyin:
```json
{
"theme": "Default",
"selectedAuthType": "###",
"mcpServers": {
"yargi_mcp": {
"command": "uvx",
"args": [
"yargi-mcp"
]
}
}
}
```
* **Anayasa Mahkemesi (Bireysel Başvuru) Araçları:**
* `search_anayasa_bireysel_basvuru_report(search_query: AnayasaBireyselReportSearchRequest) -> AnayasaBireyselReportSearchResult`: AYM Bireysel Başvuru "Karar Arama Raporu" oluşturur.
* `get_anayasa_bireysel_basvuru_document_markdown(document_url_path: str, page_number: Optional[int] = 1) -> AnayasaBireyselBasvuruDocumentMarkdown`: Belirli bir AYM Bireysel Başvuru kararını URL path'inden alır ve 5.000 karakterlik sayfalanmış Markdown içeriğini getirir.
**Yapılandırma açıklamaları:**
- `"yargi_mcp"`: Sunucunuz için yerel bir isim
- `"command"`: `uvx` komutu (uv'nin paket çalıştırma aracı)
- `"args"`: GitHub'dan doğrudan Yargı MCP'yi çalıştırmak için gerekli argümanlar
3. **Kullanım:**
- Gemini CLI'ı başlatın
- Yargı MCP araçları otomatik olarak kullanılabilir olacaktır
- Örnek komutlar:
- "Yargıtay'ın mülkiyet hakkı ile ilgili son kararlarını ara"
- "Danıştay'ın imar planı iptaline ilişkin kararlarını bul"
- "Anayasa Mahkemesi'nin ifade özgürlüğü kararlarını getir"
</details>
---
<details>
<summary>🧠 <strong>Semantik Arama (Opsiyonel)</strong></summary>
Yargı MCP, **semantik arama** özelliği ile kararları anlamsal olarak sıralayabilir. Opsiyoneldir; iki yoldan biri yapılandırıldığında otomatik etkinleşir:
- **Yerel** (önerilen, ücretsiz): kendi makinenizdeki OpenAI-uyumlu embedding sunucusu (HuggingFace TEI, llama.cpp, Ollama, vLLM, LM Studio…)
- **Hosted**: OpenRouter API anahtarı
### Semantik Arama Nasıl Çalışır?
1. `initial_keyword` ile Bedesten API'den 100 karar çekilir
2. `query` ile bu kararlar embedding modeli kullanılarak anlamsal olarak sıralanır
3. En alakalı kararlar döndürülür
### Önerilen Türkçe Kurulumu (Yerel — `multilingual-e5-large`)
`intfloat/multilingual-e5-large` Türkçe için kıyas ettiğimiz açık kaynak modeller arasında en iyilerinden. HuggingFace'in **Text Embeddings Inference (TEI)** sunucusuyla tek komutta ayağa kalkar ve OpenAI-uyumlu API sunar:
```bash
docker run -p 8080:80 ghcr.io/huggingface/text-embeddings-inference:latest \
--model-id intfloat/multilingual-e5-large
```
Sonra Yargı MCP'ye şu env vars'ları geçirin:
```bash
EMBEDDING_PROVIDER=local
LOCAL_EMBEDDING_BASE_URL=http://localhost:8080/v1
LOCAL_EMBEDDING_MODEL=intfloat/multilingual-e5-large
LOCAL_EMBEDDING_DIMENSION=1024
EMBEDDING_PROMPT_STYLE=e5
```
> ⚠️ **Önemli:** `EMBEDDING_PROMPT_STYLE=e5` şart — e5 modelleri `query:` / `passage:` öneki bekleyecek şekilde eğitilmiştir; yanlış önek sessizce kaliteyi düşürür.
#### Claude Desktop örneği (yerel TEI)
```json
{
"mcpServers": {
"Yargı MCP": {
"command": "uvx",
"args": ["yargi-mcp"],
"env": {
"EMBEDDING_PROVIDER": "local",
"LOCAL_EMBEDDING_BASE_URL": "http://localhost:8080/v1",
"LOCAL_EMBEDDING_MODEL": "intfloat/multilingual-e5-large",
"LOCAL_EMBEDDING_DIMENSION": "1024",
"EMBEDDING_PROMPT_STYLE": "e5"
}
}
}
}
```
### Alternatif 1: Ollama (yerel, daha hafif kurulum)
```bash
ollama serve
ollama pull nomic-embed-text # 768 dim, İngilizce ağırlıklı
```
```bash
EMBEDDING_PROVIDER=local
LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1
LOCAL_EMBEDDING_MODEL=nomic-embed-text
LOCAL_EMBEDDING_DIMENSION=768
EMBEDDING_PROMPT_STYLE=raw
```
> Ollama kütüphanesinde `multilingual-e5-large` doğrudan yok; Türkçe için TEI yolu daha doğru sonuç verir.
### Alternatif 2: OpenRouter (hosted)
```bash
OPENROUTER_API_KEY=sk-or-v1-xxx...
# İsteğe bağlı — varsayılan google/gemini-embedding-001 (3072 dim, ÜCRETLİ)
# OPENROUTER_EMBEDDING_MODEL=...
# OPENROUTER_EMBEDDING_DIMENSION=...
# EMBEDDING_PROMPT_STYLE=gemini # varsayılan
```
API anahtarınızı [openrouter.ai/keys](https://openrouter.ai/keys) adresinden alın. Varsayılan model `google/gemini-embedding-001` artık ücretli — ücretsiz bir model seçerseniz `OPENROUTER_EMBEDDING_MODEL`, `OPENROUTER_EMBEDDING_DIMENSION` ve uygun `EMBEDDING_PROMPT_STYLE` değerlerini birlikte ayarlayın.
### Yapılandırma Referansı
| Env Var | Açıklama | Örnek |
|---|---|---|
| `EMBEDDING_PROVIDER` | `local` ise yerel sunucu, boş ise OpenRouter | `local` |
| `EMBEDDING_PROMPT_STYLE` | `gemini` / `e5` / `raw` — modelin beklediği önek | `e5` |
| `LOCAL_EMBEDDING_BASE_URL` | Yerel sunucunun OpenAI-uyumlu URL'i | `http://localhost:8080/v1` |
| `LOCAL_EMBEDDING_MODEL` | Model adı | `intfloat/multilingual-e5-large` |
| `LOCAL_EMBEDDING_DIMENSION` | Modelin çıktı boyutu (mutlaka eşleşmeli) | `1024` |
| `OPENROUTER_API_KEY` | OpenRouter anahtarı (sadece hosted için) | `sk-or-v1-…` |
| `OPENROUTER_EMBEDDING_MODEL` | OpenRouter model id'si | `google/gemini-embedding-001` |
| `OPENROUTER_EMBEDDING_DIMENSION` | OpenRouter modelinin çıktı boyutu | `3072` |
> 💡 **Not:** Hiçbir embedding sağlayıcı yapılandırılmazsa semantik arama aracı görünmez, diğer 24 araç normal şekilde çalışır.
</details>
<details>
<summary>🛠️ <strong>Kullanılabilir Araçlar (MCP Tools)</strong></summary>
Bu FastMCP sunucusu **26 aktif MCP aracı** + **1 opsiyonel semantik arama aracı** sunar (token verimliliği için optimize edilmiş):
### **Yargıtay Araçları (Birleşik Bedesten API - Token Optimized)**
*Not: Yargıtay araçları token verimliliği için birleşik Bedesten API'ye entegre edilmiştir*
### **Danıştay Araçları (Birleşik Bedesten API - Token Optimized)**
*Not: Danıştay araçları token verimliliği için birleşik Bedesten API'ye entegre edilmiştir*
### **Birleşik Bedesten API Araçları (5 Mahkeme) - 🚀 TOKEN OPTİMİZE**
1. `search_bedesten_unified(phrase, court_types, birimAdi, kararTarihiStart, kararTarihiEnd, ...)`: **5 mahkeme türünü** birleşik arama (Yargıtay, Danıştay, Yerel Hukuk, İstinaf Hukuk, KYB) + **79 daire filtreleme** + **Tarih & Kesin Cümle Arama**
2. `get_bedesten_document_markdown(documentId: str)`: Bedesten API'den herhangi bir belgeyi Markdown formatında getirir (HTML/PDF → Markdown)
### **Emsal Karar Araçları (UYAP)**
3. `search_emsal_detailed_decisions(keyword, ...)`: Emsal (UYAP) kararlarını detaylı kriterlerle arar.
4. `get_emsal_document_markdown(id: str)`: Belirli bir Emsal kararının metnini Markdown formatında getirir.
### **Uyuşmazlık Mahkemesi Araçları**
5. `search_uyusmazlik_decisions(icerik, ...)`: Uyuşmazlık Mahkemesi kararlarını çeşitli form kriterleriyle arar.
6. `get_uyusmazlik_document_markdown_from_url(document_url)`: Bir Uyuşmazlık kararını tam URL'sinden alıp Markdown formatında getirir.
### **Anayasa Mahkemesi Araçları (Birleşik API) - 🚀 TOKEN OPTİMİZE**
7. `search_anayasa_unified(decision_type, keywords_all, ...)`: AYM kararlarını birleşik arama (Norm Denetimi + Bireysel Başvuru) - **4 araç → 2 araç optimizasyonu**
8. `get_anayasa_document_unified(document_url, page_number)`: AYM kararlarını birleşik belge getirme - **sayfalanmış Markdown** içeriği
### **KİK (Kamu İhale Kurulu) Araçları**
9. `search_kik_v2_decisions(decision_type, karar_metni, karar_no, basvuran, idare_adi, baslangic_tarihi, bitis_tarihi)`: KİK v2 API ile uyuşmazlık, düzenleyici ve mahkeme kararlarını arar.
10. `get_kik_v2_document_markdown(gundemMaddesiId)`: Arama sonucundaki `gundemMaddesiId` ile KİK karar metnini Markdown formatında getirir.
### **Rekabet Kurumu Araçları**
    * `search_rekabet_kurumu_decisions(KararTuru: Literal[...], ...) -> RekabetSearchResult`: Rekabet Kurumu kararlarını arar. `KararTuru` için kullanıcı dostu isimler kullanılır (örn: "Birleşme ve Devralma").
    * `get_rekabet_kurumu_document(karar_id: str, page_number: Optional[int] = 1) -> RekabetDocument`: Belirli bir Rekabet Kurumu kararını `karar_id` ile alır. Kararın PDF formatındaki orijinalinden istenen sayfayı ayıklar ve Markdown formatında döndürür.
---
* **Sayıştay Araçları (Birleşik API, 3 Karar Türü + 8 Daire Filtreleme):**
* `search_sayistay_unified(decision_type, start, length, ...)`: `genel_kurul`, `temyiz_kurulu` veya `daire` kararlarını tek araçla arar. `length` 1-100 aralığındadır.
* `get_sayistay_document_unified(decision_id, decision_type)`: Birleşik arama sonucundaki karar ID'si ve karar türüyle tam metni Markdown formatında getirir.
* **KVKK Araçları (Brave Search API + Türkçe Arama):**
* `search_kvkk_decisions(keywords, page)`: KVKK (Kişisel Verilerin Korunması Kurulu) kararlarını Brave Search API ile arar. **Türkçe arama** + **Site hedeflemeli** (`site:kvkk.gov.tr "karar özeti"`) + **Sayfalama desteği**. Sonuç sayısı sunucuda 10 olarak sabitlenmiştir.
* `get_kvkk_document_markdown(decision_url: str, page_number: Optional[int] = 1)`: KVKK kararının tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa)
### BDDK Araçları
* `search_bddk_decisions(keywords, page)`: BDDK (Bankacılık Düzenleme ve Denetleme Kurumu) kararlarını arar. **"Karar Sayısı" targeting** + **Spesifik URL filtreleme** (`bddk.org.tr/Mevzuat/DokumanGetir`) + **Optimized search**
* `get_bddk_document_markdown(document_id: str, page_number: Optional[int] = 1)`: BDDK kararının tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa)
### GİB (Gelir İdaresi Başkanlığı) Özelge Araçları (Resmi GİB JSON API)
* `search_gib_ozelge(keywords, ozelgeNo, kanunNo, ozelgeStartDate, ozelgeEndDate, page, pageSize)`: GİB özelgelerini (Türk Gelir İdaresi Başkanlığı vergi özelgeleri) arar — **18.000+ özelge** (KDV, Kurumlar, Gelir, ÖTV, Damga, VUK vb.). **Keyword + Özelge No + Kanun No + Tarih Aralığı** + **Otomatik ISO 8601 Dönüşümü** (`YYYY-MM-DD` girdileri otomatik olarak full ISO 8601'e çevrilir)
* `get_gib_ozelge_document_markdown(ozelge_id: int, page_number: int = 1)`: Belirli bir özelgenin tam metnini **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa) + **Metadata başlık bloğu** (Başlık, Sayı, Tarih, Kanun, Kaynak URL)
### Sigorta Tahkim Komisyonu Araçları (Tavily Search API + PDF)
* `search_sigorta_tahkim_decisions(keywords, page)`: Sigorta Tahkim Komisyonu kararlarını Tavily Search API ile arar. **Site hedeflemeli** (`sigortatahkim.org`) + **Sayfalama desteği**. Sonuç sayısı sunucuda 10 olarak sabitlenmiştir.
* `get_sigorta_tahkim_document_markdown(issue_number: str, page_number: int)`: Hakem Karar Dergisi sayısının PDF'ini indirip **sayfalanmış Markdown** formatında getirir (5.000 karakterlik sayfa). 64 sayı (2010-2025)
* `search_within_sigorta_tahkim_issue(issue_number: str, keyword: str, max_results: int)`: Belirli bir dergi sayısı içindeki kararları anahtar kelime ile arar. **Türkçe İ/I desteği** + **Relevance scoring** + **Excerpt** ile sonuç
### Yardımcı ve Uyumluluk Araçları
* `check_government_servers_health()`: Yargı kaynaklarının erişilebilirliğini kontrol eder.
* `search(query)`: ChatGPT Deep Research uyumluluğu için Bedesten destekli kaynaklarda arama yapar.
* `fetch(id)`: ChatGPT Deep Research uyumluluğu için tek bir Bedesten belge ID'sinin tam metnini getirir.
</details>
---
<details>
<summary>📊 <strong>Kapsamlı İstatistikler & Optimizasyon Başarıları</strong></summary>
🚀 **TOKEN OPTİMİZASYON BAŞARISI:**
- **%61.8 Token Azaltma:** 14,061 → 5,369 tokens (8,692 token tasarrufu)
- **Hedef Aşım:** 10,000 token hedefini 4,631 token aştık
- **Daha Hızlı Yanıt:** Claude AI ile optimize edilmiş etkileşim
- **Korunan İşlevsellik:** %100 özellik desteği devam ediyor
**GENEL İSTATİSTİKLER:**
- **Toplam Mahkeme/Kurum:** 15 farklı hukuki kurum (GİB Özelgeleri ve Sigorta Tahkim Komisyonu dahil)
- **Toplam MCP Tool:** 26 aktif araç + 1 opsiyonel semantik arama aracı
- **Daire/Kurul Filtreleme:** 87 farklı seçenek (52 Yargıtay + 27 Danıştay + 8 Sayıştay)
- **Tarih Filtreleme:** Birleşik Bedesten API aracında ISO 8601 formatında tam tarih aralığı desteği
- **Kesin Cümle Arama:** Birleşik Bedesten API aracında çift tırnak ile tam cümle arama (`"\"mülkiyet kararı\""` formatı)
- **Birleşik API:** 10 ayrı Bedesten aracı → 2 birleşik araç (search_bedesten_unified + get_bedesten_document_markdown)
- **API Kaynağı:** Dual/Triple API desteği ile maksimum kapsama
- **Tam Türk Adalet Sistemi:** Yerel mahkemelerden en yüksek mahkemelere kadar
**🏛️ Desteklenen Mahkeme Hiyerarşisi:**
```
Yerel Mahkemeler → İstinaf → Yargıtay/Danıştay → Anayasa Mahkemesi
↓ ↓ ↓ ↓
Bedesten API Bedesten API Dual/Triple API Norm+Bireysel API
+ Tarih + Kesin + Tarih + Kesin + Daire + Tarih + Gelişmiş
Cümle Arama Cümle Arama + Kesin Cümle Arama
```
**⚖️ Kapsamlı Filtreleme Özellikleri:**
- **Daire Filtreleme:** 79 seçenek (52 Yargıtay + 27 Danıştay)
- **Yargıtay:** 52 seçenek (1-23 Hukuk, 1-23 Ceza, Genel Kurullar, Başkanlar Kurulu)
- **Danıştay:** 27 seçenek (1-17 Daireler, İdare/Vergi Kurulları, Askeri Mahkemeler)
- **Tarih Filtreleme:** 5 Bedesten API aracında ISO 8601 formatı (YYYY-MM-DDTHH:MM:SS.000Z)
- Tek tarih, tarih aralığı, tek taraflı filtreleme desteği
- Yargıtay, Danıştay, Yerel Hukuk, İstinaf Hukuk, KYB kararları
- **Kesin Cümle Arama:** 5 Bedesten API aracında çift tırnak formatı
- Normal arama: `"mülkiyet kararı"` (kelimeler ayrı ayrı)
- Kesin arama: `"\"mülkiyet kararı\""` (tam cümle olarak)
- Daha kesin sonuçlar için hukuki terimler ve kavramlar
**🔧 OPTİMİZASYON DETAYLARI:**
- **Anayasa Mahkemesi:** 4 araç → 2 birleşik araç (search_anayasa_unified + get_anayasa_document_unified)
- **Yargıtay & Danıştay:** Ana API araçları birleşik Bedesten API'ye entegre edildi
- **Sayıştay:** 6 araç → 2 birleşik araç (search_sayistay_unified + get_sayistay_document_unified)
- **Parameter Optimizasyonu:** pageSize parametreleri optimize edildi
- **Açıklama Optimizasyonu:** Uzun açıklamalar kısaltıldı (örn: KIK karar_metni)
</details>
---
<details>
<summary>🌐 <strong>Web Service / ASGI Deployment</strong></summary>
Yargı MCP artık web servisi olarak da çalıştırılabilir! ASGI desteği sayesinde:
- **Web API olarak erişim**: HTTP endpoint'leri üzerinden MCP araçlarına erişim
- **Cloud deployment**: Heroku, Railway, Google Cloud Run, AWS Lambda desteği
- **Docker desteği**: Production-ready Docker container
- **FastAPI entegrasyonu**: REST API ve interaktif dokümantasyon
**Hızlı başlangıç:**
```bash
# ASGI dependencies yükle
pip install yargi-mcp[asgi]
# Web servisi olarak başlat
python run_asgi.py
# veya
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
```
Detaylı deployment rehberi için: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
</details>
---
📜 **Lisans**
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""Entry point for yargi-mcp package."""
from mcp_server_main import main
if __name__ == "__main__":
main()
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Analyze KİK v2 hash generation by examining JavaScript code patterns
and trying to reverse engineer the hash generation logic.
"""
import asyncio
import json
import hashlib
import hmac
import base64
from fastmcp import Client
from mcp_server_main import app
def analyze_webpack_hash_patterns():
"""
Analyze the webpack JavaScript code you provided to find hash generation patterns
"""
print("🔍 Analyzing webpack hash generation patterns...")
# From the JavaScript code, I can see several hash/ID generation patterns:
hash_patterns = {
# Webpack chunk system hashes (from the JS code)
"webpack_chunks": {
315: "d9a9486a4f5ba326",
531: "cd8fb385c88033ae",
671: "04c48b287646627a",
856: "682c9a7b87351f90",
1017: "9de022378fc275f6",
# ... many more from the __webpack_require__.u function
},
# Symbol generation from Zone.js
"zone_symbols": [
"__zone_symbol__",
"__Zone_symbol_prefix",
"Zone.__symbol__"
],
# Angular module federation patterns
"module_federation": [
"__webpack_modules__",
"__webpack_module_cache__",
"__webpack_require__"
]
}
# The target hash format
target_hash = "42f9bcd59e0dfbca36dec9accf5686c7a92aa97724cd8fc3550beb84b80409da"
print(f"🎯 Target hash: {target_hash}")
print(f" Length: {len(target_hash)} characters")
print(f" Format: {'SHA256' if len(target_hash) == 64 else 'Other'} (64 chars = SHA256)")
return hash_patterns
def test_webpack_style_hashing(data_dict):
"""Test webpack-style hash generation methods"""
hashes = {}
for key, value in data_dict.items():
test_string = str(value)
# Try various webpack-style hash methods
hashes[f"webpack_md5_{key}"] = hashlib.md5(test_string.encode()).hexdigest()
hashes[f"webpack_sha1_{key}"] = hashlib.sha1(test_string.encode()).hexdigest()
hashes[f"webpack_sha256_{key}"] = hashlib.sha256(test_string.encode()).hexdigest()
# Try with various prefixes/suffixes (common in webpack)
prefixed = f"__webpack__{test_string}"
hashes[f"webpack_prefixed_sha256_{key}"] = hashlib.sha256(prefixed.encode()).hexdigest()
# Try with module federation style
module_style = f"shell:{test_string}"
hashes[f"module_fed_sha256_{key}"] = hashlib.sha256(module_style.encode()).hexdigest()
# Try JSON stringified
json_style = json.dumps({"id": value, "type": "decision"}, separators=(',', ':'))
hashes[f"json_sha256_{key}"] = hashlib.sha256(json_style.encode()).hexdigest()
# Try with timestamp or sequence
with_seq = f"{test_string}_0"
hashes[f"seq_sha256_{key}"] = hashlib.sha256(with_seq.encode()).hexdigest()
return hashes
def test_angular_routing_hashes(data_dict):
"""Test Angular routing/state management hash generation"""
hashes = {}
for key, value in data_dict.items():
# Angular often uses route parameters for hash generation
route_style = f"/kurul-kararlari/{value}"
hashes[f"route_sha256_{key}"] = hashlib.sha256(route_style.encode()).hexdigest()
# Component state style
state_style = f"KurulKararGoster_{value}"
hashes[f"state_sha256_{key}"] = hashlib.sha256(state_style.encode()).hexdigest()
# Angular module style
module_style = f"kik.kurul.karar.{value}"
hashes[f"module_sha256_{key}"] = hashlib.sha256(module_style.encode()).hexdigest()
return hashes
def test_base64_encoding_variants(data_dict):
"""Test various base64 and encoding variants"""
hashes = {}
for key, value in data_dict.items():
test_string = str(value)
# Try base64 encoding then hashing
b64_encoded = base64.b64encode(test_string.encode()).decode()
hashes[f"b64_sha256_{key}"] = hashlib.sha256(b64_encoded.encode()).hexdigest()
# Try URL-safe base64
b64_url = base64.urlsafe_b64encode(test_string.encode()).decode()
hashes[f"b64url_sha256_{key}"] = hashlib.sha256(b64_url.encode()).hexdigest()
# Try hex encoding
hex_encoded = test_string.encode().hex()
hashes[f"hex_sha256_{key}"] = hashlib.sha256(hex_encoded.encode()).hexdigest()
return hashes
async def test_hash_generation_comprehensive():
print("🔐 Comprehensive KİK document hash generation analysis...")
print("=" * 70)
# First analyze the webpack patterns
webpack_patterns = analyze_webpack_hash_patterns()
client = Client(app)
async with client:
print("✅ MCP client connected")
# Get sample decisions
print("\n📊 Getting sample decisions for hash analysis...")
search_result = await client.call_tool("search_kik_v2_decisions", {
"decision_type": "uyusmazlik",
"karar_metni": "2024"
})
if hasattr(search_result, 'content') and search_result.content:
search_data = json.loads(search_result.content[0].text)
decisions = search_data.get('decisions', [])
if decisions:
print(f"✅ Found {len(decisions)} decisions")
# Test with first decision
sample_decision = decisions[0]
print(f"\n📋 Sample decision for hash analysis:")
for key, value in sample_decision.items():
print(f" {key}: {value}")
target_hash = "42f9bcd59e0dfbca36dec9accf5686c7a92aa97724cd8fc3550beb84b80409da"
print(f"\n🎯 Target hash to match: {target_hash}")
all_hashes = {}
# Test different hash generation methods
print(f"\n🔨 Testing webpack-style hashing...")
webpack_hashes = test_webpack_style_hashing(sample_decision)
all_hashes.update(webpack_hashes)
print(f"🔨 Testing Angular routing hashes...")
angular_hashes = test_angular_routing_hashes(sample_decision)
all_hashes.update(angular_hashes)
print(f"🔨 Testing base64 encoding variants...")
b64_hashes = test_base64_encoding_variants(sample_decision)
all_hashes.update(b64_hashes)
# Check for matches
print(f"\n🎯 Checking for hash matches...")
matches_found = []
partial_matches = []
for hash_name, hash_value in all_hashes.items():
if hash_value == target_hash:
matches_found.append((hash_name, hash_value))
print(f" 🎉 EXACT MATCH FOUND: {hash_name}")
elif hash_value[:8] == target_hash[:8]: # First 8 chars match
partial_matches.append((hash_name, hash_value))
print(f" 🔍 Partial match (first 8): {hash_name} -> {hash_value[:16]}...")
elif hash_value[-8:] == target_hash[-8:]: # Last 8 chars match
partial_matches.append((hash_name, hash_value))
print(f" 🔍 Partial match (last 8): {hash_name} -> ...{hash_value[-16:]}")
if not matches_found and not partial_matches:
print(f" ❌ No matches found")
print(f"\n📝 Sample generated hashes (first 10):")
for i, (hash_name, hash_value) in enumerate(list(all_hashes.items())[:10]):
print(f" {hash_name}: {hash_value}")
# Try combinations with other decisions
print(f"\n🔄 Testing hash combinations with multiple decisions...")
if len(decisions) > 1:
for i, decision in enumerate(decisions[1:3]): # Test 2 more
print(f"\n Testing decision {i+2}: {decision.get('kararNo')}")
decision_hashes = test_webpack_style_hashing(decision)
for hash_name, hash_value in decision_hashes.items():
if hash_value == target_hash:
print(f" 🎉 MATCH FOUND in decision {i+2}: {hash_name}")
matches_found.append((f"decision_{i+2}_{hash_name}", hash_value))
# Try composite hashes (combining multiple fields)
print(f"\n🔗 Testing composite hash generation...")
composite_tests = [
f"{sample_decision.get('gundemMaddesiId')}_{sample_decision.get('kararNo')}",
f"{sample_decision.get('kararNo')}_{sample_decision.get('kararTarihi')}",
f"uyusmazlik_{sample_decision.get('gundemMaddesiId')}_{sample_decision.get('kararTarihi')}",
json.dumps(sample_decision, separators=(',', ':'), sort_keys=True),
f"{sample_decision.get('basvuran')}_{sample_decision.get('gundemMaddesiId')}",
]
for i, composite_str in enumerate(composite_tests):
composite_hash = hashlib.sha256(composite_str.encode()).hexdigest()
if composite_hash == target_hash:
print(f" 🎉 COMPOSITE MATCH FOUND: test_{i} -> {composite_str[:50]}...")
matches_found.append((f"composite_{i}", composite_hash))
print(f"\n🎯 Hash analysis completed!")
print(f" Total matches found: {len(matches_found)}")
print(f" Partial matches: {len(partial_matches)}")
else:
print("❌ No decisions found")
else:
print("❌ Search failed")
print("=" * 70)
if __name__ == "__main__":
asyncio.run(test_hash_generation_comprehensive())
+25 -25
View File
@@ -1,14 +1,14 @@
# anayasa_mcp_module/bireysel_client.py
# This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
import asyncio
import httpx
from bs4 import BeautifulSoup, Tag
from typing import Dict, Any, List, Optional, Tuple
import logging
import html
import re
import tempfile
import os
import io
from urllib.parse import urlencode, urljoin, quote
from markitdown import MarkItDown
import math # For math.ceil for pagination
@@ -100,11 +100,11 @@ class AnayasaBireyselBasvuruApiClient:
for decision_div in decision_divs:
title_tag = decision_div.find("h4")
title_text = title_tag.get_text(strip=True) if title_tag and title_tag.strong else (title_tag.get_text(strip=True) if title_tag else None)
title_text = title_tag.get_text(strip=True) if title_tag and title_tag.strong else (title_tag.get_text(strip=True) if title_tag else "")
alti_cizili_div = decision_div.find("div", class_="AltiCizili")
ref_no, dec_type, body, app_date, dec_date, url_path = None, None, None, None, None, None
ref_no, dec_type, body, app_date, dec_date, url_path = "", "", "", "", "", ""
if alti_cizili_div:
link_tag = alti_cizili_div.find("a", href=True)
if link_tag:
@@ -125,14 +125,14 @@ class AnayasaBireyselBasvuruApiClient:
ref_no = parts[current_idx]
current_idx += 1
dec_type = parts[current_idx] if len(parts) > current_idx else None
dec_type = parts[current_idx] if len(parts) > current_idx else ""
current_idx += 1
body = parts[current_idx] if len(parts) > current_idx else None
body = parts[current_idx] if len(parts) > current_idx else ""
current_idx += 1
app_date_raw = parts[current_idx] if len(parts) > current_idx else None
app_date_raw = parts[current_idx] if len(parts) > current_idx else ""
current_idx += 1
dec_date_raw = parts[current_idx] if len(parts) > current_idx else None
dec_date_raw = parts[current_idx] if len(parts) > current_idx else ""
if app_date_raw and "Başvuru Tarihi :" in app_date_raw:
app_date = app_date_raw.replace("Başvuru Tarihi :", "").strip()
@@ -149,7 +149,7 @@ class AnayasaBireyselBasvuruApiClient:
subject_div = decision_div.find(lambda tag: tag.name == 'div' and not tag.has_attr('class') and tag.get_text(strip=True).startswith("BAŞVURU KONUSU :"))
subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else None
subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else ""
details_list: List[AnayasaBireyselReportDecisionDetail] = []
karar_detaylari_div = decision_div.find_next_sibling("div", id="KararDetaylari") # Corrected: was KararDetaylari
@@ -160,13 +160,13 @@ class AnayasaBireyselBasvuruApiClient:
cells = row.find_all("td")
if len(cells) == 4: # Hak, Müdahale İddiası, Sonuç, Giderim
details_list.append(AnayasaBireyselReportDecisionDetail(
hak=cells[0].get_text(strip=True) or None,
mudahale_iddiasi=cells[1].get_text(strip=True) or None,
sonuc=cells[2].get_text(strip=True) or None,
giderim=cells[3].get_text(strip=True) or None,
hak=cells[0].get_text(strip=True) or "",
mudahale_iddiasi=cells[1].get_text(strip=True) or "",
sonuc=cells[2].get_text(strip=True) or "",
giderim=cells[3].get_text(strip=True) or "",
))
full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else None
full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else ""
processed_decisions.append(AnayasaBireyselReportDecisionSummary(
title=title_text,
@@ -230,23 +230,23 @@ class AnayasaBireyselBasvuruApiClient:
html_input_for_markdown = processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
# Ensure the content is wrapped in basic HTML structure if it's not already
if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
tmp_file.write(f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>")
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
else:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
html_content = html_input_for_markdown
conversion_result = md_converter.convert(temp_file_path)
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_content.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
conversion_result = md_converter.convert(html_stream)
markdown_text = conversion_result.text_content
except Exception as e:
logger.error(f"AnayasaBireyselBasvuruApiClient: MarkItDown conversion error: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(
@@ -303,7 +303,7 @@ class AnayasaBireyselBasvuruApiClient:
elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value
elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value
full_markdown_content = self._convert_html_to_markdown_bireysel(html_content_from_api)
full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_bireysel, html_content_from_api)
if not full_markdown_content:
return AnayasaBireyselBasvuruDocumentMarkdown(
+36 -33
View File
@@ -1,14 +1,14 @@
# anayasa_mcp_module/client.py
# This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
import asyncio
import httpx
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Tuple
import logging
import html
import re
import tempfile
import os
import io
from urllib.parse import urlencode, urljoin, quote
from markitdown import MarkItDown
import math # For math.ceil for pagination
@@ -51,36 +51,43 @@ class AnayasaMahkemesiApiClient:
for kw in params.keywords_any: query_params.append(("HerhangiBirKelimeAra[]", kw))
if params.keywords_exclude:
for kw in params.keywords_exclude: query_params.append(("BulunmayanKelimeAra[]", kw))
if params.period and params.period.value: query_params.append(("Donemler_id", params.period.value))
if params.period and params.period and params.period != "ALL": query_params.append(("Donemler_id", params.period))
if params.case_number_esas: query_params.append(("EsasNo", params.case_number_esas))
if params.decision_number_karar: query_params.append(("KararNo", params.decision_number_karar))
if params.first_review_date_start: query_params.append(("IlkIncelemeTarihiIlk", params.first_review_date_start))
if params.first_review_date_end: query_params.append(("IlkIncelemeTarihiSon", params.first_review_date_end))
if params.decision_date_start: query_params.append(("KararTarihiIlk", params.decision_date_start))
if params.decision_date_end: query_params.append(("KararTarihiSon", params.decision_date_end))
if params.application_type and params.application_type.value: query_params.append(("BasvuruTurler_id", params.application_type.value))
if params.application_type and params.application_type and params.application_type != "ALL": query_params.append(("BasvuruTurler_id", params.application_type))
if params.applicant_general_name: query_params.append(("BasvuranGeneller_id", params.applicant_general_name))
if params.applicant_specific_name: query_params.append(("BasvuranOzeller_id", params.applicant_specific_name))
if params.attending_members_names:
for name in params.attending_members_names: query_params.append(("Uyeler_id[]", name))
if params.rapporteur_name: query_params.append(("Raportorler_id", params.rapporteur_name))
if params.norm_type and params.norm_type.value: query_params.append(("NormunTurler_id", params.norm_type.value))
if params.norm_type and params.norm_type and params.norm_type != "ALL": query_params.append(("NormunTurler_id", params.norm_type))
if params.norm_id_or_name: query_params.append(("NormunNumarasiAdlar_id", params.norm_id_or_name))
if params.norm_article: query_params.append(("NormunMaddeNumarasi", params.norm_article))
if params.review_outcomes:
for outcome_enum_val in params.review_outcomes:
if outcome_enum_val.value: query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_enum_val.value))
if params.reason_for_final_outcome and params.reason_for_final_outcome.value:
query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome.value))
for outcome_val in params.review_outcomes:
if outcome_val and outcome_val != "ALL": query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_val))
if params.reason_for_final_outcome and params.reason_for_final_outcome and params.reason_for_final_outcome != "ALL":
query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome))
if params.basis_constitution_article_numbers:
for article_no in params.basis_constitution_article_numbers: query_params.append(("DayanakHukmu[]", article_no))
if params.official_gazette_date_start: query_params.append(("ResmiGazeteTarihiIlk", params.official_gazette_date_start))
if params.official_gazette_date_end: query_params.append(("ResmiGazeteTarihiSon", params.official_gazette_date_end))
if params.official_gazette_number_start: query_params.append(("ResmiGazeteSayisiIlk", params.official_gazette_number_start))
if params.official_gazette_number_end: query_params.append(("ResmiGazeteSayisiSon", params.official_gazette_number_end))
if params.has_press_release and params.has_press_release.value: query_params.append(("BasinDuyurusu", params.has_press_release.value))
if params.has_dissenting_opinion and params.has_dissenting_opinion.value: query_params.append(("KarsiOy", params.has_dissenting_opinion.value))
if params.has_different_reasoning and params.has_different_reasoning.value: query_params.append(("FarkliGerekce", params.has_different_reasoning.value))
if params.has_press_release and params.has_press_release and params.has_press_release != "ALL": query_params.append(("BasinDuyurusu", params.has_press_release))
if params.has_dissenting_opinion and params.has_dissenting_opinion and params.has_dissenting_opinion != "ALL": query_params.append(("KarsiOy", params.has_dissenting_opinion))
if params.has_different_reasoning and params.has_different_reasoning and params.has_different_reasoning != "ALL": query_params.append(("FarkliGerekce", params.has_different_reasoning))
# Add pagination and sorting parameters as query params instead of URL path
if params.results_per_page and params.results_per_page != 10:
query_params.append(("SatirSayisi", str(params.results_per_page)))
if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi":
query_params.append(("Siralama", params.sort_by_criteria))
if params.page_to_fetch and params.page_to_fetch > 1:
query_params.append(("page", str(params.page_to_fetch)))
@@ -90,16 +97,8 @@ class AnayasaMahkemesiApiClient:
self,
params: AnayasaNormDenetimiSearchRequest
) -> AnayasaSearchResult:
path_segments = []
if params.results_per_page and params.results_per_page != 10: # Default is 10
path_segments.append(f"SatirSayisi/{params.results_per_page}")
if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi": # Default is KararTarihi
# Ensure correct quoting for criteria that might have Turkish chars or spaces
path_segments.append(f"Siralama/{quote(params.sort_by_criteria)}")
path_segments.append(self.SEARCH_PATH_SEGMENT)
request_path = "/" + "/".join(path_segments)
# Use simple /Ara endpoint - the complex path structure seems to cause 404s
request_path = f"/{self.SEARCH_PATH_SEGMENT}"
final_query_params = self._build_search_query_params_for_aym(params)
logger.info(f"AnayasaMahkemesiApiClient: Performing Norm Denetimi search. Path: {request_path}, Params: {final_query_params}")
@@ -222,24 +221,23 @@ class AnayasaMahkemesiApiClient:
html_input_for_markdown = str(body_tag) if body_tag else processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
# Ensure the content is wrapped in basic HTML structure if it's not already
if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
tmp_file.write(f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>")
html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
else:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
html_content = html_input_for_markdown
conversion_result = md_converter.convert(temp_file_path)
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_content.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
conversion_result = md_converter.convert(html_stream)
markdown_text = conversion_result.text_content
except Exception as e:
logger.error(f"AnayasaMahkemesiApiClient: MarkItDown conversion error: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(
@@ -277,6 +275,11 @@ class AnayasaMahkemesiApiClient:
if not karar_metni_div: # Fallback if not in KararMetni
karar_metni_div = soup.find("div", class_="WordSection1")
# Initialize with empty string defaults
decision_ek_no_from_page = ""
decision_date_from_page = ""
official_gazette_from_page = ""
if karar_metni_div:
# Attempt to find E.K. No (Esas No, Karar No)
# Norm Denetimi pages often have this in bold <p> tags directly or in the WordSection1
@@ -307,7 +310,7 @@ class AnayasaMahkemesiApiClient:
official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip()
full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api)
full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_norm_denetimi, html_content_from_api)
if not full_markdown_content:
return AnayasaDocumentMarkdown(
+103 -85
View File
@@ -1,46 +1,24 @@
# anayasa_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Literal
from enum import Enum
# --- Enums (AnayasaDonemEnum, AnayasaBasvuruTuruEnum, etc. - same as before) ---
# --- Enums (AnayasaDonemEnum, etc. - same as before) ---
class AnayasaDonemEnum(str, Enum):
TUMU = ""
TUMU = "ALL"
DONEM_1961 = "1"
DONEM_1982 = "2"
class AnayasaBasvuruTuruEnum(str, Enum):
TUMU = ""
IPTAL = "1"
ITIRAZ = "2"
DIGER = "3"
class AnayasaVarYokEnum(str, Enum):
TUMU = ""
TUMU = "ALL"
YOK = "0"
VAR = "1"
class AnayasaNormTuruEnum(str, Enum):
TUMU = ""
ANAYASA = "1"
ANAYASA_DEGISTIREN_KANUN = "2"
CUMHURBASKANLIGI_KARARNAMESI = "14"
ICTUZUK = "3"
KANUN = "4"
KANUN_HUKMUNDE_KARARNAME = "5"
KARAR = "6"
NIZAMNAME = "7"
TALIMATNAME = "8"
TARIFE = "9"
TBMM_KARARI = "10"
TEZKERE = "11"
TUZUK = "12"
YOK_SECENEGI = "0"
YONETMELIK = "13"
class AnayasaIncelemeSonucuEnum(str, Enum):
TUMU = ""
TUMU = "ALL"
ESAS_ACILMAMIS_SAYILMA = "1"
ESAS_IPTAL = "2"
ESAS_KARAR_YER_OLMADIGI = "3"
@@ -52,7 +30,7 @@ class AnayasaIncelemeSonucuEnum(str, Enum):
KANUN_6216_M43_4_IPTAL = "12"
class AnayasaSonucGerekcesiEnum(str, Enum):
TUMU = ""
TUMU = "ALL"
ANAYASAYA_AYKIRI_DEGIL = "29"
ANAYASAYA_ESAS_YONUNDEN_AYKIRILIK = "1"
ANAYASAYA_ESAS_YONUNDEN_UYGUNLUK = "2"
@@ -89,60 +67,60 @@ class AnayasaNormDenetimiSearchRequest(BaseModel):
keywords_all: Optional[List[str]] = Field(default_factory=list, description="Keywords for AND logic (KelimeAra[]).")
keywords_any: Optional[List[str]] = Field(default_factory=list, description="Keywords for OR logic (HerhangiBirKelimeAra[]).")
keywords_exclude: Optional[List[str]] = Field(default_factory=list, description="Keywords to exclude (BulunmayanKelimeAra[]).")
period: Optional[AnayasaDonemEnum] = Field(default=AnayasaDonemEnum.TUMU, description="Constitutional period (Donemler_id).")
case_number_esas: Optional[str] = Field(None, description="Case registry number (EsasNo), e.g., '2023/123'.")
decision_number_karar: Optional[str] = Field(None, description="Decision number (KararNo), e.g., '2023/456'.")
first_review_date_start: Optional[str] = Field(None, description="First review start date (IlkIncelemeTarihiIlk), format DD/MM/YYYY.")
first_review_date_end: Optional[str] = Field(None, description="First review end date (IlkIncelemeTarihiSon), format DD/MM/YYYY.")
decision_date_start: Optional[str] = Field(None, description="Decision start date (KararTarihiIlk), format DD/MM/YYYY.")
decision_date_end: Optional[str] = Field(None, description="Decision end date (KararTarihiSon), format DD/MM/YYYY.")
application_type: Optional[AnayasaBasvuruTuruEnum] = Field(default=AnayasaBasvuruTuruEnum.TUMU, description="Type of application (BasvuruTurler_id).")
applicant_general_name: Optional[str] = Field(None, description="General applicant name (BasvuranGeneller_id).")
applicant_specific_name: Optional[str] = Field(None, description="Specific applicant name (BasvuranOzeller_id).")
official_gazette_date_start: Optional[str] = Field(None, description="Official Gazette start date (ResmiGazeteTarihiIlk), format DD/MM/YYYY.")
official_gazette_date_end: Optional[str] = Field(None, description="Official Gazette end date (ResmiGazeteTarihiSon), format DD/MM/YYYY.")
official_gazette_number_start: Optional[str] = Field(None, description="Official Gazette starting number (ResmiGazeteSayisiIlk).")
official_gazette_number_end: Optional[str] = Field(None, description="Official Gazette ending number (ResmiGazeteSayisiSon).")
has_press_release: Optional[AnayasaVarYokEnum] = Field(default=AnayasaVarYokEnum.TUMU, description="Press release available (BasinDuyurusu).")
has_dissenting_opinion: Optional[AnayasaVarYokEnum] = Field(default=AnayasaVarYokEnum.TUMU, description="Dissenting opinion exists (KarsiOy).")
has_different_reasoning: Optional[AnayasaVarYokEnum] = Field(default=AnayasaVarYokEnum.TUMU, description="Different reasoning exists (FarkliGerekce).")
period: Optional[Literal["ALL", "1", "2"]] = Field(default="ALL", description="Constitutional period (Donemler_id).")
case_number_esas: str = Field("", description="Case registry number (EsasNo), e.g., '2023/123'.")
decision_number_karar: str = Field("", description="Decision number (KararNo), e.g., '2023/456'.")
first_review_date_start: str = Field("", description="First review start date (IlkIncelemeTarihiIlk), format DD/MM/YYYY.")
first_review_date_end: str = Field("", description="First review end date (IlkIncelemeTarihiSon), format DD/MM/YYYY.")
decision_date_start: str = Field("", description="Decision start date (KararTarihiIlk), format DD/MM/YYYY.")
decision_date_end: str = Field("", description="Decision end date (KararTarihiSon), format DD/MM/YYYY.")
application_type: Optional[Literal["ALL", "1", "2", "3"]] = Field(default="ALL", description="Type of application (BasvuruTurler_id).")
applicant_general_name: str = Field("", description="General applicant name (BasvuranGeneller_id).")
applicant_specific_name: str = Field("", description="Specific applicant name (BasvuranOzeller_id).")
official_gazette_date_start: str = Field("", description="Official Gazette start date (ResmiGazeteTarihiIlk), format DD/MM/YYYY.")
official_gazette_date_end: str = Field("", description="Official Gazette end date (ResmiGazeteTarihiSon), format DD/MM/YYYY.")
official_gazette_number_start: str = Field("", description="Official Gazette starting number (ResmiGazeteSayisiIlk).")
official_gazette_number_end: str = Field("", description="Official Gazette ending number (ResmiGazeteSayisiSon).")
has_press_release: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Press release available (BasinDuyurusu).")
has_dissenting_opinion: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Dissenting opinion exists (KarsiOy).")
has_different_reasoning: Optional[Literal["ALL", "0", "1"]] = Field(default="ALL", description="Different reasoning exists (FarkliGerekce).")
attending_members_names: Optional[List[str]] = Field(default_factory=list, description="List of attending members' exact names (Uyeler_id[]).")
rapporteur_name: Optional[str] = Field(None, description="Rapporteur's exact name (Raportorler_id).")
norm_type: Optional[AnayasaNormTuruEnum] = Field(default=AnayasaNormTuruEnum.TUMU, description="Type of the reviewed norm (NormunTurler_id).")
norm_id_or_name: Optional[str] = Field(None, description="Number or name of the norm (NormunNumarasiAdlar_id).")
norm_article: Optional[str] = Field(None, description="Article number of the norm (NormunMaddeNumarasi).")
review_outcomes: Optional[List[AnayasaIncelemeSonucuEnum]] = Field(default_factory=list, description="List of review types and outcomes (IncelemeTuruKararSonuclar_id[]).")
reason_for_final_outcome: Optional[AnayasaSonucGerekcesiEnum] = Field(default=AnayasaSonucGerekcesiEnum.TUMU, description="Main reason for the decision outcome (KararSonucununGerekcesi).")
rapporteur_name: str = Field("", description="Rapporteur's exact name (Raportorler_id).")
norm_type: Optional[Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"]] = Field(default="ALL", description="Type of the reviewed norm (NormunTurler_id).")
norm_id_or_name: str = Field("", description="Number or name of the norm (NormunNumarasiAdlar_id).")
norm_article: str = Field("", description="Article number of the norm (NormunMaddeNumarasi).")
review_outcomes: Optional[List[Literal["1", "2", "3", "4", "5", "6", "7", "8", "12"]]] = Field(default_factory=list, description="List of review types and outcomes (IncelemeTuruKararSonuclar_id[]).")
reason_for_final_outcome: Optional[Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "29", "30"]] = Field(default="ALL", description="Main reason for the decision outcome (KararSonucununGerekcesi).")
basis_constitution_article_numbers: Optional[List[str]] = Field(default_factory=list, description="List of supporting Constitution article numbers (DayanakHukmu[]).")
results_per_page: Optional[int] = Field(10, description="Number of results per page. Options: 10, 20, 30, 40, 50.")
page_to_fetch: Optional[int] = Field(1, ge=1, description="Page number to fetch for results list.")
sort_by_criteria: Optional[str] = Field("KararTarihi", description="Sort criteria. Options: 'KararTarihi', 'YayinTarihi', 'Toplam' (keyword count).")
results_per_page: int = Field(10, ge=1, le=10, description="Results per page.")
page_to_fetch: int = Field(1, ge=1, description="Page number to fetch for results list.")
sort_by_criteria: str = Field("KararTarihi", description="Sort criteria. Options: 'KararTarihi', 'YayinTarihi', 'Toplam' (keyword count).")
class AnayasaReviewedNormInfo(BaseModel):
"""Details of a norm reviewed within an AYM decision summary."""
norm_name_or_number: Optional[str] = None
article_number: Optional[str] = None
review_type_and_outcome: Optional[str] = None
outcome_reason: Optional[str] = None
norm_name_or_number: str = Field("", description="Norm name or number")
article_number: str = Field("", description="Article number")
review_type_and_outcome: str = Field("", description="Review type and outcome")
outcome_reason: str = Field("", description="Outcome reason")
basis_constitution_articles_cited: List[str] = Field(default_factory=list)
postponement_period: Optional[str] = None
postponement_period: str = Field("", description="Postponement period")
class AnayasaDecisionSummary(BaseModel):
"""Model for a single Anayasa Mahkemesi (Norm Denetimi) decision summary from search results."""
decision_reference_no: Optional[str] = None
decision_page_url: Optional[HttpUrl] = None
keywords_found_count: Optional[int] = None
application_type_summary: Optional[str] = None
applicant_summary: Optional[str] = None
decision_outcome_summary: Optional[str] = None
decision_date_summary: Optional[str] = None
decision_reference_no: str = Field("", description="Decision reference number")
decision_page_url: str = Field("", description="Decision page URL")
keywords_found_count: Optional[int] = Field(0, description="Keywords found count")
application_type_summary: str = Field("", description="Application type summary")
applicant_summary: str = Field("", description="Applicant summary")
decision_outcome_summary: str = Field("", description="Decision outcome summary")
decision_date_summary: str = Field("", description="Decision date summary")
reviewed_norms: List[AnayasaReviewedNormInfo] = Field(default_factory=list)
class AnayasaSearchResult(BaseModel):
"""Model for the overall search result for Anayasa Mahkemesi Norm Denetimi decisions."""
decisions: List[AnayasaDecisionSummary]
total_records_found: Optional[int] = None
retrieved_page_number: Optional[int] = None
total_records_found: int = Field(0, description="Total records found")
retrieved_page_number: int = Field(1, description="Retrieved page number")
class AnayasaDocumentMarkdown(BaseModel):
"""
@@ -150,10 +128,10 @@ class AnayasaDocumentMarkdown(BaseModel):
and pagination information.
"""
source_url: HttpUrl
decision_reference_no_from_page: Optional[str] = Field(None, description="E.K. No parsed from the document page.")
decision_date_from_page: Optional[str] = Field(None, description="Decision date parsed from the document page.")
official_gazette_info_from_page: Optional[str] = Field(None, description="Official Gazette info parsed from the document page.")
markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content.") # Corrected chunk size
decision_reference_no_from_page: str = Field("", description="E.K. No parsed from the document page.")
decision_date_from_page: str = Field("", description="Decision date parsed from the document page.")
official_gazette_info_from_page: str = Field("", description="Official Gazette info parsed from the document page.")
markdown_chunk: str = Field("", description="A 5,000 character chunk of the Markdown content.") # Corrected chunk size
current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).")
total_pages: int = Field(description="Total number of pages for the full markdown content.")
is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.")
@@ -168,27 +146,27 @@ class AnayasaBireyselReportSearchRequest(BaseModel):
class AnayasaBireyselReportDecisionDetail(BaseModel):
"""Details of a specific right/claim within a Bireysel Başvuru decision summary in a report."""
hak: Optional[str] = Field(None, description="İhlal edildiği iddia edilen hak (örneğin, Mülkiyet hakkı).")
mudahale_iddiasi: Optional[str] = Field(None, description="İhlale neden olan müdahale iddiası.")
sonuc: Optional[str] = Field(None, description="İnceleme sonucu (örneğin, İhlal, Düşme).")
giderim: Optional[str] = Field(None, description="Kararlaştırılan giderim (örneğin, Yeniden yargılama).")
hak: str = Field("", description="İhlal edildiği iddia edilen hak (örneğin, Mülkiyet hakkı).")
mudahale_iddiasi: str = Field("", description="İhlale neden olan müdahale iddiası.")
sonuc: str = Field("", description="İnceleme sonucu (örneğin, İhlal, Düşme).")
giderim: str = Field("", description="Kararlaştırılan giderim (örneğin, Yeniden yargılama).")
class AnayasaBireyselReportDecisionSummary(BaseModel):
"""Model for a single Anayasa Mahkemesi (Bireysel Başvuru) decision summary from a 'Karar Arama Raporu'."""
title: Optional[str] = Field(None, description="Başvurunun başlığı (e.g., 'HASAN DURMUŞ Başvurusuna İlişkin Karar').")
decision_reference_no: Optional[str] = Field(None, description="Başvuru Numarası (e.g., '2019/19126').")
decision_page_url: Optional[HttpUrl] = Field(None, description="URL to the full decision page.")
decision_type_summary: Optional[str] = Field(None, description="Karar Türü (Başvuru Sonucu) (e.g., 'Esas (İhlal)').")
decision_making_body: Optional[str] = Field(None, description="Kararı Veren Birim (e.g., 'Genel Kurul', 'Birinci Bölüm').")
application_date_summary: Optional[str] = Field(None, description="Başvuru Tarihi (DD/MM/YYYY).")
decision_date_summary: Optional[str] = Field(None, description="Karar Tarihi (DD/MM/YYYY).")
application_subject_summary: Optional[str] = Field(None, description="Başvuru konusunun özeti.")
title: str = Field("", description="Başvurunun başlığı (e.g., 'HASAN DURMUŞ Başvurusuna İlişkin Karar').")
decision_reference_no: str = Field("", description="Başvuru Numarası (e.g., '2019/19126').")
decision_page_url: str = Field("", description="URL to the full decision page.")
decision_type_summary: str = Field("", description="Karar Türü (Başvuru Sonucu) (e.g., 'Esas (İhlal)').")
decision_making_body: str = Field("", description="Kararı Veren Birim (e.g., 'Genel Kurul', 'Birinci Bölüm').")
application_date_summary: str = Field("", description="Başvuru Tarihi (DD/MM/YYYY).")
decision_date_summary: str = Field("", description="Karar Tarihi (DD/MM/YYYY).")
application_subject_summary: str = Field("", description="Başvuru konusunun özeti.")
details: List[AnayasaBireyselReportDecisionDetail] = Field(default_factory=list, description="İncelenen haklar ve sonuçlarına ilişkin detaylar.")
class AnayasaBireyselReportSearchResult(BaseModel):
"""Model for the overall search result for Anayasa Mahkemesi 'Karar Arama Raporu'."""
decisions: List[AnayasaBireyselReportDecisionSummary]
total_records_found: Optional[int] = Field(None, description="Raporda bulunan toplam karar sayısı.")
total_records_found: int = Field(0, description="Raporda bulunan toplam karar sayısı.")
retrieved_page_number: int = Field(description="Alınan rapor sayfa numarası.")
@@ -210,3 +188,43 @@ class AnayasaBireyselBasvuruDocumentMarkdown(BaseModel):
is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.")
# --- End Models for Bireysel Başvuru ---
# --- Unified Models ---
class AnayasaUnifiedSearchRequest(BaseModel):
"""Unified search request for both Norm Denetimi and Bireysel Başvuru."""
decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Decision type: norm_denetimi or bireysel_basvuru")
# Common parameters
keywords: List[str] = Field(default_factory=list, description="Keywords to search for")
page_to_fetch: int = Field(1, ge=1, le=100, description="Page number to fetch (1-100)")
results_per_page: int = Field(10, ge=1, le=100, description="Results per page (1-100)")
# Norm Denetimi specific parameters (ignored for bireysel_basvuru)
keywords_all: List[str] = Field(default_factory=list, description="All keywords must be present (norm_denetimi only)")
keywords_any: List[str] = Field(default_factory=list, description="Any of these keywords (norm_denetimi only)")
decision_type_norm: Literal["ALL", "1", "2", "3"] = Field("ALL", description="Decision type for norm denetimi")
application_date_start: str = Field("", description="Application start date (norm_denetimi only)")
application_date_end: str = Field("", description="Application end date (norm_denetimi only)")
# Bireysel Başvuru specific parameters (ignored for norm_denetimi)
decision_start_date: str = Field("", description="Decision start date (bireysel_basvuru only)")
decision_end_date: str = Field("", description="Decision end date (bireysel_basvuru only)")
norm_type: Literal["ALL", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"] = Field("ALL", description="Norm type (bireysel_basvuru only)")
subject_category: str = Field("", description="Subject category (bireysel_basvuru only)")
class AnayasaUnifiedSearchResult(BaseModel):
"""Unified search result containing decisions from either system."""
decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Type of decisions returned")
decisions: List[Dict[str, Any]] = Field(default_factory=list, description="Decision list (structure varies by type)")
total_records_found: int = Field(0, description="Total number of records found")
retrieved_page_number: int = Field(1, description="Page number that was retrieved")
class AnayasaUnifiedDocumentMarkdown(BaseModel):
"""Unified document model for both Norm Denetimi and Bireysel Başvuru."""
decision_type: Literal["norm_denetimi", "bireysel_basvuru"] = Field(..., description="Type of document")
source_url: HttpUrl = Field(..., description="Source URL of the document")
document_data: Dict[str, Any] = Field(default_factory=dict, description="Document content and metadata")
markdown_chunk: Optional[str] = Field(None, description="Markdown content chunk")
current_page: int = Field(1, description="Current page number")
total_pages: int = Field(1, description="Total number of pages")
is_paginated: bool = Field(False, description="Whether document is paginated")
+172
View File
@@ -0,0 +1,172 @@
# anayasa_mcp_module/unified_client.py
# Unified client for both Norm Denetimi and Bireysel Başvuru
import logging
from typing import Optional, Tuple
from urllib.parse import urlparse, urlunparse
from .models import (
AnayasaUnifiedSearchRequest,
AnayasaUnifiedSearchResult,
AnayasaUnifiedDocumentMarkdown,
# Removed AnayasaDecisionTypeEnum - now using string literals
AnayasaNormDenetimiSearchRequest,
AnayasaBireyselReportSearchRequest
)
from .client import AnayasaMahkemesiApiClient
from .bireysel_client import AnayasaBireyselBasvuruApiClient
logger = logging.getLogger(__name__)
# Canonical hosts per decision type. Norm Denetimi (/ND/) documents live on the
# "norm" subdomain; Bireysel Başvuru (/BB/) documents on the plain subdomain.
# Callers (or upstream search links) sometimes supply the wrong host for a given
# path, which makes the AYM server return 404. We re-key the host off the path.
_NORM_HOST = "normkararlarbilgibankasi.anayasa.gov.tr"
_BIREYSEL_HOST = "kararlarbilgibankasi.anayasa.gov.tr"
def normalize_anayasa_document_url(document_url: str) -> Tuple[Optional[str], str]:
"""Detect the AYM decision type from the URL path and force the correct host.
Detection is path-based (``/ND/`` vs ``/BB/``) because the path is
unambiguous, whereas the supplied host may be wrong. Query params and
fragment are preserved (they are harmless for document fetches).
Returns ``(decision_type, normalized_url)`` where ``decision_type`` is
``"norm_denetimi"``, ``"bireysel_basvuru"``, or ``None`` if it cannot be
determined (URL returned unchanged in that case).
"""
parsed = urlparse(document_url)
path = parsed.path or ""
if "/ND/" in path:
decision_type, host = "norm_denetimi", _NORM_HOST
elif "/BB/" in path:
decision_type, host = "bireysel_basvuru", _BIREYSEL_HOST
else:
# Fall back to host-based detection when the path is uninformative.
if "normkararlarbilgibankasi" in parsed.netloc:
return "norm_denetimi", document_url
if "kararlarbilgibankasi" in parsed.netloc:
return "bireysel_basvuru", document_url
return None, document_url
normalized = urlunparse((
parsed.scheme or "https",
host,
parsed.path,
parsed.params,
parsed.query,
parsed.fragment,
))
return decision_type, normalized
class AnayasaUnifiedClient:
"""Unified client that handles both Norm Denetimi and Bireysel Başvuru searches."""
def __init__(self, request_timeout: float = 60.0):
self.norm_client = AnayasaMahkemesiApiClient(request_timeout)
self.bireysel_client = AnayasaBireyselBasvuruApiClient(request_timeout)
async def search_unified(self, params: AnayasaUnifiedSearchRequest) -> AnayasaUnifiedSearchResult:
"""Unified search that routes to appropriate client based on decision_type."""
if params.decision_type == "norm_denetimi":
# Convert to norm denetimi request
norm_params = AnayasaNormDenetimiSearchRequest(
keywords_all=params.keywords_all or params.keywords,
keywords_any=params.keywords_any,
application_type=params.decision_type_norm,
page_to_fetch=params.page_to_fetch,
results_per_page=params.results_per_page
)
result = await self.norm_client.search_norm_denetimi_decisions(norm_params)
# Convert to unified format
decisions_list = [decision.model_dump() for decision in result.decisions]
return AnayasaUnifiedSearchResult(
decision_type="norm_denetimi",
decisions=decisions_list,
total_records_found=result.total_records_found,
retrieved_page_number=result.retrieved_page_number
)
elif params.decision_type == "bireysel_basvuru":
# Convert to bireysel başvuru request
bireysel_params = AnayasaBireyselReportSearchRequest(
keywords=params.keywords,
decision_start_date=params.decision_start_date,
decision_end_date=params.decision_end_date,
norm_type=params.norm_type,
subject_category=params.subject_category,
page_to_fetch=params.page_to_fetch,
results_per_page=params.results_per_page
)
result = await self.bireysel_client.search_bireysel_basvuru_report(bireysel_params)
# Convert to unified format
decisions_list = [decision.model_dump() for decision in result.decisions]
return AnayasaUnifiedSearchResult(
decision_type="bireysel_basvuru",
decisions=decisions_list,
total_records_found=result.total_records_found,
retrieved_page_number=result.retrieved_page_number
)
else:
raise ValueError(f"Unsupported decision type: {params.decision_type}")
async def get_document_unified(self, document_url: str, page_number: int = 1) -> AnayasaUnifiedDocumentMarkdown:
"""Unified document retrieval that auto-detects the appropriate client."""
# Auto-detect decision type from the path and force the correct host.
# This repairs malformed URLs (e.g. a /ND/ path on the bireysel host),
# which otherwise 404 against the AYM server.
decision_type, normalized_url = normalize_anayasa_document_url(document_url)
if normalized_url != document_url:
logger.info(
f"AnayasaUnifiedClient: Normalized document URL "
f"'{document_url}' -> '{normalized_url}'"
)
if decision_type == "norm_denetimi":
result = await self.norm_client.get_decision_document_as_markdown(normalized_url, page_number)
return AnayasaUnifiedDocumentMarkdown(
decision_type="norm_denetimi",
source_url=result.source_url,
document_data=result.model_dump(),
markdown_chunk=result.markdown_chunk,
current_page=result.current_page,
total_pages=result.total_pages,
is_paginated=result.is_paginated
)
elif decision_type == "bireysel_basvuru":
result = await self.bireysel_client.get_decision_document_as_markdown(normalized_url, page_number)
return AnayasaUnifiedDocumentMarkdown(
decision_type="bireysel_basvuru",
source_url=result.source_url,
document_data=result.model_dump(),
markdown_chunk=result.markdown_chunk,
current_page=result.current_page,
total_pages=result.total_pages,
is_paginated=result.is_paginated
)
else:
raise ValueError(f"Cannot determine document type from URL: {document_url}")
async def close_client_session(self):
"""Close both client sessions."""
if hasattr(self.norm_client, 'close_client_session'):
await self.norm_client.close_client_session()
if hasattr(self.bireysel_client, 'close_client_session'):
await self.bireysel_client.close_client_session()
+35
View File
@@ -0,0 +1,35 @@
"""
ASGI application for Yargı MCP Server (simple deployment variant).
This is a minimal ASGI application that can be run with:
uvicorn app:app --host 0.0.0.0 --port 8000
The MCP server will be available at:
http://localhost:8000/mcp/
For the FastAPI-wrapped variant with CORS and extra metadata routes,
see asgi_app.py instead.
"""
from starlette.responses import JSONResponse
from mcp_server_main import create_app
mcp = create_app()
@mcp.custom_route("/health", methods=["GET"])
async def health_check(request):
"""Health check endpoint for monitoring services (Fly.io, Render, etc.)."""
return JSONResponse({
"status": "healthy",
"service": "Yargı MCP Server",
"version": "0.2.1",
})
# Create ASGI app directly from FastMCP server
app = mcp.http_app()
# Endpoints:
# - /mcp/ - MCP server (Streamable HTTP transport, default FastMCP path)
# - /health - Health check for monitoring
Executable
+146
View File
@@ -0,0 +1,146 @@
"""
ASGI application for Yargı MCP Server
This module provides ASGI/HTTP access to the Yargı MCP server,
allowing it to be deployed as a web service with FastAPI wrapper.
Usage:
uvicorn asgi_app:app --host 0.0.0.0 --port 8000
"""
import os
import json
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from mcp_server_main import create_app
# Setup logging
logger = logging.getLogger(__name__)
# Configure CORS
cors_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
# Create MCP app
mcp_server = create_app()
# Create MCP Starlette sub-application
mcp_app = mcp_server.http_app(path="/")
# Configure JSON encoder for proper Turkish character support
class UTF8JSONResponse(JSONResponse):
def __init__(self, content=None, status_code=200, headers=None, **kwargs):
if headers is None:
headers = {}
headers["Content-Type"] = "application/json; charset=utf-8"
super().__init__(content, status_code, headers, **kwargs)
def render(self, content) -> bytes:
return json.dumps(
content,
ensure_ascii=False,
allow_nan=False,
indent=None,
separators=(",", ":"),
).encode("utf-8")
custom_middleware = [
Middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type", "X-Request-ID", "X-Session-ID"],
),
]
# Create FastAPI wrapper application
app = FastAPI(
title="Yargı MCP Server",
description="MCP server for Turkish legal databases",
version="0.1.0",
middleware=custom_middleware,
default_response_class=UTF8JSONResponse,
redirect_slashes=False,
)
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring"""
return {
"status": "healthy",
"service": "Yargı MCP Server",
"version": "0.1.0",
"tools_count": len(mcp_server._tool_manager._tools),
}
@app.api_route("/mcp", methods=["GET", "POST", "HEAD", "OPTIONS"])
async def redirect_to_slash(request: Request):
"""Redirect /mcp to /mcp/ preserving HTTP method with 308"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/mcp/", status_code=308)
@app.get("/")
async def root():
"""Root endpoint with service information"""
return {
"service": "Yargı MCP Server",
"description": "MCP server for Turkish legal databases",
"endpoints": {
"mcp": "/mcp",
"health": "/health",
"status": "/status",
},
"transports": {
"http": "/mcp"
},
"supported_databases": [
"Yargıtay (Court of Cassation)",
"Danıştay (Council of State)",
"Emsal (Precedent)",
"Uyuşmazlık Mahkemesi (Court of Jurisdictional Disputes)",
"Anayasa Mahkemesi (Constitutional Court)",
"Kamu İhale Kurulu (Public Procurement Authority)",
"Rekabet Kurumu (Competition Authority)",
"Sayıştay (Court of Accounts)",
"KVKK (Personal Data Protection Authority)",
"BDDK (Banking Regulation and Supervision Agency)",
"Bedesten API (Multiple courts)",
"Sigorta Tahkim Komisyonu (Insurance Arbitration Commission)",
],
}
@app.get("/status")
async def status():
"""Status endpoint with detailed information"""
tools = []
for tool in mcp_server._tool_manager._tools.values():
tools.append({
"name": tool.name,
"description": tool.description[:100] + "..." if len(tool.description) > 100 else tool.description
})
return {
"status": "operational",
"tools": tools,
"total_tools": len(tools),
"transport": "streamable_http",
}
# Mount MCP app at /mcp/
app.mount("/mcp/", mcp_app)
# Set the lifespan context after mounting
app.router.lifespan_context = mcp_app.lifespan
# Export for uvicorn
__all__ = ["app"]
+17
View File
@@ -0,0 +1,17 @@
# bddk_mcp_module/__init__.py
from .client import BddkApiClient
from .models import (
BddkSearchRequest,
BddkDecisionSummary,
BddkSearchResult,
BddkDocumentMarkdown
)
__all__ = [
"BddkApiClient",
"BddkSearchRequest",
"BddkDecisionSummary",
"BddkSearchResult",
"BddkDocumentMarkdown"
]
+253
View File
@@ -0,0 +1,253 @@
# bddk_mcp_module/client.py
import asyncio
import httpx
from typing import List, Optional, Dict, Any
import logging
import os
import re
import io
import math
from urllib.parse import urlparse
from markitdown import MarkItDown
from .models import (
BddkSearchRequest,
BddkDecisionSummary,
BddkSearchResult,
BddkDocumentMarkdown
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
class BddkApiClient:
"""
API client for searching and retrieving BDDK (Banking Regulation Authority) decisions
using Tavily Search API for discovery and direct HTTP requests for content retrieval.
"""
TAVILY_API_URL = "https://api.tavily.com/search"
BDDK_BASE_URL = "https://www.bddk.org.tr"
DOCUMENT_URL_TEMPLATE = "https://www.bddk.org.tr/Mevzuat/DokumanGetir/{document_id}"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
def __init__(self, request_timeout: float = 60.0):
"""Initialize the BDDK API client."""
self.tavily_api_key = os.getenv("TAVILY_API_KEY")
if not self.tavily_api_key:
# Fallback to development token
self.tavily_api_key = "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"
logger.info("Using fallback Tavily API token (development token)")
else:
logger.info("Using Tavily API key from environment variable")
self.http_client = httpx.AsyncClient(
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
},
timeout=httpx.Timeout(request_timeout)
)
self.markitdown = MarkItDown()
async def close_client_session(self):
"""Close the HTTP client session."""
await self.http_client.aclose()
logger.info("BddkApiClient: HTTP client session closed.")
def _extract_document_id(self, url: str) -> Optional[str]:
"""Extract document ID from BDDK URL."""
# Primary pattern: https://www.bddk.org.tr/Mevzuat/DokumanGetir/310
match = re.search(r'/DokumanGetir/(\d+)', url)
if match:
return match.group(1)
# Alternative patterns for different BDDK URL formats
# Pattern: /Liste/55 -> use as document ID
match = re.search(r'/Liste/(\d+)', url)
if match:
return match.group(1)
# Pattern: /EkGetir/13?ekId=381 -> use ekId as document ID
match = re.search(r'ekId=(\d+)', url)
if match:
return match.group(1)
return None
async def search_decisions(
self,
request: BddkSearchRequest
) -> BddkSearchResult:
"""
Search for BDDK decisions using Tavily API.
Args:
request: Search request parameters
Returns:
BddkSearchResult with matching decisions
"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.tavily_api_key}"
}
# Tavily API request - enhanced for BDDK decision documents
query = f"{request.keywords} \"Karar Sayısı\""
payload = {
"query": query,
"country": "turkey",
"include_domains": ["https://www.bddk.org.tr/Mevzuat/DokumanGetir"],
"max_results": request.pageSize,
"search_depth": "advanced"
}
# Calculate offset for pagination
if request.page > 1:
# Tavily doesn't have direct pagination, so we'll need to handle this
# For now, we'll just return empty for pages > 1
logger.warning(f"Tavily API doesn't support pagination. Page {request.page} requested.")
response = await self.http_client.post(
self.TAVILY_API_URL,
json=payload,
headers=headers
)
response.raise_for_status()
data = response.json()
# Log raw Tavily response for debugging
logger.info(f"Tavily returned {len(data.get('results', []))} results")
# Convert Tavily results to our format
decisions = []
for result in data.get("results", []):
# Extract document ID from URL
url = result.get("url", "")
logger.debug(f"Processing URL: {url}")
doc_id = self._extract_document_id(url)
if doc_id:
decision = BddkDecisionSummary(
title=result.get("title", "").replace("[PDF] ", "").strip(),
document_id=doc_id,
content=result.get("content", "")[:500] # Limit content length
)
decisions.append(decision)
logger.debug(f"Added decision: {decision.title} (ID: {doc_id})")
else:
logger.warning(f"Could not extract document ID from URL: {url}")
return BddkSearchResult(
decisions=decisions,
total_results=len(data.get("results", [])),
page=request.page,
pageSize=request.pageSize
)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error searching BDDK decisions: {e}")
if e.response.status_code == 401:
raise Exception("Tavily API authentication failed. Check API key.")
raise Exception(f"Failed to search BDDK decisions: {str(e)}")
except Exception as e:
logger.error(f"Error searching BDDK decisions: {e}")
raise Exception(f"Failed to search BDDK decisions: {str(e)}")
async def get_document_markdown(
self,
document_id: str,
page_number: int = 1
) -> BddkDocumentMarkdown:
"""
Retrieve a BDDK document and convert it to Markdown format.
Args:
document_id: BDDK document ID (e.g., '310')
page_number: Page number for paginated content (1-indexed)
Returns:
BddkDocumentMarkdown with paginated content
"""
try:
# Try different URL patterns for BDDK documents
potential_urls = [
f"https://www.bddk.org.tr/Mevzuat/DokumanGetir/{document_id}",
f"https://www.bddk.org.tr/Mevzuat/Liste/{document_id}",
f"https://www.bddk.org.tr/KurumHakkinda/EkGetir/13?ekId={document_id}",
f"https://www.bddk.org.tr/KurumHakkinda/EkGetir/5?ekId={document_id}"
]
document_url = None
response = None
# Try each URL pattern until one works
for url in potential_urls:
try:
logger.info(f"Trying BDDK document URL: {url}")
response = await self.http_client.get(
url,
follow_redirects=True
)
response.raise_for_status()
document_url = url
break
except httpx.HTTPStatusError:
continue
if not response or not document_url:
raise Exception(f"Could not find document with ID {document_id}")
logger.info(f"Successfully fetched BDDK document from: {document_url}")
# Determine content type
content_type = response.headers.get("content-type", "").lower()
# Convert to Markdown based on content type
if "pdf" in content_type:
# Handle PDF documents. markitdown is sync; offload to thread
# so PDF parsing doesn't block the event-loop / other requests.
pdf_stream = io.BytesIO(response.content)
result = await asyncio.to_thread(
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
)
markdown_content = result.text_content
else:
# Handle HTML documents (sync conversion offloaded to thread)
html_stream = io.BytesIO(response.content)
result = await asyncio.to_thread(
self.markitdown.convert_stream, html_stream, file_extension=".html"
)
markdown_content = result.text_content
# Clean up the markdown content
markdown_content = markdown_content.strip()
# Calculate pagination
total_length = len(markdown_content)
total_pages = math.ceil(total_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
# Extract the requested page
start_idx = (page_number - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end_idx = start_idx + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
page_content = markdown_content[start_idx:end_idx]
return BddkDocumentMarkdown(
document_id=document_id,
markdown_content=page_content,
page_number=page_number,
total_pages=total_pages
)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching BDDK document {document_id}: {e}")
raise Exception(f"Failed to fetch BDDK document: {str(e)}")
except Exception as e:
logger.error(f"Error processing BDDK document {document_id}: {e}")
raise Exception(f"Failed to process BDDK document: {str(e)}")
+43
View File
@@ -0,0 +1,43 @@
# bddk_mcp_module/models.py
from pydantic import BaseModel, Field
from typing import List, Optional
class BddkSearchRequest(BaseModel):
"""
Request model for searching BDDK decisions via Tavily API.
BDDK (Bankacılık Düzenleme ve Denetleme Kurumu) is Turkey's Banking
Regulation and Supervision Agency responsible for banking licenses,
electronic money institutions, and financial regulations.
"""
keywords: str = Field(..., description="Search keywords in Turkish")
page: int = Field(1, ge=1, description="Page number (1-indexed)")
pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)")
class BddkDecisionSummary(BaseModel):
"""Summary of a BDDK decision from search results."""
title: str = Field(..., description="Decision title")
document_id: str = Field(..., description="BDDK document ID (e.g., '310')")
content: str = Field(..., description="Decision summary/excerpt")
class BddkSearchResult(BaseModel):
"""Response model for BDDK decision search results."""
decisions: List[BddkDecisionSummary] = Field(
default_factory=list,
description="List of matching BDDK decisions"
)
total_results: int = Field(0, description="Total number of results")
page: int = Field(1, description="Current page number")
pageSize: int = Field(10, description="Results per page")
class BddkDocumentMarkdown(BaseModel):
"""
BDDK decision document converted to Markdown format.
Supports paginated content for long documents (5000 chars per page).
"""
document_id: str = Field(..., description="BDDK document ID")
markdown_content: str = Field("", description="Document content in Markdown")
page_number: int = Field(1, description="Current page number")
total_pages: int = Field(1, description="Total number of pages")
+1
View File
@@ -0,0 +1 @@
# bedesten_mcp_module/__init__.py
+308
View File
@@ -0,0 +1,308 @@
# bedesten_mcp_module/client.py
import asyncio
import base64
import io
import logging
import os
import time
from typing import Optional
import httpx
from markitdown import MarkItDown
from .models import (
BedestenSearchRequest, BedestenSearchResponse,
BedestenDocumentRequest, BedestenDocumentResponse,
BedestenDocumentMarkdown, BedestenDocumentRequestData
)
from .enums import get_full_birim_adi
logger = logging.getLogger(__name__)
class BedestenRateLimited(Exception):
"""Raised when the local rate-limit bucket would block longer than allowed.
Carries the suggested retry-after (seconds) so callers can surface a
structured 429-style response to the MCP client instead of silently
blocking the event-loop slot for the full bucket-pause window.
"""
def __init__(self, retry_after: float) -> None:
self.retry_after = retry_after
super().__init__(f"local bucket would block {retry_after:.1f}s")
class _TokenBucket:
"""Asyncio token bucket with explicit back-pressure.
Measured Bedesten limit (per source IP, 2026-05-08): 10 requests per
rolling 30s window with full refill — equivalent to capacity=10,
refill_rate=1 token / 3s. Even with margin, 429s still leak through
when other clients share the egress IP, so we also expose
``penalize_until`` so callers can freeze the bucket when the server
actually returns 429 (Retry-After).
"""
def __init__(self, capacity: int, refill_per_s: float) -> None:
self.capacity = float(capacity)
self.refill_per_s = float(refill_per_s)
self._tokens = float(capacity)
self._last = time.monotonic()
self._not_before = 0.0
self._lock = asyncio.Lock()
async def acquire(self, max_wait: Optional[float] = None) -> None:
"""Acquire one token. If ``max_wait`` is set and the next wait would
exceed it, raise :class:`BedestenRateLimited` immediately instead of
sleeping — keeps a single rate-limited request from holding the
worker-slot for the full bucket-pause window (up to ~30s on 429)."""
deadline = (time.monotonic() + max_wait) if max_wait is not None else None
while True:
async with self._lock:
now = time.monotonic()
if now < self._not_before:
wait_s = self._not_before - now
else:
self._tokens = min(
self.capacity,
self._tokens + (now - self._last) * self.refill_per_s,
)
self._last = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return
wait_s = (1.0 - self._tokens) / self.refill_per_s
if deadline is not None:
remaining = deadline - time.monotonic()
if wait_s > remaining:
raise BedestenRateLimited(retry_after=wait_s)
await asyncio.sleep(wait_s)
def penalize_until(self, monotonic_deadline: float) -> None:
"""Pause the bucket until ``monotonic_deadline`` (drains tokens)."""
self._not_before = max(self._not_before, monotonic_deadline)
self._tokens = 0.0
self._last = time.monotonic()
class BedestenApiClient:
"""
API Client for Bedesten (bedesten.adalet.gov.tr) - Alternative legal decision search system.
Currently used for Yargıtay decisions, but can be extended for other court types.
"""
BASE_URL = "https://bedesten.adalet.gov.tr"
SEARCH_ENDPOINT = "/emsal-karar/searchDocuments"
DOCUMENT_ENDPOINT = "/emsal-karar/getDocumentContent"
# Measured limit (per source IP): 10 requests per 30s window with full
# refill (≈ 1 token / 3s steady). We default to 1-token capacity and
# 3.5s spacing (no burst, ~14% safety margin). Override via env:
# BEDESTEN_RATE_CAPACITY (default 1)
# BEDESTEN_RATE_REFILL_S (default 3.5; seconds per token)
# BEDESTEN_RATE_MAX_WAIT_S (default 8.0; max seconds to wait in the
# local bucket before returning a structured 429 to the caller)
_DEFAULT_CAPACITY = int(os.getenv("BEDESTEN_RATE_CAPACITY", "1"))
_DEFAULT_REFILL_S = float(os.getenv("BEDESTEN_RATE_REFILL_S", "3.5"))
_DEFAULT_MAX_WAIT_S = float(os.getenv("BEDESTEN_RATE_MAX_WAIT_S", "8.0"))
def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "*/*",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"AdaletApplicationName": "UyapMevzuat",
"Content-Type": "application/json; charset=utf-8",
"Origin": "https://mevzuat.adalet.gov.tr",
"Referer": "https://mevzuat.adalet.gov.tr/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
},
timeout=request_timeout
)
self._bucket = _TokenBucket(
capacity=self._DEFAULT_CAPACITY,
refill_per_s=1.0 / self._DEFAULT_REFILL_S,
)
def _handle_429(self, response: httpx.Response, op: str) -> None:
"""Apply back-pressure to the shared bucket based on Retry-After."""
retry_after_raw = response.headers.get("Retry-After", "")
try:
retry_after = float(retry_after_raw)
except (TypeError, ValueError):
retry_after = 30.0
# Cap penalty so a hostile/buggy server can't freeze us indefinitely.
retry_after = max(1.0, min(retry_after, 60.0))
self._bucket.penalize_until(time.monotonic() + retry_after + 0.5)
logger.warning(
f"BedestenApiClient: 429 on {op}; bucket paused {retry_after + 0.5:.1f}s"
)
async def search_documents(self, search_request: BedestenSearchRequest) -> BedestenSearchResponse:
"""
Search for documents using Bedesten API.
Currently supports: YARGITAYKARARI, DANISTAYKARARI, YERELHUKMAHKARARI, etc.
"""
logger.info(f"BedestenApiClient: Searching documents with phrase: {search_request.data.phrase}")
# Map abbreviated birimAdi to full Turkish name before sending to API
original_birim_adi = search_request.data.birimAdi
mapped_birim_adi = get_full_birim_adi(original_birim_adi)
search_request.data.birimAdi = mapped_birim_adi
if original_birim_adi != "ALL":
logger.info(f"BedestenApiClient: Mapped birimAdi '{original_birim_adi}' to '{mapped_birim_adi}'")
try:
# Create request dict and remove birimAdi if empty
request_dict = search_request.model_dump()
if not request_dict["data"]["birimAdi"]: # Remove if empty string
del request_dict["data"]["birimAdi"]
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
response = await self.http_client.post(
self.SEARCH_ENDPOINT,
json=request_dict
)
if response.status_code == 429:
self._handle_429(response, "search")
response.raise_for_status()
response_json = response.json()
# Parse and return the response
return BedestenSearchResponse(**response_json)
except httpx.RequestError as e:
logger.error(f"BedestenApiClient: HTTP request error during search: {e}")
raise
except Exception as e:
logger.error(f"BedestenApiClient: Error processing search response: {e}")
raise
async def get_document_as_markdown(self, document_id: str) -> BedestenDocumentMarkdown:
"""
Get document content and convert to markdown.
Handles both HTML (text/html) and PDF (application/pdf) content types.
"""
logger.info(f"BedestenApiClient: Fetching document for markdown conversion (ID: {document_id})")
try:
# Prepare request
doc_request = BedestenDocumentRequest(
data=BedestenDocumentRequestData(documentId=document_id)
)
# Get document
await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
response = await self.http_client.post(
self.DOCUMENT_ENDPOINT,
json=doc_request.model_dump()
)
if response.status_code == 429:
self._handle_429(response, f"document {document_id}")
response.raise_for_status()
response_json = response.json()
doc_response = BedestenDocumentResponse(**response_json)
# Add null safety checks for document data
if not hasattr(doc_response, 'data') or doc_response.data is None:
raise ValueError("Document response does not contain data")
if not hasattr(doc_response.data, 'content') or doc_response.data.content is None:
raise ValueError("Document data does not contain content")
if not hasattr(doc_response.data, 'mimeType') or doc_response.data.mimeType is None:
raise ValueError("Document data does not contain mimeType")
# Decode base64 content with error handling
try:
content_bytes = base64.b64decode(doc_response.data.content)
except Exception as e:
raise ValueError(f"Failed to decode base64 content: {str(e)}")
mime_type = doc_response.data.mimeType
logger.info(f"BedestenApiClient: Document mime type: {mime_type}")
# Convert to markdown based on mime type. markitdown is sync and
# PDF parsing in particular can block the event-loop for seconds,
# which on a single-worker uvicorn deployment stalls every other
# in-flight MCP request and new TLS handshakes. Offload to a
# thread so the event-loop stays responsive.
if mime_type == "text/html":
html_content = content_bytes.decode('utf-8')
markdown_content = await asyncio.to_thread(
self._convert_html_to_markdown, html_content
)
elif mime_type == "application/pdf":
markdown_content = await asyncio.to_thread(
self._convert_pdf_to_markdown, content_bytes
)
else:
logger.warning(f"Unsupported mime type: {mime_type}")
markdown_content = f"Unsupported content type: {mime_type}. Unable to convert to markdown."
return BedestenDocumentMarkdown(
documentId=document_id,
markdown_content=markdown_content,
source_url=f"https://mevzuat.adalet.gov.tr/ictihat/{document_id}",
mime_type=mime_type
)
except httpx.RequestError as e:
logger.error(f"BedestenApiClient: HTTP error fetching document {document_id}: {e}")
raise
except Exception as e:
logger.error(f"BedestenApiClient: Error processing document {document_id}: {e}")
raise
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
"""Convert HTML to Markdown using MarkItDown"""
if not html_content:
return None
try:
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_content.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
result = md_converter.convert(html_stream)
markdown_content = result.text_content
logger.info("Successfully converted HTML to Markdown")
return markdown_content
except Exception as e:
logger.error(f"Error converting HTML to Markdown: {e}")
return f"Error converting HTML content: {str(e)}"
def _convert_pdf_to_markdown(self, pdf_bytes: bytes) -> Optional[str]:
"""Convert PDF to Markdown using MarkItDown"""
if not pdf_bytes:
return None
try:
# Create BytesIO stream from PDF bytes
pdf_stream = io.BytesIO(pdf_bytes)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
result = md_converter.convert(pdf_stream)
markdown_content = result.text_content
logger.info("Successfully converted PDF to Markdown")
return markdown_content
except Exception as e:
logger.error(f"Error converting PDF to Markdown: {e}")
return f"Error converting PDF content: {str(e)}. The document may be corrupted or in an unsupported format."
async def close_client_session(self):
"""Close HTTP client session"""
await self.http_client.aclose()
logger.info("BedestenApiClient: HTTP client session closed.")
+113
View File
@@ -0,0 +1,113 @@
# bedesten_mcp_module/enums.py
from typing import Literal
# Unified compressed enum for both Yargıtay and Danıştay chambers
BirimAdiEnum = Literal[
"ALL", # All chambers
# Yargıtay (Court of Cassation) - Civil Chambers
"H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "H10",
"H11", "H12", "H13", "H14", "H15", "H16", "H17", "H18", "H19", "H20",
"H21", "H22", "H23",
# Yargıtay - Criminal Chambers
"C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "C10",
"C11", "C12", "C13", "C14", "C15", "C16", "C17", "C18", "C19", "C20",
"C21", "C22", "C23",
# Yargıtay - Councils and Assemblies
"HGK", # Hukuk Genel Kurulu
"CGK", # Ceza Genel Kurulu
"BGK", # Büyük Genel Kurulu
"HBK", # Hukuk Daireleri Başkanlar Kurulu
"CBK", # Ceza Daireleri Başkanlar Kurulu
# Danıştay (Council of State) - Chambers
"D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10",
"D11", "D12", "D13", "D14", "D15", "D16", "D17",
# Danıştay - Councils and Boards
"DBGK", # Büyük Gen.Kur. (Grand General Assembly)
"IDDK", # İdare Dava Daireleri Kurulu
"VDDK", # Vergi Dava Daireleri Kurulu
"IBK", # İçtihatları Birleştirme Kurulu
"IIK", # İdari İşler Kurulu
"DBK", # Başkanlar Kurulu
# Military High Administrative Court
"AYIM", # Askeri Yüksek İdare Mahkemesi
"AYIMDK", # Askeri Yüksek İdare Mahkemesi Daireler Kurulu
"AYIMB", # Askeri Yüksek İdare Mahkemesi Başsavcılığı
"AYIM1", # Askeri Yüksek İdare Mahkemesi 1. Daire
"AYIM2", # Askeri Yüksek İdare Mahkemesi 2. Daire
"AYIM3" # Askeri Yüksek İdare Mahkemesi 3. Daire
]
# Mapping from abbreviated values to full Turkish API values
BIRIM_ADI_MAPPING = {
"ALL": None, # Will be handled specially in client
# Yargıtay Civil Chambers (1-23)
"H1": "1. Hukuk Dairesi", "H2": "2. Hukuk Dairesi", "H3": "3. Hukuk Dairesi",
"H4": "4. Hukuk Dairesi", "H5": "5. Hukuk Dairesi", "H6": "6. Hukuk Dairesi",
"H7": "7. Hukuk Dairesi", "H8": "8. Hukuk Dairesi", "H9": "9. Hukuk Dairesi",
"H10": "10. Hukuk Dairesi", "H11": "11. Hukuk Dairesi", "H12": "12. Hukuk Dairesi",
"H13": "13. Hukuk Dairesi", "H14": "14. Hukuk Dairesi", "H15": "15. Hukuk Dairesi",
"H16": "16. Hukuk Dairesi", "H17": "17. Hukuk Dairesi", "H18": "18. Hukuk Dairesi",
"H19": "19. Hukuk Dairesi", "H20": "20. Hukuk Dairesi", "H21": "21. Hukuk Dairesi",
"H22": "22. Hukuk Dairesi", "H23": "23. Hukuk Dairesi",
# Yargıtay Criminal Chambers (1-23)
"C1": "1. Ceza Dairesi", "C2": "2. Ceza Dairesi", "C3": "3. Ceza Dairesi",
"C4": "4. Ceza Dairesi", "C5": "5. Ceza Dairesi", "C6": "6. Ceza Dairesi",
"C7": "7. Ceza Dairesi", "C8": "8. Ceza Dairesi", "C9": "9. Ceza Dairesi",
"C10": "10. Ceza Dairesi", "C11": "11. Ceza Dairesi", "C12": "12. Ceza Dairesi",
"C13": "13. Ceza Dairesi", "C14": "14. Ceza Dairesi", "C15": "15. Ceza Dairesi",
"C16": "16. Ceza Dairesi", "C17": "17. Ceza Dairesi", "C18": "18. Ceza Dairesi",
"C19": "19. Ceza Dairesi", "C20": "20. Ceza Dairesi", "C21": "21. Ceza Dairesi",
"C22": "22. Ceza Dairesi", "C23": "23. Ceza Dairesi",
# Yargıtay Councils and Assemblies
"HGK": "Hukuk Genel Kurulu",
"CGK": "Ceza Genel Kurulu",
"BGK": "Büyük Genel Kurulu",
"HBK": "Hukuk Daireleri Başkanlar Kurulu",
"CBK": "Ceza Daireleri Başkanlar Kurulu",
# Danıştay Chambers (1-17)
"D1": "1. Daire", "D2": "2. Daire", "D3": "3. Daire", "D4": "4. Daire",
"D5": "5. Daire", "D6": "6. Daire", "D7": "7. Daire", "D8": "8. Daire",
"D9": "9. Daire", "D10": "10. Daire", "D11": "11. Daire", "D12": "12. Daire",
"D13": "13. Daire", "D14": "14. Daire", "D15": "15. Daire", "D16": "16. Daire",
"D17": "17. Daire",
# Danıştay Councils and Boards
"DBGK": "Büyük Gen.Kur.",
"IDDK": "İdare Dava Daireleri Kurulu",
"VDDK": "Vergi Dava Daireleri Kurulu",
"IBK": "İçtihatları Birleştirme Kurulu",
"IIK": "İdari İşler Kurulu",
"DBK": "Başkanlar Kurulu",
# Military High Administrative Court
"AYIM": "Askeri Yüksek İdare Mahkemesi",
"AYIMDK": "Askeri Yüksek İdare Mahkemesi Daireler Kurulu",
"AYIMB": "Askeri Yüksek İdare Mahkemesi Başsavcılığı",
"AYIM1": "Askeri Yüksek İdare Mahkemesi 1. Daire",
"AYIM2": "Askeri Yüksek İdare Mahkemesi 2. Daire",
"AYIM3": "Askeri Yüksek İdare Mahkemesi 3. Daire"
}
# Helper function to get full Turkish name from abbreviated value
def get_full_birim_adi(abbreviated_value: str) -> str:
"""Convert abbreviated birimAdi value to full Turkish name for API calls."""
if abbreviated_value == "ALL" or not abbreviated_value:
return "" # Empty string for ALL or None
return BIRIM_ADI_MAPPING.get(abbreviated_value, abbreviated_value)
# Helper function to validate abbreviated value
def is_valid_birim_adi(abbreviated_value: str) -> bool:
"""Check if abbreviated birimAdi value is valid."""
return abbreviated_value in BIRIM_ADI_MAPPING
+91
View File
@@ -0,0 +1,91 @@
# bedesten_mcp_module/models.py
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Literal, Union
from datetime import datetime
# Import compressed BirimAdiEnum for chamber filtering
from .enums import BirimAdiEnum
# Court Type Options for Unified Search
BedestenCourtTypeEnum = Literal[
"YARGITAYKARARI", # Yargıtay (Court of Cassation)
"DANISTAYKARAR", # Danıştay (Council of State)
"YERELHUKUK", # Local Civil Courts
"ISTINAFHUKUK", # Civil Courts of Appeals
"KYB" # Extraordinary Appeals (Kanun Yararına Bozma)
]
# Search Request Models
class BedestenSearchData(BaseModel):
pageSize: int = Field(..., description="Results per page (1-10)")
pageNumber: int = Field(..., description="Page number (1-indexed)")
itemTypeList: List[str] = Field(..., description="Court type filter (YARGITAYKARARI/DANISTAYKARAR/YERELHUKUK/ISTINAFHUKUK/KYB)")
phrase: str = Field(..., description="Search phrase. Supports: 'word', \"exact phrase\", +required, -exclude, AND/OR/NOT operators. No wildcards or regex.")
birimAdi: BirimAdiEnum = Field("ALL", description="""
Chamber filter (optional). Abbreviated values with Turkish names:
• Yargıtay: H1-H23 (1-23. Hukuk Dairesi), C1-C23 (1-23. Ceza Dairesi), HGK (Hukuk Genel Kurulu), CGK (Ceza Genel Kurulu), BGK (Büyük Genel Kurulu), HBK (Hukuk Daireleri Başkanlar Kurulu), CBK (Ceza Daireleri Başkanlar Kurulu)
• Danıştay: D1-D17 (1-17. Daire), DBGK (Büyük Gen.Kur.), IDDK (İdare Dava Daireleri Kurulu), VDDK (Vergi Dava Daireleri Kurulu), IBK (İçtihatları Birleştirme Kurulu), IIK (İdari İşler Kurulu), DBK (Başkanlar Kurulu), AYIM (Askeri Yüksek İdare Mahkemesi), AYIM1-3 (Askeri Yüksek İdare Mahkemesi 1-3. Daire)
""")
kararTarihiStart: Optional[str] = Field(None, description="Start date (ISO 8601 format)")
kararTarihiEnd: Optional[str] = Field(None, description="End date (ISO 8601 format)")
sortFields: List[str] = Field(default=["KARAR_TARIHI"], description="Sort fields")
sortDirection: str = Field(default="desc", description="Sort direction (asc/desc)")
class BedestenSearchRequest(BaseModel):
data: BedestenSearchData
applicationName: str = "UyapMevzuat"
paging: bool = True
# Search Response Models
class BedestenItemType(BaseModel):
name: str
description: str
class BedestenDecisionEntry(BaseModel):
documentId: str
itemType: BedestenItemType
birimId: Optional[str] = None
birimAdi: Optional[str]
esasNoYil: Optional[int] = None
esasNoSira: Optional[int] = None
kararNoYil: Optional[int] = None
kararNoSira: Optional[int] = None
kararTuru: Optional[str] = None
kararTarihi: str
kararTarihiStr: str
kesinlesmeDurumu: Optional[str] = None
kararNo: Optional[str] = None
esasNo: Optional[str] = None
class BedestenSearchDataResponse(BaseModel):
emsalKararList: List[BedestenDecisionEntry]
total: int
start: int
class BedestenSearchResponse(BaseModel):
data: Optional[BedestenSearchDataResponse]
metadata: Dict[str, Any]
# Document Request/Response Models
class BedestenDocumentRequestData(BaseModel):
documentId: str
class BedestenDocumentRequest(BaseModel):
data: BedestenDocumentRequestData
applicationName: str = "UyapMevzuat"
class BedestenDocumentData(BaseModel):
content: str # Base64 encoded HTML or PDF
mimeType: str
version: int
class BedestenDocumentResponse(BaseModel):
data: BedestenDocumentData
metadata: Dict[str, Any]
class BedestenDocumentMarkdown(BaseModel):
documentId: str = Field(..., description="The document ID (Belge Kimliği) from Bedesten")
markdown_content: Optional[str] = Field(None, description="The decision content (Karar İçeriği) converted to Markdown")
source_url: str = Field(..., description="The source URL (Kaynak URL) of the document")
mime_type: Optional[str] = Field(None, description="Original content type (İçerik Türü) (text/html or application/pdf)")
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
from fastmcp import Client
from mcp_server_main import app
import json
import asyncio
async def check_response_format():
client = Client(app)
async with client:
result = await client.call_tool('search_bedesten_unified', {
'phrase': 'mülkiyet',
'court_types': ['YARGITAYKARARI'],
'birimAdi': 'H1',
'pageSize': 3
})
if result and result.content:
data = json.loads(result.content[0].text)
print('Response keys:', list(data.keys()))
print('Sample response:', json.dumps(data, indent=2, ensure_ascii=False)[:500])
asyncio.run(check_response_format())
+27 -28
View File
@@ -1,13 +1,13 @@
# danistay_mcp_module/client.py
import asyncio
import httpx
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional
import logging
import html
import re
import tempfile
import os
import io
from markitdown import MarkItDown
from .models import (
@@ -35,8 +35,6 @@ class DanistayApiClient:
headers={
"Content-Type": "application/json; charset=UTF-8", # Arama endpoint'leri için
"Accept": "application/json, text/plain, */*", # Arama endpoint'leri için
# /getDokuman HTML döndürdüğü için Accept header'ı GET isteğinde farklı olabilir
# ama httpx genellikle bunu yönetir. Gerekirse özel header eklenebilir.
"X-Requested-With": "XMLHttpRequest",
},
timeout=request_timeout,
@@ -44,7 +42,7 @@ class DanistayApiClient:
)
def _prepare_keywords_for_api(self, keywords: List[str]) -> List[str]:
return [f'"{k.strip("\"")}"' for k in keywords if k and k.strip()]
return ['"' + k.strip('"') + '"' for k in keywords if k and k.strip()]
async def search_keyword_decisions(
self,
@@ -79,12 +77,16 @@ class DanistayApiClient:
mevzuatNumarasi=params.mevzuatNumarasi or "",
mevzuatAdi=params.mevzuatAdi or "",
madde=params.madde or "",
siralama=params.siralama,
siralamaDirection=params.siralamaDirection,
siralama="1",
siralamaDirection="desc",
pageSize=params.pageSize,
pageNumber=params.pageNumber
)
final_payload = {"data": data_for_payload.model_dump(exclude_defaults=False, exclude_none=False)}
# Create request dict and remove empty string fields to avoid API issues
payload_dict = data_for_payload.model_dump(exclude_defaults=False, exclude_none=False)
# Remove empty string fields that might cause API issues
cleaned_payload = {k: v for k, v in payload_dict.items() if v != ""}
final_payload = {"data": cleaned_payload}
logger.info(f"DanistayApiClient: Performing DETAILED search via {self.DETAILED_SEARCH_ENDPOINT} with payload: {final_payload}")
return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload)
@@ -126,33 +128,30 @@ class DanistayApiClient:
html_input_for_markdown = processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False) # Basic conversion
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_input_for_markdown.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
tmp_file.write(html_input_for_markdown) # Write the full HTML string
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
conversion_result = md_converter.convert(html_stream)
markdown_text = conversion_result.text_content
logger.info("DanistayApiClient: HTML to Markdown conversion successful.")
except Exception as e:
logger.error(f"DanistayApiClient: Error during MarkItDown HTML to Markdown conversion: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(self, document_id: str) -> DanistayDocumentMarkdown:
async def get_decision_document_as_markdown(self, id: str) -> DanistayDocumentMarkdown:
"""
Retrieves a specific Danıştay decision by ID and returns its content as Markdown.
The /getDokuman endpoint for Danıştay returns direct HTML.
The /getDokuman endpoint for Danıştay requires arananKelime parameter.
"""
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
# Add required arananKelime parameter - using empty string as minimum requirement
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={id}&arananKelime="
source_url = f"{self.BASE_URL}{document_api_url}"
logger.info(f"DanistayApiClient: Fetching Danistay document for Markdown (ID: {document_id}) from {source_url}")
logger.info(f"DanistayApiClient: Fetching Danistay document for Markdown (ID: {id}) from {source_url}")
try:
# For direct HTML response, we might want different headers if the API is sensitive,
@@ -164,27 +163,27 @@ class DanistayApiClient:
html_content_from_api = response.text
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"DanistayApiClient: Received empty or non-string HTML content for ID {document_id}.")
logger.warning(f"DanistayApiClient: Received empty or non-string HTML content for ID {id}.")
# Return with None markdown_content if HTML is effectively empty
return DanistayDocumentMarkdown(
document_id=document_id,
id=id,
markdown_content=None,
source_url=source_url
)
markdown_content = self._convert_html_to_markdown_danistay(html_content_from_api)
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_danistay, html_content_from_api)
return DanistayDocumentMarkdown(
document_id=document_id,
id=id,
markdown_content=markdown_content,
source_url=source_url
)
except httpx.RequestError as e:
logger.error(f"DanistayApiClient: HTTP error fetching Danistay document (ID: {document_id}): {e}")
logger.error(f"DanistayApiClient: HTTP error fetching Danistay document (ID: {id}): {e}")
raise
# Removed ValueError for JSON as Danistay /getDokuman returns direct HTML
except Exception as e: # Catches other errors like MarkItDown issues if they propagate
logger.error(f"DanistayApiClient: General error processing Danistay document (ID: {document_id}): {e}")
logger.error(f"DanistayApiClient: General error processing Danistay document (ID: {id}): {e}")
raise
async def close_client_session(self):
+31 -35
View File
@@ -1,11 +1,11 @@
# danistay_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
from typing import List, Optional, Dict, Any
class DanistayBaseSearchRequest(BaseModel):
"""Base model for common search parameters for Danistay."""
pageSize: int = Field(default=10, ge=1, le=100)
pageSize: int = Field(default=10, ge=1, le=10)
pageNumber: int = Field(default=1, ge=1)
# siralama and siralamaDirection are part of detailed search, not necessarily keyword search
# as per user's provided payloads.
@@ -21,11 +21,11 @@ class DanistayKeywordSearchRequestData(BaseModel):
class DanistayKeywordSearchRequest(BaseModel): # This is the model the MCP tool will accept
"""Model for keyword-based search request for Danistay."""
andKelimeler: List[str] = Field(default_factory=list, description="Keywords for AND logic, e.g., ['word1', 'word2']")
orKelimeler: List[str] = Field(default_factory=list, description="Keywords for OR logic.")
notAndKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT AND logic.")
notOrKelimeler: List[str] = Field(default_factory=list, description="Keywords for NOT OR logic.")
pageSize: int = Field(default=10, ge=1, le=100)
andKelimeler: List[str] = Field(default_factory=list, description="AND keywords")
orKelimeler: List[str] = Field(default_factory=list, description="OR keywords")
notAndKelimeler: List[str] = Field(default_factory=list, description="NOT AND keywords")
notOrKelimeler: List[str] = Field(default_factory=list, description="NOT OR keywords")
pageSize: int = Field(default=10, ge=1, le=10)
pageNumber: int = Field(default=1, ge=1)
class DanistayDetailedSearchRequestData(BaseModel): # Internal data model for detailed search payload
@@ -51,20 +51,18 @@ class DanistayDetailedSearchRequestData(BaseModel): # Internal data model for de
class DanistayDetailedSearchRequest(DanistayBaseSearchRequest): # MCP tool will accept this
"""Model for detailed search request for Danistay."""
daire: Optional[str] = Field(None, description="Chamber/Department name (e.g., '1. Daire').")
esasYil: Optional[str] = Field(None, description="Case year for 'Esas No'.")
esasIlkSiraNo: Optional[str] = Field(None, description="Starting sequence for 'Esas No'.")
esasSonSiraNo: Optional[str] = Field(None, description="Ending sequence for 'Esas No'.")
kararYil: Optional[str] = Field(None, description="Decision year for 'Karar No'.")
kararIlkSiraNo: Optional[str] = Field(None, description="Starting sequence for 'Karar No'.")
kararSonSiraNo: Optional[str] = Field(None, description="Ending sequence for 'Karar No'.")
baslangicTarihi: Optional[str] = Field(None, description="Start date for decision (DD.MM.YYYY).")
bitisTarihi: Optional[str] = Field(None, description="End date for decision (DD.MM.YYYY).")
mevzuatNumarasi: Optional[str] = Field(None, description="Legislation number.")
mevzuatAdi: Optional[str] = Field(None, description="Legislation name.")
madde: Optional[str] = Field(None, description="Article number.")
siralama: str = Field("1", description="Sorting criteria (e.g., 1: Esas No, 3: Karar Tarihi).")
siralamaDirection: str = Field("desc", description="Sorting direction ('asc' or 'desc').")
daire: str = Field("", description="Chamber")
esasYil: str = Field("", description="Case year")
esasIlkSiraNo: str = Field("", description="Start case no")
esasSonSiraNo: str = Field("", description="End case no")
kararYil: str = Field("", description="Decision year")
kararIlkSiraNo: str = Field("", description="Start decision no")
kararSonSiraNo: str = Field("", description="End decision no")
baslangicTarihi: str = Field("", description="Start date")
bitisTarihi: str = Field("", description="End date")
mevzuatNumarasi: str = Field("", description="Law number")
mevzuatAdi: str = Field("", description="Law name")
madde: str = Field("", description="Article")
# Add a general keyword field if detailed search also supports it
# arananKelime: Optional[str] = Field(None, description="General keyword for detailed search.")
@@ -76,36 +74,34 @@ class DanistayApiDecisionEntry(BaseModel):
id: str
# The API response for keyword search uses "daireKurul", detailed search example uses "daire".
# We use an alias to handle both and map to a consistent field name "chamber".
chamber: Optional[str] = Field(None, alias="daire", alt_alias="daireKurul", description="The chamber or board.")
esasNo: Optional[str] = Field(None)
kararNo: Optional[str] = Field(None)
kararTarihi: Optional[str] = Field(None)
arananKelime: Optional[str] = Field(None, description="Matched keyword if provided in response.")
chamber: str = Field("", alias="daire", description="Chamber")
esasNo: str = Field("", description="Case number")
kararNo: str = Field("", description="Decision number")
kararTarihi: str = Field("", description="Decision date")
arananKelime: str = Field("", description="Keyword")
# index: Optional[int] = None # Present in response, can be added if needed by MCP tool
# siraNo: Optional[int] = None # Present in detailed response, can be added
document_url: Optional[HttpUrl] = Field(None, description="URL to the full document, constructed by the client.")
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
class Config:
populate_by_name = True # Important for alias to work
extra = 'ignore' # Ignore any extra fields from API not defined in model
model_config = ConfigDict(populate_by_name=True, extra='ignore') # Important for alias to work and ignore extra fields
class DanistayApiResponseInnerData(BaseModel):
"""Model for the inner 'data' object in the Danistay API search response."""
data: List[DanistayApiDecisionEntry]
recordsTotal: int
recordsFiltered: int
draw: Optional[int] = Field(None, description="Draw counter from API, usually for DataTables.")
draw: int = Field(0, description="Draw counter")
class DanistayApiResponse(BaseModel):
"""Model for the complete search response from the Danistay API."""
data: DanistayApiResponseInnerData
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata from API.")
data: Optional[DanistayApiResponseInnerData] = Field(None, description="Response data, can be null when no results found")
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) from API.")
class DanistayDocumentMarkdown(BaseModel):
"""Model for a Danistay decision document, containing only Markdown content."""
document_id: str
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
id: str
markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.")
source_url: HttpUrl
class CompactDanistaySearchResult(BaseModel):
+23 -23
View File
@@ -1,13 +1,13 @@
# emsal_mcp_module/client.py
import asyncio
import httpx
# from bs4 import BeautifulSoup # Uncomment if needed for advanced HTML pre-processing
from typing import Dict, Any, List, Optional
import logging
import html
import re
import tempfile
import os
import io
from markitdown import MarkItDown
from .models import (
@@ -64,7 +64,11 @@ class EmsalApiClient:
pageNumber=params.page_number
)
final_payload = {"data": data_for_api_payload.model_dump(by_alias=True, exclude_none=True)}
# Create request dict and remove empty string fields to avoid API issues
payload_dict = data_for_api_payload.model_dump(by_alias=True, exclude_none=True)
# Remove empty string fields that might cause API issues
cleaned_payload = {k: v for k, v in payload_dict.items() if v != ""}
final_payload = {"data": cleaned_payload}
logger.info(f"EmsalApiClient: Performing DETAILED search with payload: {final_payload}")
return await self._execute_api_search(self.DETAILED_SEARCH_ENDPOINT, final_payload)
@@ -114,33 +118,29 @@ class EmsalApiClient:
html_input_for_markdown = content
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_input_for_markdown.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
conversion_result = md_converter.convert(temp_file_path)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
conversion_result = md_converter.convert(html_stream)
markdown_text = conversion_result.text_content
logger.info("EmsalApiClient: HTML to Markdown conversion successful.")
except Exception as e:
logger.error(f"EmsalApiClient: Error during MarkItDown HTML to Markdown conversion for Emsal: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(self, document_id: str) -> EmsalDocumentMarkdown:
async def get_decision_document_as_markdown(self, id: str) -> EmsalDocumentMarkdown:
"""
Retrieves a specific Emsal decision by ID and returns its content as Markdown.
Assumes Emsal /getDokuman endpoint returns JSON with HTML content in the 'data' field.
"""
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={id}"
source_url = f"{self.BASE_URL}{document_api_url}"
logger.info(f"EmsalApiClient: Fetching Emsal document for Markdown (ID: {document_id}) from {source_url}")
logger.info(f"EmsalApiClient: Fetching Emsal document for Markdown (ID: {id}) from {source_url}")
try:
response = await self.http_client.get(document_api_url)
@@ -151,24 +151,24 @@ class EmsalApiClient:
html_content_from_api = response_json.get("data")
if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {document_id}.")
return EmsalDocumentMarkdown(document_id=document_id, markdown_content=None, source_url=source_url)
logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {id}.")
return EmsalDocumentMarkdown(id=id, markdown_content=None, source_url=source_url)
markdown_content = self._clean_html_and_convert_to_markdown_emsal(html_content_from_api)
markdown_content = await asyncio.to_thread(self._clean_html_and_convert_to_markdown_emsal, html_content_from_api)
return EmsalDocumentMarkdown(
document_id=document_id,
id=id,
markdown_content=markdown_content,
source_url=source_url
)
except httpx.RequestError as e:
logger.error(f"EmsalApiClient: HTTP error fetching Emsal document (ID: {document_id}): {e}")
logger.error(f"EmsalApiClient: HTTP error fetching Emsal document (ID: {id}): {e}")
raise
except ValueError as e:
logger.error(f"EmsalApiClient: ValueError processing Emsal document response (ID: {document_id}): {e}")
logger.error(f"EmsalApiClient: ValueError processing Emsal document response (ID: {id}): {e}")
raise
except Exception as e:
logger.error(f"EmsalApiClient: General error processing Emsal document (ID: {document_id}): {e}")
logger.error(f"EmsalApiClient: General error processing Emsal document (ID: {id}): {e}")
raise
async def close_client_session(self):
+33 -36
View File
@@ -1,6 +1,6 @@
# emsal_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
from typing import List, Optional, Dict, Any
class EmsalDetailedSearchRequestData(BaseModel):
@@ -12,12 +12,12 @@ class EmsalDetailedSearchRequestData(BaseModel):
"""
arananKelime: Optional[str] = ""
Bam_Hukuk_Mahkemeleri: Optional[str] = Field(None, alias="Bam Hukuk Mahkemeleri")
Hukuk_Mahkemeleri: Optional[str] = Field(None, alias="Hukuk Mahkemeleri")
Bam_Hukuk_Mahkemeleri: str = Field("", alias="Bam Hukuk Mahkemeleri")
Hukuk_Mahkemeleri: str = Field("", alias="Hukuk Mahkemeleri")
# Add other specific court type fields from the form if they are separate keys in payload
# E.g., "Ceza Mahkemeleri", "İdari Mahkemeler" etc.
birimHukukMah: Optional[str] = Field("", description="List of selected Regional Civil Chambers, '+' separated.")
birimHukukMah: Optional[str] = Field("", description="Regional chambers (+ separated)")
esasYil: Optional[str] = ""
esasIlkSiraNo: Optional[str] = ""
@@ -32,68 +32,65 @@ class EmsalDetailedSearchRequestData(BaseModel):
pageSize: int
pageNumber: int
class Config:
populate_by_name = True # Enables use of alias in serialization (when dumping to dict for payload)
# anystr_strip_whitespace = True # Optional: strip whitespace from strings
model_config = ConfigDict(populate_by_name=True) # Enables use of alias in serialization (when dumping to dict for payload)
class EmsalSearchRequest(BaseModel): # This is the model the MCP tool will accept
"""Model for Emsal detailed search request, with user-friendly field names."""
keyword: Optional[str] = Field(None, description="Keyword to search.")
keyword: str = Field("", description="Keyword")
selected_bam_civil_court: Optional[str] = Field(None, description="Selected BAM Civil Court (maps to 'Bam Hukuk Mahkemeleri' payload key).")
selected_civil_court: Optional[str] = Field(None, description="Selected Civil Court (maps to 'Hukuk Mahkemeleri' payload key).")
selected_regional_civil_chambers: Optional[List[str]] = Field(default_factory=list, description="Selected Regional Civil Chambers (for 'birimHukukMah', joined by '+').")
selected_bam_civil_court: str = Field("", description="BAM Civil Court")
selected_civil_court: str = Field("", description="Civil Court")
selected_regional_civil_chambers: List[str] = Field(default_factory=list, description="Regional chambers")
case_year_esas: Optional[str] = Field(None, description="Case year for 'Esas No'.")
case_start_seq_esas: Optional[str] = Field(None, description="Starting sequence for 'Esas No'.")
case_end_seq_esas: Optional[str] = Field(None, description="Ending sequence for 'Esas No'.")
case_year_esas: str = Field("", description="Case year")
case_start_seq_esas: str = Field("", description="Start case no")
case_end_seq_esas: str = Field("", description="End case no")
decision_year_karar: Optional[str] = Field(None, description="Decision year for 'Karar No'.")
decision_start_seq_karar: Optional[str] = Field(None, description="Starting sequence for 'Karar No'.")
decision_end_seq_karar: Optional[str] = Field(None, description="Ending sequence for 'Karar No'.")
decision_year_karar: str = Field("", description="Decision year")
decision_start_seq_karar: str = Field("", description="Start decision no")
decision_end_seq_karar: str = Field("", description="End decision no")
start_date: Optional[str] = Field(None, description="Start date for decision (DD.MM.YYYY).")
end_date: Optional[str] = Field(None, description="End date for decision (DD.MM.YYYY).")
start_date: str = Field("", description="Start date (DD.MM.YYYY)")
end_date: str = Field("", description="End date (DD.MM.YYYY)")
sort_criteria: str = Field("1", description="Sorting criteria (e.g., 1: Esas No).")
sort_direction: str = Field("desc", description="Sorting direction ('asc' or 'desc').")
sort_criteria: str = Field("1", description="Sort by")
sort_direction: str = Field("desc", description="Direction")
page_number: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1, le=100)
page_size: int = Field(default=10, ge=1, le=10)
class EmsalApiDecisionEntry(BaseModel):
"""Model for an individual decision entry from the Emsal API search response."""
id: str
daire: Optional[str] = Field(None, description="The chamber/court that made the decision.")
esasNo: Optional[str] = Field(None)
kararNo: Optional[str] = Field(None)
kararTarihi: Optional[str] = Field(None)
arananKelime: Optional[str] = Field(None, description="Matched keyword from the search.")
durum: Optional[str] = Field(None, description="Status of the decision (e.g., 'KESİNLEŞMEDİ').")
daire: str = Field("", description="Chamber")
esasNo: str = Field("", description="Case number")
kararNo: str = Field("", description="Decision number")
kararTarihi: str = Field("", description="Decision date")
arananKelime: str = Field("", description="Keyword")
durum: str = Field("", description="Status")
# index: Optional[int] = None # Present in Emsal response, can be added if tool needs it
document_url: Optional[HttpUrl] = Field(None, description="URL to the full document, constructed by the client.")
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
class Config:
extra = 'ignore'
model_config = ConfigDict(extra='ignore')
class EmsalApiResponseInnerData(BaseModel):
"""Model for the inner 'data' object in the Emsal API search response."""
data: List[EmsalApiDecisionEntry]
recordsTotal: int
recordsFiltered: int
draw: Optional[int] = Field(None, description="Draw counter from API, usually for DataTables.")
draw: int = Field(0, description="Draw counter (Çizim Sayıcısı) from API, usually for DataTables.")
class EmsalApiResponse(BaseModel):
"""Model for the complete search response from the Emsal API."""
data: EmsalApiResponseInnerData
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata from API, if any.")
data: Optional[EmsalApiResponseInnerData] = None
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata (Meta Veri) from API, if any.")
class EmsalDocumentMarkdown(BaseModel):
"""Model for an Emsal decision document, containing only Markdown content."""
document_id: str
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
id: str
markdown_content: str = Field("", description="The decision content (Karar İçeriği) converted to Markdown.")
source_url: HttpUrl
class CompactEmsalSearchResult(BaseModel):
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
# gib_mcp_module/__init__.py
+355
View File
@@ -0,0 +1,355 @@
# gib_mcp_module/client.py
import asyncio
import httpx
import io
import logging
import math
from typing import Optional, Any, Dict
from markitdown import MarkItDown
from .models import (
GibSearchRequest,
GibOzelgeSummary,
GibSearchResult,
GibDocumentMarkdown,
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
class GibApiClient:
"""
API client for searching and retrieving GİB özelgeler (Turkish Revenue
Administration tax rulings) via the public gib.gov.tr JSON API.
The endpoint is a single POST list endpoint; document retrieval is done
by filtering the same endpoint with an exact `id`.
"""
BASE_URL = "https://gib.gov.tr/api"
LIST_PATH = "/gibportal/mevzuat/ozelge/list"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
# Fixed filter values required by the backend
_REQUIRED_STATUS = 2
_REQUIRED_DELETED = False
_REQUIRED_KTYPE = 99 # ktype=99 selects özelge
_SORT_FIELD = "ozelgeTarih"
_SORT_TYPE = "DESC"
def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "application/json",
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.7",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; yargi-mcp/1.0; +https://github.com/saidsurucu/yargi-mcp)",
},
timeout=request_timeout,
verify=True,
follow_redirects=True,
)
@staticmethod
def _normalize_date(value: str, end_of_day: bool = False) -> Optional[str]:
"""
Accept 'YYYY-MM-DD' or full ISO 8601; always return full ISO 8601.
GİB backend rejects date-only strings.
"""
if not value:
return None
v = value.strip()
if not v:
return None
# Already ISO with time component
if "T" in v:
return v
# Simple YYYY-MM-DD - expand to start/end of day
suffix = "T23:59:59.999Z" if end_of_day else "T00:00:00.000Z"
return f"{v}{suffix}"
def _build_search_body(self, params: GibSearchRequest) -> Dict[str, Any]:
body: Dict[str, Any] = {
"status": self._REQUIRED_STATUS,
"deleted": self._REQUIRED_DELETED,
"ktype": self._REQUIRED_KTYPE,
}
keywords = params.keywords.strip()
kanun_no = params.kanunNo.strip()
# Frontend sets title/kanunNo/description to the SAME value; the backend
# ORs across them. If the caller supplies both, combine them so kanun_no
# still biases toward ruling text, while keywords remain primary.
search_term = keywords or kanun_no
if keywords and kanun_no and kanun_no not in keywords:
search_term = f"{keywords} {kanun_no}"
if search_term:
body["title"] = search_term
body["kanunNo"] = search_term
body["description"] = search_term
if params.ozelgeNo.strip():
body["ozelgeNo"] = params.ozelgeNo.strip()
if params.kanunId and params.kanunId > 0:
body["kanunIds"] = [params.kanunId]
start_iso = self._normalize_date(params.ozelgeStartDate, end_of_day=False)
end_iso = self._normalize_date(params.ozelgeEndDate, end_of_day=True)
if start_iso:
body["ozelgeStartDate"] = start_iso
if end_iso:
body["ozelgeEndDate"] = end_iso
return body
def _build_query_params(self, page_1_indexed: int, page_size: int) -> Dict[str, Any]:
# API expects 0-indexed page
zero_indexed = max(0, page_1_indexed - 1)
return {
"page": zero_indexed,
"size": page_size,
"sortFieldName": self._SORT_FIELD,
"sortType": self._SORT_TYPE,
}
@staticmethod
def _to_summary(item: Dict[str, Any]) -> Optional[GibOzelgeSummary]:
if not isinstance(item, dict):
return None
raw_id = item.get("id")
if raw_id is None:
return None
try:
ozelge_id = int(raw_id)
except (TypeError, ValueError):
return None
return GibOzelgeSummary(
id=ozelge_id,
ozelgeNo=item.get("ozelgeNo"),
ozelgeTarih=item.get("ozelgeTarih"),
title=item.get("title"),
kanunNo=item.get("kanunNo"),
kanunTitle=item.get("kanunTitle"),
siteLink=item.get("siteLink"),
)
async def search_ozelge(self, params: GibSearchRequest) -> GibSearchResult:
"""Search GİB özelgeler."""
body = self._build_search_body(params)
query = self._build_query_params(params.page, params.pageSize)
logger.info(
"GibApiClient: search page=%s size=%s body_keys=%s",
params.page, params.pageSize, sorted(body.keys()),
)
try:
resp = await self.http_client.post(self.LIST_PATH, params=query, json=body)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPStatusError as e:
logger.error("GibApiClient: HTTP %s during search", e.response.status_code)
return GibSearchResult(
ozelgeler=[],
total_results=0,
total_pages=0,
current_page=params.page,
page_size=params.pageSize,
)
except Exception as e:
logger.error("GibApiClient: search request failed: %s", e)
return GibSearchResult(
ozelgeler=[],
total_results=0,
total_pages=0,
current_page=params.page,
page_size=params.pageSize,
)
container = (payload or {}).get("resultContainer") or {}
raw_items = container.get("content") or []
summaries = []
for raw in raw_items:
summary = self._to_summary(raw)
if summary is not None:
summaries.append(summary)
total_results = container.get("totalElements") or 0
total_pages = container.get("totalPages") or 0
try:
total_results = int(total_results)
except (TypeError, ValueError):
total_results = 0
try:
total_pages = int(total_pages)
except (TypeError, ValueError):
total_pages = 0
return GibSearchResult(
ozelgeler=summaries,
total_results=total_results,
total_pages=total_pages,
current_page=params.page,
page_size=params.pageSize,
)
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
"""Convert HTML content to Markdown using MarkItDown with BytesIO."""
if not html_content:
return None
try:
html_bytes = html_content.encode("utf-8")
html_stream = io.BytesIO(html_bytes)
md_converter = MarkItDown(enable_plugins=False)
result = md_converter.convert(html_stream)
return result.text_content
except Exception as e:
logger.error("GibApiClient: HTML→Markdown conversion failed: %s", e)
return None
@staticmethod
def _build_header_block(item: Dict[str, Any]) -> str:
"""Build a small Markdown header block summarising the ruling metadata."""
parts = []
title = item.get("title")
if title:
parts.append(f"# {title}")
meta_lines = []
if item.get("ozelgeNo"):
meta_lines.append(f"**Sayı:** {item['ozelgeNo']}")
if item.get("ozelgeTarih"):
meta_lines.append(f"**Tarih:** {item['ozelgeTarih']}")
if item.get("kanunTitle"):
kanun_no = item.get("kanunNo")
if kanun_no:
meta_lines.append(f"**Kanun:** {item['kanunTitle']} ({kanun_no})")
else:
meta_lines.append(f"**Kanun:** {item['kanunTitle']}")
if item.get("siteLink"):
meta_lines.append(f"**Kaynak:** {item['siteLink']}")
if meta_lines:
parts.append("\n".join(meta_lines))
return "\n\n".join(parts).strip()
async def get_ozelge_document(
self, ozelge_id: int, page_number: int = 1
) -> GibDocumentMarkdown:
"""Retrieve a single özelge and return its paginated Markdown form."""
logger.info(
"GibApiClient: fetching özelge id=%s page=%s", ozelge_id, page_number
)
if not isinstance(ozelge_id, int) or ozelge_id <= 0:
return GibDocumentMarkdown(
ozelge_id=ozelge_id if isinstance(ozelge_id, int) else 0,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message="ozelge_id must be a positive integer",
)
body = {
"status": self._REQUIRED_STATUS,
"deleted": self._REQUIRED_DELETED,
"ktype": self._REQUIRED_KTYPE,
"id": ozelge_id,
}
query = {"page": 0, "size": 1}
try:
resp = await self.http_client.post(self.LIST_PATH, params=query, json=body)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPStatusError as e:
msg = f"HTTP {e.response.status_code} when fetching özelge {ozelge_id}"
logger.error("GibApiClient: %s", msg)
return GibDocumentMarkdown(
ozelge_id=ozelge_id,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message=msg,
)
except Exception as e:
msg = f"Request failed: {e}"
logger.error("GibApiClient: %s", msg)
return GibDocumentMarkdown(
ozelge_id=ozelge_id,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message=msg,
)
container = (payload or {}).get("resultContainer") or {}
content = container.get("content") or []
if not content:
return GibDocumentMarkdown(
ozelge_id=ozelge_id,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message=f"Özelge {ozelge_id} not found",
)
item = content[0] if isinstance(content[0], dict) else {}
description_html = item.get("description") or ""
markdown_body = (await asyncio.to_thread(self._convert_html_to_markdown, description_html)) or ""
header_block = self._build_header_block(item)
if header_block and markdown_body:
full_markdown = f"{header_block}\n\n---\n\n{markdown_body}"
else:
full_markdown = header_block or markdown_body
if not full_markdown.strip():
return GibDocumentMarkdown(
ozelge_id=ozelge_id,
ozelge_no=item.get("ozelgeNo"),
title=item.get("title"),
ozelge_tarih=item.get("ozelgeTarih"),
kanun_title=item.get("kanunTitle"),
kanun_no=item.get("kanunNo"),
site_link=item.get("siteLink"),
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message="Document body is empty",
)
total_pages = max(
1, math.ceil(len(full_markdown) / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
)
current_page_clamped = max(1, min(page_number, total_pages))
start = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end = start + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
chunk = full_markdown[start:end]
return GibDocumentMarkdown(
ozelge_id=ozelge_id,
ozelge_no=item.get("ozelgeNo"),
title=item.get("title"),
ozelge_tarih=item.get("ozelgeTarih"),
kanun_title=item.get("kanunTitle"),
kanun_no=item.get("kanunNo"),
site_link=item.get("siteLink"),
markdown_chunk=chunk,
current_page=current_page_clamped,
total_pages=total_pages,
is_paginated=total_pages > 1,
error_message=None,
)
async def close_client_session(self):
if hasattr(self, "http_client") and self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("GibApiClient: HTTP client session closed.")
+64
View File
@@ -0,0 +1,64 @@
# gib_mcp_module/models.py
from pydantic import BaseModel, Field
from typing import List, Optional
class GibSearchRequest(BaseModel):
"""
Request model for searching GİB özelgeler (Turkish Revenue Administration tax rulings).
GİB (Gelir İdaresi Başkanlığı) publishes official tax-ruling letters
("özelge") responding to taxpayer questions on VAT, income tax,
corporate tax, stamp duty, and other tax matters. 18,000+ rulings
are searchable via the public gib.gov.tr API.
"""
keywords: str = Field("", description="Keywords searched across title, kanunNo and description (Turkish)")
ozelgeNo: str = Field("", description="Exact özelge reference number (e.g., 'E-40247694-130-15524')")
kanunNo: str = Field("", description="Law number filter, e.g. '3065' for KDV")
kanunId: int = Field(0, description="Optional numeric law ID filter (0=ignore)")
ozelgeStartDate: str = Field("", description="Start date YYYY-MM-DD or full ISO 8601")
ozelgeEndDate: str = Field("", description="End date YYYY-MM-DD or full ISO 8601")
page: int = Field(1, ge=1, description="Page number (1-indexed)")
pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)")
class GibOzelgeSummary(BaseModel):
"""Summary of a single GİB özelge from search results (no full HTML)."""
id: int = Field(..., description="Numeric özelge ID for document retrieval")
ozelgeNo: Optional[str] = Field(None, description="Official ruling reference number")
ozelgeTarih: Optional[str] = Field(None, description="Ruling date (ISO datetime)")
title: Optional[str] = Field(None, description="Subject/title of the ruling")
kanunNo: Optional[str] = Field(None, description="Law number (e.g., '3065')")
kanunTitle: Optional[str] = Field(None, description="Law title (e.g., 'KATMA DEĞER VERGİSİ KANUNU')")
siteLink: Optional[str] = Field(None, description="Direct URL to the ruling on gib.gov.tr")
class GibSearchResult(BaseModel):
"""Response model for GİB özelge search results."""
ozelgeler: List[GibOzelgeSummary] = Field(default_factory=list, description="Matching özelge summaries")
total_results: int = Field(0, description="Total number of matching özelgeler across all pages")
total_pages: int = Field(0, description="Total number of pages for this query")
current_page: int = Field(1, description="Current page (1-indexed)")
page_size: int = Field(10, description="Results per page")
class GibDocumentMarkdown(BaseModel):
"""
GİB özelge document converted to paginated Markdown.
Long rulings are split into 5000-character chunks; request successive
pages via page_number to read the full text.
"""
ozelge_id: int = Field(..., description="Numeric özelge ID")
ozelge_no: Optional[str] = Field(None, description="Official ruling reference number")
title: Optional[str] = Field(None, description="Subject/title of the ruling")
ozelge_tarih: Optional[str] = Field(None, description="Ruling date (ISO datetime)")
kanun_title: Optional[str] = Field(None, description="Related law title")
kanun_no: Optional[str] = Field(None, description="Related law number")
site_link: Optional[str] = Field(None, description="Direct URL to the ruling on gib.gov.tr")
markdown_chunk: Optional[str] = Field(None, description="Current 5000-character Markdown chunk")
current_page: int = Field(1, description="Current page number (1-indexed)")
total_pages: int = Field(0, description="Total pages for the full Markdown content")
is_paginated: bool = Field(False, description="True if split across multiple pages")
error_message: Optional[str] = Field(None, description="Populated when retrieval failed")
-18
View File
@@ -1,18 +0,0 @@
@echo off
echo Yargi MCP Kurulum Script'i (install.py) baslatiliyor...
REM Python'in PATH'de oldugunu varsayiyoruz.
REM Kullanici sistemine gore "python" veya "py -3" veya "python3" olabilir.
REM Oncelikle "python" deneyelim.
python install.py
if errorlevel 1 (
echo "python install.py" komutu basarisiz oldu. "py -3 install.py" deneniyor...
py -3 install.py
if errorlevel 1 (
echo "py -3 install.py" komutu da basarisiz oldu.
echo Lutfen Python 3'un sisteminizde kurulu ve PATH'de oldugundan emin olun.
)
)
echo.
pause
-302
View File
@@ -1,302 +0,0 @@
# install.py
import subprocess
import sys
import os
import shutil
import platform
from urllib.parse import urlencode, urljoin, quote
# --- Yapılandırma ---
MCP_SERVER_SCRIPT_NAME = "mcp_server_main.py"
CLAUDE_TOOL_NAME = "Yargı MCP"
DEPENDENCIES_FOR_FASTMCP = [
"httpx", "beautifulsoup4", "markitdown", "pydantic", "aiohttp"
]
# --- Yardımcı Fonksiyonlar ---
def print_info(message):
print(f"[INFO] {message}")
def print_warning(message):
print(f"[UYARI] {message}")
def print_error(message):
print(f"[HATA] {message}")
def command_exists(command_parts):
"""Bir komutun sistemde var olup olmadığını kontrol eder ve yolunu döndürür."""
try:
command_to_check = command_parts[0] if isinstance(command_parts, list) else command_parts
found_path = shutil.which(command_to_check)
if found_path:
return found_path
if platform.system() == "Windows" and not command_to_check.endswith(".exe"):
# .exe olmadan da PATH'de bulunabilir (örn: pyenv shims)
# ama yine de .exe ile de kontrol edelim
path_with_exe = shutil.which(command_to_check + ".exe")
if path_with_exe:
return path_with_exe
return None
except Exception:
return None
def run_command(command_parts, capture_output_flag=False, check_return_code=True, shell=False, cwd=None, log_output_on_success=False):
"""Verilen komutu çalıştırır."""
cmd_str_for_log = ' '.join(command_parts) if isinstance(command_parts, list) else command_parts
print_info(f"Komut çalıştırılıyor: {cmd_str_for_log}")
kwargs = {
"text": True,
"shell": shell,
"cwd": cwd,
"encoding": 'utf-8',
"errors": 'replace' # Handles potential decoding errors in output
}
if capture_output_flag:
kwargs["capture_output"] = True
# Else, stdout/stderr go to console by default (unless shell redirects them)
try:
process = subprocess.run(command_parts, **kwargs)
if capture_output_flag:
if log_output_on_success and process.returncode == 0:
if process.stdout: print_info(f"Stdout:\n{process.stdout.strip()}")
if process.stderr: print_warning(f"Stderr:\n{process.stderr.strip()}")
elif process.returncode != 0: # Always log output on error if captured
if process.stdout: print_error(f"Hata Stdout:\n{process.stdout.strip()}")
if process.stderr: print_error(f"Hata Stderr:\n{process.stderr.strip()}")
if check_return_code and process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, cmd_str_for_log, output=process.stdout, stderr=process.stderr)
return process
except subprocess.CalledProcessError as e:
# run_command already printed details if capture_output_flag was true
if not capture_output_flag: # If output went to console, just print a simpler error
print_error(f"Komut hatası (return code {e.returncode}): {cmd_str_for_log}")
raise
except FileNotFoundError:
print_error(f"Komut bulunamadı: {command_parts[0] if isinstance(command_parts, list) else command_parts.split()[0]}")
raise
except Exception as e:
print_error(f"Komut çalıştırılırken beklenmedik hata ({cmd_str_for_log}): {type(e).__name__} - {e}")
raise
def get_python_executable():
"""Kullanılabilir Python 3 çalıştırılabilir dosyasını bulur."""
print_info("Python 3 yorumlayıcısı aranıyor...")
# Önce mevcut çalışan Python'u dene
current_python = sys.executable
if current_python:
try:
print_info(f"Mevcut Python deneniyor: {current_python}")
result = run_command([current_python, "-c", "import sys; assert sys.version_info.major == 3, 'Not Python 3'"], capture_output_flag=True, log_output_on_success=False)
if result.returncode == 0:
print_info(f"Kullanılacak Python: {current_python}")
return current_python
except Exception as e:
print_warning(f"Mevcut Python ({current_python}) kontrol edilirken sorun: {e}")
# PATH'deki python3 ve python komutlarını dene
for cmd_name in ["python3", "python"]:
found_cmd_path = command_exists(cmd_name)
if found_cmd_path:
try:
print_info(f"PATH'de bulunan '{cmd_name}' deneniyor: {found_cmd_path}")
result = run_command([found_cmd_path, "-c", "import sys; assert sys.version_info.major == 3, 'Not Python 3'; print(sys.executable)"], capture_output_flag=True, log_output_on_success=False)
if result.returncode == 0 and result.stdout:
resolved_path = result.stdout.strip()
print_info(f"Kullanılacak Python: {resolved_path} ('{cmd_name}' komutu ile bulundu)")
return resolved_path
except Exception as e:
print_warning(f"'{cmd_name}' ({found_cmd_path}) kontrol edilirken sorun: {e}")
print_error("Python 3 sisteminizde bulunamadı veya PATH'e doğru şekilde eklenmemiş.")
print_error("Lütfen Python 3'ü (https://www.python.org/downloads/) kurun.")
sys.exit(1)
# --- Kurulum Fonksiyonları ---
def install_uv(python_exe_path):
print_info("Adım 1/3: uv kontrol ediliyor/kuruluyor...")
uv_executable = command_exists("uv")
if uv_executable:
print_info(f"uv zaten kurulu: {uv_executable}")
run_command([uv_executable, "--version"], capture_output_flag=True, log_output_on_success=True)
return uv_executable
print_info("uv kurulu değil. Kurulum denenecek...")
try:
if platform.system() == "Windows":
print_info("PowerShell ile uv indirme ve kurma script'i çalıştırılacak.")
run_command([
"powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-NonInteractive",
"-Command", "try { irm https://astral.sh/uv/install.ps1 | iex } catch { Write-Error $_; exit 1 }"
], shell=False)
else:
print_info("curl ile uv kurulum script'i çalıştırılacak.")
process = subprocess.run("curl -LsSf https://astral.sh/uv/install.sh | sh", shell=True, capture_output=True, text=True, encoding='utf-8', errors='replace')
if process.stdout: print_info(f"uv install script stdout:\n{process.stdout}")
if process.stderr: print_warning(f"uv install script stderr:\n{process.stderr}")
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, "curl ... | sh")
uv_executable = command_exists("uv")
if not uv_executable: # PATH'e hemen yansımamış olabilir, bilinen yerleri kontrol et
common_paths_uv = []
if platform.system() == "Windows":
cargo_uv_path = os.path.join(os.environ.get("USERPROFILE", ""), ".cargo", "bin", "uv.exe")
localapp_uv_path = os.path.join(os.environ.get("LOCALAPPDATA", ""), "uv", "uv.exe")
if os.path.exists(cargo_uv_path): common_paths_uv.append(cargo_uv_path)
if os.path.exists(localapp_uv_path): common_paths_uv.append(localapp_uv_path)
else: # macOS / Linux
common_paths_uv.extend([
os.path.join(os.environ.get("HOME", ""), ".cargo", "bin", "uv"),
os.path.join(os.environ.get("HOME", ""), ".local", "bin", "uv")
])
for p_uv in common_paths_uv:
if command_exists(p_uv): uv_executable = p_uv; break
if uv_executable and command_exists(uv_executable):
print_info(f"uv başarıyla kuruldu/bulundu: {uv_executable}")
run_command([uv_executable, "--version"], capture_output_flag=True, log_output_on_success=True)
return uv_executable
else: # Son çare pip
print_warning("uv resmi script ile kuruldu/bulundu ancak PATH'de doğrulanamadı. pip ile deneniyor...")
run_command([python_exe_path, "-m", "pip", "install", "uv"])
uv_executable = command_exists("uv")
if uv_executable:
print_info(f"uv pip ile başarıyla kuruldu: {uv_executable}")
run_command([uv_executable, "--version"], capture_output_flag=True, log_output_on_success=True)
return uv_executable
print_error("uv pip ile de kurulamadı. Lütfen manuel kurulum yapın: https://astral.sh/uv")
return None
except Exception as e:
print_error(f"uv kurulumu sırasında genel bir hata oluştu: {e}")
print_warning("Lütfen uv'yi manuel olarak kurmayı deneyin: https://astral.sh/uv")
return None
def install_fastmcp_cli(python_exe_path, uv_exe_path): # uv_exe_path artık kullanılmıyor
"""fastmcp CLI'yi kontrol eder ve gerekirse pip/pip3 ile kurar."""
print_info("Adım 2/3: fastmcp CLI kontrol ediliyor/kuruluyor...")
fastmcp_executable = command_exists("fastmcp")
if fastmcp_executable:
print_info(f"fastmcp CLI zaten kurulu: {fastmcp_executable}")
run_command([fastmcp_executable, "version"], capture_output_flag=True, log_output_on_success=True)
return fastmcp_executable
print_info("fastmcp CLI kurulu değil. pip/pip3 ile kurulum denenecek...")
try:
pip_cmd_to_try = [python_exe_path, "-m", "pip", "install", "fastmcp"]
print_info(f"{' '.join(pip_cmd_to_try)} komutu deneniyor...")
run_command(pip_cmd_to_try)
fastmcp_executable = command_exists("fastmcp")
if fastmcp_executable:
print_info(f"fastmcp CLI başarıyla kuruldu: {fastmcp_executable}")
run_command([fastmcp_executable, "version"], capture_output_flag=True, log_output_on_success=True)
return fastmcp_executable
else:
scripts_dir = os.path.dirname(python_exe_path)
if platform.system() == "Windows" and not scripts_dir.lower().endswith("scripts"):
scripts_dir = os.path.join(scripts_dir, "Scripts")
potential_fastmcp_path = os.path.join(scripts_dir, "fastmcp.exe" if platform.system() == "Windows" else "fastmcp")
if command_exists(potential_fastmcp_path):
print_info(f"fastmcp CLI şu yolda bulundu: {potential_fastmcp_path}")
run_command([potential_fastmcp_path, "version"], capture_output_flag=True, log_output_on_success=True)
return potential_fastmcp_path
else:
print_error("fastmcp CLI kuruldu ancak PATH'de veya bilinen Python script yollarında bulunamadı.")
print_error("Lütfen terminalinizi yeniden başlatın veya PATH'i manuel güncelleyin.")
return None
except Exception as e:
print_error(f"fastmcp CLI kurulumu sırasında hata oluştu: {e}")
return None
def install_tool_to_claude_desktop(fastmcp_exe_path):
"""Yargı MCP sunucusunu Claude Desktop'a kurar."""
print_info(f"Adım 3/3: \"{CLAUDE_TOOL_NAME}\" Claude Desktop'a kuruluyor...")
if not os.path.exists(MCP_SERVER_SCRIPT_NAME):
print_error(f"Ana sunucu script'i '{MCP_SERVER_SCRIPT_NAME}' bulunamadı.")
print_error("Lütfen bu script'i ana sunucu script'inin bulunduğu dizinde çalıştırın.")
return False
dependencies_cmd_part = []
for dep in DEPENDENCIES_FOR_FASTMCP:
dependencies_cmd_part.extend(["--with", dep])
install_command = [
fastmcp_exe_path, "install", MCP_SERVER_SCRIPT_NAME,
"--name", CLAUDE_TOOL_NAME
] + dependencies_cmd_part
try:
process = run_command(install_command, capture_output_flag=True, check_return_code=False, log_output_on_success=False)
if process.returncode == 0:
print_info(f"\"{CLAUDE_TOOL_NAME}\" başarıyla Claude Desktop'a kuruldu/güncellendi.")
if process.stdout: print_info(f"fastmcp install stdout:\n{process.stdout.strip()}")
if process.stderr: print_warning(f"fastmcp install stderr:\n{process.stderr.strip()}")
return True
else:
error_output = (process.stdout or "") + (process.stderr or "")
if "claude app not found" in error_output.lower():
print_error("Claude Desktop uygulaması sisteminizde bulunamadı veya algılanamadı.")
print_error("Lütfen Claude Desktop'ın kurulu ve çalışır durumda olduğundan emin olun.")
print_error("Claude Desktop'ı https://claude.ai/download adresinden indirebilirsiniz.")
else:
print_error(f"Sunucu Claude Desktop'a kurulurken hata oluştu (return code {process.returncode}).")
print_error("Lütfen fastmcp CLI'nin düzgün çalıştığından emin olun.")
if process.stderr: print_error(f"fastmcp install stderr:\n{process.stderr.strip()}")
if process.stdout: print_info(f"fastmcp install stdout (hata durumunda):\n{process.stdout.strip()}")
return False
except Exception as e:
print_error(f"Sunucu Claude Desktop'a kurulurken genel bir hata oluştu: {e}")
return False
# --- Ana Kurulum Mantığı ---
def main():
print("===================================================================")
print(" Yargi MCP Sunucusu - Python Kurulum Script'i")
print("===================================================================")
if platform.system() == "Windows":
confirm = input("Bu script, uv ve fastmcp araclarini kuracak ve Yargi MCP sunucusunu Claude Desktop'a entegre edecektir. Devam etmek istiyor musunuz? (E/H): ")
if confirm.lower() != 'e':
print_info("Kurulum kullanıcı tarafından iptal edildi.")
sys.exit(0)
python_executable = get_python_executable()
uv_executable_path = install_uv(python_executable) # uv hala öneriliyor fastmcp install için
fastmcp_executable_path = install_fastmcp_cli(python_executable, uv_executable_path) # uv_exe_path burada kullanılmıyor
if not fastmcp_executable_path:
print_error("fastmcp CLI kurulumu başarısız oldu. Kurulum sonlandırılıyor.")
sys.exit(1)
if not install_tool_to_claude_desktop(fastmcp_executable_path):
print_error("Claude Desktop'a kurulum başarısız oldu.")
sys.exit(1)
print_info("===================================================================")
print_info(" KURULUM BAŞARIYLA TAMAMLANDI!")
print_info("===================================================================")
print_info(f"- \"{CLAUDE_TOOL_NAME}\" aracı Claude Desktop'a eklenmiş olmalıdır.")
print_info("- Değişikliklerin etkili olması için Claude Desktop'ı yeniden başlatmanız gerekebilir.")
print_info("- Eğer uv veya fastmcp PATH'e yeni eklendiyse, terminalinizi de yeniden başlatmanız gerekebilir.")
if __name__ == "__main__":
try:
main()
except SystemExit:
pass # sys.exit() çağrıldığında script sonlansın
except Exception as e:
print_error(f"Beklenmedik bir genel hata oluştu: {e}")
sys.exit(1)
finally:
if platform.system() == "Windows":
input("Çıkmak için Enter tuşuna basın...")
else:
print("Kurulum script'i tamamlandı.")
-54
View File
@@ -1,54 +0,0 @@
#!/bin/bash
# --- Script Bilgileri ---
echo "==================================================================="
echo " Yargi MCP Sunucusu - Kurulum Başlatıcı (macOS/Linux)"
echo "==================================================================="
echo " Bu script, Yargi MCP sunucusunun kurulumu için gerekli olan"
echo " Python script'ini (install.py) çalıştıracaktır."
echo ""
read -p "Devam etmek istiyor musunuz? (E/H): " continue_script
if [[ ! "$continue_script" =~ ^[Ee]$ ]]; then
echo "Kurulum iptal edildi."
exit 0
fi
echo ""
# --- Python Yorumlayıcısını Bul ve install.py'yi Çalıştır ---
PYTHON_EXECUTABLE=""
# Öncelikle python3'ü dene
if command -v python3 &>/dev/null; then
PYTHON_EXECUTABLE="python3"
# Sonra python'u dene (Python 3 olduğundan emin olmak için install.py içinde kontrol var)
elif command -v python &>/dev/null; then
PYTHON_EXECUTABLE="python"
fi
if [ -z "$PYTHON_EXECUTABLE" ]; then
echo "[HATA] Sisteminizde Python 3 bulunamadı veya PATH'e eklenmemiş."
echo "Lütfen Python 3'ü (https://www.python.org/downloads/) kurun."
exit 1
fi
echo "[INFO] '$PYTHON_EXECUTABLE install.py' komutu çalıştırılıyor..."
echo "-------------------------------------------------------------------"
"$PYTHON_EXECUTABLE" install.py
# install.py script'inin çıkış kodunu kontrol et
INSTALL_EXIT_CODE=$?
echo "-------------------------------------------------------------------"
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
echo "[INFO] install.py script'i başarıyla tamamlandı."
else
echo "[HATA] install.py script'i bir hatayla sonlandı (Çıkış Kodu: $INSTALL_EXIT_CODE)."
echo "[HATA] Lütfen yukarıdaki hata mesajlarını kontrol edin."
fi
echo ""
# Pencerenin hemen kapanmaması için (özellikle çift tıklanarak çalıştırılırsa)
read -p "Kurulum script'i tamamlandı. Çıkmak için Enter tuşuna basın..."
exit $INSTALL_EXIT_CODE
View File
+507
View File
@@ -0,0 +1,507 @@
# kik_mcp_module/client_v2.py
import asyncio
import base64
import httpx
import logging
import uuid
import ssl
import os
from typing import Optional
from datetime import datetime
# Cryptography imports for AES-256-CBC encryption of document IDs
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
HAS_CRYPTOGRAPHY = True
except ImportError:
HAS_CRYPTOGRAPHY = False
from .models_v2 import (
KikV2DecisionType, KikV2SearchPayload, KikV2SearchPayloadDk, KikV2SearchPayloadMk,
KikV2RequestData, KikV2QueryRequest, KikV2KeyValuePair,
KikV2SearchResponse, KikV2SearchResponseDk, KikV2SearchResponseMk,
KikV2SearchResult, KikV2CompactDecision, KikV2DocumentMarkdown
)
logger = logging.getLogger(__name__)
class KikV2ApiClient:
"""
New KIK v2 API Client for https://ekapv2.kik.gov.tr
This client uses the modern JSON-based API endpoint that provides
better structured data compared to the legacy form-based API.
"""
BASE_URL = "https://ekapv2.kik.gov.tr"
# Endpoint mappings for different decision types
ENDPOINTS = {
KikV2DecisionType.UYUSMAZLIK: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari",
KikV2DecisionType.DUZENLEYICI: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk",
KikV2DecisionType.MAHKEME: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk"
}
# AES-256-CBC encryption key for document ID encryption (reverse engineered from ekapv2.kik.gov.tr Angular app)
# This key is used to encrypt numeric gundemMaddesiId values to 64-character hex hashes for document URLs
DOCUMENT_ID_ENCRYPTION_KEY = bytes([
236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174,
174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176
])
# AES-192-CBC key (environment.r8fact) used by the Angular HTTP interceptor to sign every
# request. The server decrypts X-Custom-Request-Ts and rejects stale timestamps with
# HTTP 401 "İstek zaman aşımına uğradı.", so these headers MUST be generated per-request
# with the current timestamp (see _generate_security_headers).
REQUEST_SIGNING_KEY = b"Qm2LtXR0aByP69vZNKef4wMJ" # UTF-8 bytes, 24 chars -> AES-192
@staticmethod
def encrypt_document_id(numeric_id: str) -> str:
"""
Encrypt a numeric KİK gundemMaddesiId to the 64-character hex hash
used in document URLs.
Algorithm: AES-256-CBC with PKCS7 padding
Output format: IV (16 bytes hex) + Ciphertext (16 bytes hex) = 64 chars
Args:
numeric_id: The numeric document ID from search results (e.g., "177280")
Returns:
64-character hex string for use in document URL KararId parameter
"""
if not HAS_CRYPTOGRAPHY:
raise ImportError("cryptography library required for document ID encryption")
# Generate random IV (16 bytes)
iv = os.urandom(16)
# Create AES-CBC cipher with the encryption key
cipher = Cipher(
algorithms.AES(KikV2ApiClient.DOCUMENT_ID_ENCRYPTION_KEY),
modes.CBC(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
# Encode plaintext and apply PKCS7 padding
plaintext = numeric_id.encode('utf-8')
block_size = 16
padding_len = block_size - (len(plaintext) % block_size)
padded_plaintext = plaintext + bytes([padding_len] * padding_len)
# Encrypt
ciphertext = encryptor.update(padded_plaintext) + encryptor.finalize()
# Return IV + ciphertext as lowercase hex (64 characters total)
return iv.hex() + ciphertext.hex()
def __init__(self, request_timeout: float = 60.0):
# Create SSL context with legacy server support
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Enable legacy server connect option for older SSL implementations (Python 3.12+)
if hasattr(ssl, 'OP_LEGACY_SERVER_CONNECT'):
ssl_context.options |= ssl.OP_LEGACY_SERVER_CONNECT
# Set broader cipher suite support including legacy ciphers
ssl_context.set_ciphers('ALL:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
verify=ssl_context,
headers={
"Accept": "application/json",
"Accept-Language": "tr",
"Content-Type": "application/json",
"Origin": self.BASE_URL,
"Referer": f"{self.BASE_URL}/sorgulamalar/kurul-kararlari",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
"api-version": "v1",
"sec-ch-ua": '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"'
},
timeout=request_timeout
)
# Generate security headers (these might need to be updated based on API requirements)
self.security_headers = self._generate_security_headers()
def _sign_request_value(self, plaintext: str, iv: bytes) -> str:
"""AES-192-CBC encrypt a value with the request signing key, return base64 ciphertext."""
cipher = Cipher(
algorithms.AES(self.REQUEST_SIGNING_KEY),
modes.CBC(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
data = plaintext.encode("utf-8")
block_size = 16
padding_len = block_size - (len(data) % block_size)
padded = data + bytes([padding_len] * padding_len)
ciphertext = encryptor.update(padded) + encryptor.finalize()
return base64.b64encode(ciphertext).decode("ascii")
def _generate_security_headers(self) -> dict:
"""
Generate the custom security headers required by the KIK v2 API.
Mirrors the Angular HTTP interceptor on ekapv2.kik.gov.tr: a random GUID and a
current-timestamp (epoch milliseconds) are AES-192-CBC encrypted with environment.r8fact
using a fresh random IV. The IV is sent as -Siv, the encrypted timestamp as -Ts, and the
encrypted GUID as -R8id. The server validates the decrypted timestamp's freshness, so these
MUST be regenerated on every request; stale values yield HTTP 401 "İstek zaman aşımına uğradı.".
"""
if not HAS_CRYPTOGRAPHY:
raise ImportError("cryptography library required for KIK v2 request signing")
request_guid = str(uuid.uuid4())
iv = os.urandom(16)
timestamp_ms = str(int(datetime.now().timestamp() * 1000))
return {
"X-Custom-Request-Guid": request_guid,
"X-Custom-Request-R8id": self._sign_request_value(request_guid, iv),
"X-Custom-Request-Siv": base64.b64encode(iv).decode("ascii"),
"X-Custom-Request-Ts": self._sign_request_value(timestamp_ms, iv),
}
def _build_search_payload(self,
decision_type: KikV2DecisionType,
karar_metni: str = "",
karar_no: str = "",
basvuran: str = "",
idare_adi: str = "",
baslangic_tarihi: str = "",
bitis_tarihi: str = ""):
"""Build the search payload for KIK v2 API."""
key_value_pairs = []
# Add non-empty search criteria
if karar_metni:
key_value_pairs.append(KikV2KeyValuePair(key="KararMetni", value=karar_metni))
if karar_no:
key_value_pairs.append(KikV2KeyValuePair(key="KararNo", value=karar_no))
if basvuran:
key_value_pairs.append(KikV2KeyValuePair(key="BasvuranAdi", value=basvuran))
if idare_adi:
key_value_pairs.append(KikV2KeyValuePair(key="IdareAdi", value=idare_adi))
if baslangic_tarihi:
key_value_pairs.append(KikV2KeyValuePair(key="BaslangicTarihi", value=baslangic_tarihi))
if bitis_tarihi:
key_value_pairs.append(KikV2KeyValuePair(key="BitisTarihi", value=bitis_tarihi))
# If no search criteria provided, use a generic search
if not key_value_pairs:
key_value_pairs.append(KikV2KeyValuePair(key="KararMetni", value=""))
query_request = KikV2QueryRequest(keyValueOfstringanyType=key_value_pairs)
request_data = KikV2RequestData(keyValuePairs=query_request)
# Return appropriate payload based on decision type
if decision_type == KikV2DecisionType.UYUSMAZLIK:
return KikV2SearchPayload(sorgulaKurulKararlari=request_data)
elif decision_type == KikV2DecisionType.DUZENLEYICI:
return KikV2SearchPayloadDk(sorgulaKurulKararlariDk=request_data)
elif decision_type == KikV2DecisionType.MAHKEME:
return KikV2SearchPayloadMk(sorgulaKurulKararlariMk=request_data)
else:
raise ValueError(f"Unsupported decision type: {decision_type}")
async def search_decisions(self,
decision_type: KikV2DecisionType = KikV2DecisionType.UYUSMAZLIK,
karar_metni: str = "",
karar_no: str = "",
basvuran: str = "",
idare_adi: str = "",
baslangic_tarihi: str = "",
bitis_tarihi: str = "") -> KikV2SearchResult:
"""
Search KIK decisions using the v2 API.
Args:
decision_type: Type of decision to search (uyusmazlik/duzenleyici/mahkeme)
karar_metni: Decision text search
karar_no: Decision number (e.g., "2025/UH.II-1801")
basvuran: Applicant name
idare_adi: Administration name
baslangic_tarihi: Start date (YYYY-MM-DD format)
bitis_tarihi: End date (YYYY-MM-DD format)
Returns:
KikV2SearchResult with compact decision list
"""
logger.info(f"KikV2ApiClient: Searching {decision_type.value} decisions with criteria - karar_metni: '{karar_metni}', karar_no: '{karar_no}', basvuran: '{basvuran}'")
try:
# Build request payload
payload = self._build_search_payload(
decision_type=decision_type,
karar_metni=karar_metni,
karar_no=karar_no,
basvuran=basvuran,
idare_adi=idare_adi,
baslangic_tarihi=baslangic_tarihi,
bitis_tarihi=bitis_tarihi
)
# Update security headers for this request
headers = {**self.http_client.headers, **self._generate_security_headers()}
# Get the appropriate endpoint for this decision type
endpoint = self.ENDPOINTS[decision_type]
# Make API request
response = await self.http_client.post(
endpoint,
json=payload.model_dump(),
headers=headers
)
response.raise_for_status()
response_data = response.json()
logger.debug(f"KikV2ApiClient: Raw API response structure: {type(response_data)}")
# Parse the API response based on decision type
if decision_type == KikV2DecisionType.UYUSMAZLIK:
api_response = KikV2SearchResponse(**response_data)
result_data = api_response.SorgulaKurulKararlariResponse.SorgulaKurulKararlariResult
elif decision_type == KikV2DecisionType.DUZENLEYICI:
api_response = KikV2SearchResponseDk(**response_data)
result_data = api_response.SorgulaKurulKararlariDkResponse.SorgulaKurulKararlariDkResult
elif decision_type == KikV2DecisionType.MAHKEME:
api_response = KikV2SearchResponseMk(**response_data)
result_data = api_response.SorgulaKurulKararlariMkResponse.SorgulaKurulKararlariMkResult
else:
raise ValueError(f"Unsupported decision type: {decision_type}")
# Check for API errors
if result_data.hataKodu and result_data.hataKodu != "0":
logger.warning(f"KikV2ApiClient: API returned error - Code: {result_data.hataKodu}, Message: {result_data.hataMesaji}")
return KikV2SearchResult(
decisions=[],
total_records=0,
page=1,
error_code=result_data.hataKodu,
error_message=result_data.hataMesaji
)
# Convert to compact format
compact_decisions = []
total_count = 0
for decision_group in result_data.KurulKararTutanakDetayListesi:
for decision_detail in decision_group.KurulKararTutanakDetayi:
compact_decision = KikV2CompactDecision(
kararNo=decision_detail.kararNo,
kararTarihi=decision_detail.kararTarihi,
basvuran=decision_detail.basvuran,
idareAdi=decision_detail.idareAdi,
basvuruKonusu=decision_detail.basvuruKonusu,
gundemMaddesiId=decision_detail.gundemMaddesiId,
decision_type=decision_type.value
)
compact_decisions.append(compact_decision)
total_count += 1
logger.info(f"KikV2ApiClient: Found {total_count} decisions")
return KikV2SearchResult(
decisions=compact_decisions,
total_records=total_count,
page=1,
error_code="0",
error_message=""
)
except httpx.HTTPStatusError as e:
logger.error(f"KikV2ApiClient: HTTP error during search: {e.response.status_code} - {e.response.text}")
return KikV2SearchResult(
decisions=[],
total_records=0,
page=1,
error_code="HTTP_ERROR",
error_message=f"HTTP {e.response.status_code}: {e.response.text}"
)
except Exception as e:
logger.error(f"KikV2ApiClient: Unexpected error during search: {str(e)}")
return KikV2SearchResult(
decisions=[],
total_records=0,
page=1,
error_code="UNEXPECTED_ERROR",
error_message=str(e)
)
async def get_document_markdown(self, document_id: str) -> KikV2DocumentMarkdown:
"""
Get KİK decision document content in Markdown format.
This method uses a two-step process:
1. Call GetSorgulamaUrl endpoint to get the actual document URL
2. Use httpx to fetch the document content
Args:
document_id: The gundemMaddesiId from search results
Returns:
KikV2DocumentMarkdown with document content converted to Markdown
"""
logger.info(f"KikV2ApiClient: Getting document for ID: {document_id}")
if not document_id or not document_id.strip():
return KikV2DocumentMarkdown(
document_id=document_id,
kararNo="",
markdown_content="",
source_url="",
error_message="Document ID is required"
)
try:
# Step 1: Get the actual document URL using GetSorgulamaUrl endpoint
logger.info(f"KikV2ApiClient: Step 1 - Getting document URL for ID: {document_id}")
# Update security headers for this request
headers = {**self.http_client.headers, **self._generate_security_headers()}
# Call GetSorgulamaUrl to get the real document URL
url_payload = {"sorguSayfaTipi": 2} # As shown in curl example
url_response = await self.http_client.post(
"/b_ihalearaclari/api/KurulKararlari/GetSorgulamaUrl",
json=url_payload,
headers=headers
)
url_response.raise_for_status()
url_data = url_response.json()
# Get the base document URL from API response
base_document_url = url_data.get("sorgulamaUrl", "")
if not base_document_url:
return KikV2DocumentMarkdown(
document_id=document_id,
kararNo="",
markdown_content="",
source_url="",
error_message="Could not get document URL from GetSorgulamaUrl API"
)
# If document_id is numeric, encrypt it to get the KararId hash
# The web interface uses AES-256-CBC encrypted hashes for document URLs
karar_id = document_id
if document_id.isdigit():
try:
karar_id = self.encrypt_document_id(document_id)
logger.info(f"KikV2ApiClient: Encrypted numeric ID {document_id} to hash: {karar_id}")
except Exception as enc_error:
logger.warning(f"KikV2ApiClient: Could not encrypt document ID, using as-is: {enc_error}")
# Construct full document URL with the encrypted KararId
document_url = f"{base_document_url}?KararId={karar_id}"
logger.info(f"KikV2ApiClient: Step 2 - Retrieved document URL: {document_url}")
except Exception as e:
logger.error(f"KikV2ApiClient: Error getting document URL for ID {document_id}: {str(e)}")
# Fallback to old method if GetSorgulamaUrl fails
# Also encrypt numeric IDs in fallback path
karar_id = document_id
if document_id.isdigit():
try:
karar_id = self.encrypt_document_id(document_id)
logger.info(f"KikV2ApiClient: Encrypted numeric ID in fallback: {karar_id}")
except Exception as enc_error:
logger.warning(f"KikV2ApiClient: Could not encrypt in fallback: {enc_error}")
document_url = f"https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId={karar_id}"
logger.info(f"KikV2ApiClient: Falling back to direct URL: {document_url}")
try:
# Step 2: Use httpx to get the document content
logger.info(f"KikV2ApiClient: Step 2 - Using httpx to retrieve document from: {document_url}")
# Create a separate httpx client for document retrieval with HTML headers
doc_ssl_context = ssl.create_default_context()
doc_ssl_context.check_hostname = False
doc_ssl_context.verify_mode = ssl.CERT_NONE
if hasattr(ssl, 'OP_LEGACY_SERVER_CONNECT'):
doc_ssl_context.options |= ssl.OP_LEGACY_SERVER_CONNECT
doc_ssl_context.set_ciphers('ALL:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
async with httpx.AsyncClient(
verify=doc_ssl_context,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "tr,en-US;q=0.5",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
},
timeout=60.0,
follow_redirects=True
) as doc_client:
response = await doc_client.get(document_url)
response.raise_for_status()
html_content = response.text
logger.info(f"KikV2ApiClient: Retrieved content via httpx, length: {len(html_content)}")
# Convert HTML to Markdown using MarkItDown with BytesIO
try:
from markitdown import MarkItDown
from io import BytesIO
md = MarkItDown()
html_bytes = html_content.encode('utf-8')
html_stream = BytesIO(html_bytes)
# markitdown is sync; offload to thread so HTML parsing doesn't
# block the event-loop / other in-flight MCP requests.
result = await asyncio.to_thread(md.convert_stream, html_stream, file_extension=".html")
markdown_content = result.text_content
return KikV2DocumentMarkdown(
document_id=document_id,
kararNo="",
markdown_content=markdown_content,
source_url=document_url,
error_message=""
)
except ImportError:
return KikV2DocumentMarkdown(
document_id=document_id,
kararNo="",
markdown_content="MarkItDown library not available",
source_url=document_url,
error_message="MarkItDown library not installed"
)
except Exception as e:
logger.error(f"KikV2ApiClient: Error retrieving document {document_id}: {str(e)}")
return KikV2DocumentMarkdown(
document_id=document_id,
kararNo="",
markdown_content="",
source_url=document_url,
error_message=str(e)
)
async def close_client_session(self):
"""Close HTTP client session."""
await self.http_client.aclose()
logger.info("KikV2ApiClient: HTTP client session closed.")
+147
View File
@@ -0,0 +1,147 @@
# kik_mcp_module/models_v2.py
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional
from datetime import datetime
from enum import Enum
# New KIK v2 API Models
class KikV2DecisionType(str, Enum):
"""KIK v2 Decision Types with corresponding endpoints."""
UYUSMAZLIK = "uyusmazlik" # Disputes - GetKurulKararlari
DUZENLEYICI = "duzenleyici" # Regulatory - GetKurulKararlariDk
MAHKEME = "mahkeme" # Court - GetKurulKararlariMk
class KikV2SearchRequest(BaseModel):
"""Model for KIK v2 API search request."""
KararMetni: str = Field("", description="Decision text search query")
KararNo: str = Field("", description="Decision number (e.g., '2025/UH.II-1801')")
BasvuranAdi: str = Field("", description="Applicant name")
IdareAdi: str = Field("", description="Administration name")
BaslangicTarihi: str = Field("", description="Start date (YYYY-MM-DD)")
BitisTarihi: str = Field("", description="End date (YYYY-MM-DD)")
class KikV2KeyValuePair(BaseModel):
"""Key-value pair for KIK v2 API request."""
key: str
value: str
class KikV2QueryRequest(BaseModel):
"""Nested query structure for KIK v2 API."""
keyValueOfstringanyType: List[KikV2KeyValuePair]
class KikV2RequestData(BaseModel):
"""Main request data structure for KIK v2 API."""
keyValuePairs: KikV2QueryRequest
# Request Payloads for different decision types
class KikV2SearchPayload(BaseModel):
"""Complete payload for KIK v2 API search - Uyuşmazlık (Disputes)."""
sorgulaKurulKararlari: KikV2RequestData
class KikV2SearchPayloadDk(BaseModel):
"""Complete payload for KIK v2 API search - Düzenleyici (Regulatory)."""
sorgulaKurulKararlariDk: KikV2RequestData
class KikV2SearchPayloadMk(BaseModel):
"""Complete payload for KIK v2 API search - Mahkeme (Court)."""
sorgulaKurulKararlariMk: KikV2RequestData
# Response Models
class KikV2DecisionDetail(BaseModel):
"""Individual decision detail from KIK v2 API response."""
resmiGazeteMukerrerSayi: str = Field("", description="Official Gazette duplicate number")
itiraz: str = Field("", description="Objection")
yayinlanmaTarihi: str = Field("", description="Publication date")
idareAdi: str = Field("", description="Administration name")
uzmanTCKN: str = Field("", description="Expert TCKN")
resmiGazeteTarihi: str = Field("", description="Official Gazette date")
basvuruKonusu: str = Field("", description="Application subject")
kararTurKod: str = Field("", description="Decision type code")
kararTurAciklama: str = Field("", description="Decision type description")
karar: str = Field("", description="Decision text")
kararNo: str = Field("", description="Decision number")
resmiGazeteSayisi: str = Field("", description="Official Gazette number")
inceleme: str = Field("", description="Review")
basvuruTarihi: str = Field("", description="Application date")
kararNitelikKod: str = Field("", description="Decision nature code")
resmiGazeteMukerrer: str = Field("", description="Official Gazette duplicate")
basvuruSayisi: str = Field("", description="Application number")
basvuran: str = Field("", description="Applicant")
kararNitelik: str = Field("", description="Decision nature")
uyusmazlikKararNo: str = Field("", description="Dispute decision number")
kurulNo: str = Field("", description="Board number")
gundemMaddesiSiraNo: str = Field("", description="Agenda item sequence")
kararTarihi: str = Field("", description="Decision date (ISO format)")
dosyaBirimKodu: str = Field("", description="File unit code")
gundemMaddesiId: str = Field("", description="Agenda item ID")
class KikV2DecisionGroup(BaseModel):
"""Group of decision details."""
KurulKararTutanakDetayi: List[KikV2DecisionDetail] = Field(alias="kurulKararTutanakDetayi")
model_config = ConfigDict(populate_by_name=True)
class KikV2SearchResultData(BaseModel):
"""Search result data structure."""
hataKodu: str = Field("", description="Error code")
hataMesaji: str = Field("", description="Error message")
KurulKararTutanakDetayListesi: List[KikV2DecisionGroup]
model_config = ConfigDict(populate_by_name=True)
class KikV2SearchResultWrapper(BaseModel):
"""Wrapper for search result."""
SorgulaKurulKararlariResult: KikV2SearchResultData
# Base Response Models
class KikV2SearchResponse(BaseModel):
"""Complete KIK v2 API search response for Uyuşmazlık (Disputes)."""
SorgulaKurulKararlariResponse: KikV2SearchResultWrapper
# Düzenleyici Kararlar (Regulatory Decisions) Response Models
class KikV2SearchResultWrapperDk(BaseModel):
"""Wrapper for regulatory decisions search result."""
SorgulaKurulKararlariDkResult: KikV2SearchResultData
class KikV2SearchResponseDk(BaseModel):
"""Complete KIK v2 API search response for Düzenleyici (Regulatory) decisions."""
SorgulaKurulKararlariDkResponse: KikV2SearchResultWrapperDk
# Mahkeme Kararlar (Court Decisions) Response Models
class KikV2SearchResultWrapperMk(BaseModel):
"""Wrapper for court decisions search result."""
SorgulaKurulKararlariMkResult: KikV2SearchResultData
class KikV2SearchResponseMk(BaseModel):
"""Complete KIK v2 API search response for Mahkeme (Court) decisions."""
SorgulaKurulKararlariMkResponse: KikV2SearchResultWrapperMk
# Simplified Models for MCP Tools
class KikV2CompactDecision(BaseModel):
"""Compact decision format for MCP tool responses."""
kararNo: str = Field("", description="Decision number")
kararTarihi: str = Field("", description="Decision date")
basvuran: str = Field("", description="Applicant")
idareAdi: str = Field("", description="Administration")
basvuruKonusu: str = Field("", description="Application subject")
gundemMaddesiId: str = Field("", description="Document ID for retrieval")
decision_type: str = Field("", description="Decision type (uyusmazlik/duzenleyici/mahkeme)")
class KikV2SearchResult(BaseModel):
"""Compact search results for MCP tools."""
decisions: List[KikV2CompactDecision]
total_records: int = Field(0, description="Total number of decisions found")
page: int = Field(1, description="Current page number")
error_code: str = Field("", description="API error code")
error_message: str = Field("", description="API error message")
class KikV2DocumentMarkdown(BaseModel):
"""Document content in Markdown format."""
document_id: str = Field("", description="Document ID")
kararNo: str = Field("", description="Decision number")
markdown_content: str = Field("", description="Decision content in Markdown")
source_url: str = Field("", description="Source URL")
error_message: str = Field("", description="Error message if retrieval failed")
+1
View File
@@ -0,0 +1 @@
# kvkk_mcp_module/__init__.py
+373
View File
@@ -0,0 +1,373 @@
# kvkk_mcp_module/client.py
import asyncio
import httpx
from bs4 import BeautifulSoup
from typing import List, Optional, Dict, Any
import logging
import os
import re
import io
import math
from urllib.parse import urljoin, urlparse, parse_qs
from markitdown import MarkItDown
from pydantic import HttpUrl
from .models import (
KvkkSearchRequest,
KvkkDecisionSummary,
KvkkSearchResult,
KvkkDocumentMarkdown
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
class KvkkApiClient:
"""
API client for searching and retrieving KVKK (Personal Data Protection Authority) decisions
using Brave Search API for discovery and direct HTTP requests for content retrieval.
"""
BRAVE_API_URL = "https://api.search.brave.com/res/v1/web/search"
KVKK_BASE_URL = "https://www.kvkk.gov.tr"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
def __init__(self, request_timeout: float = 60.0):
"""Initialize the KVKK API client."""
self.brave_api_token = os.getenv("BRAVE_API_TOKEN")
if not self.brave_api_token:
# Fallback to provided free token
self.brave_api_token = "BSAuaRKB-dvSDSQxIN0ft1p2k6N82Kq"
logger.info("Using fallback Brave API token (limited free token)")
else:
logger.info("Using Brave API token from environment variable")
self.http_client = httpx.AsyncClient(
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
timeout=request_timeout,
verify=True,
follow_redirects=True
)
def _construct_search_query(self, keywords: str) -> str:
"""Construct the search query for Brave API."""
base_query = 'site:kvkk.gov.tr "karar özeti"'
if keywords.strip():
return f"{base_query} {keywords.strip()}"
return base_query
def _extract_decision_id_from_url(self, url: str) -> Optional[str]:
"""Extract decision ID from KVKK decision URL."""
try:
# Example URL: https://www.kvkk.gov.tr/Icerik/7288/2021-1303
parsed_url = urlparse(url)
path_parts = parsed_url.path.strip('/').split('/')
if len(path_parts) >= 3 and path_parts[0] == 'Icerik':
# Extract the decision ID from the path
decision_id = '/'.join(path_parts[1:]) # e.g., "7288/2021-1303"
return decision_id
except Exception as e:
logger.debug(f"Could not extract decision ID from URL {url}: {e}")
return None
def _extract_decision_metadata_from_title(self, title: str) -> Dict[str, Optional[str]]:
"""Extract decision metadata from title string."""
metadata = {
"decision_date": None,
"decision_number": None
}
if not title:
return metadata
# Extract decision date (DD/MM/YYYY format)
date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', title)
if date_match:
metadata["decision_date"] = date_match.group(1)
# Extract decision number (YYYY/XXXX format)
number_match = re.search(r'(\d{4}/\d+)', title)
if number_match:
metadata["decision_number"] = number_match.group(1)
return metadata
async def search_decisions(self, params: KvkkSearchRequest) -> KvkkSearchResult:
"""Search for KVKK decisions using Brave API."""
search_query = self._construct_search_query(params.keywords)
logger.info(f"KvkkApiClient: Searching with query: {search_query}")
try:
# Calculate offset for pagination
offset = (params.page - 1) * params.pageSize
response = await self.http_client.get(
self.BRAVE_API_URL,
headers={
"Accept": "application/json",
"Accept-Encoding": "gzip",
"x-subscription-token": self.brave_api_token
},
params={
"q": search_query,
"country": "TR",
"search_lang": "tr",
"ui_lang": "tr-TR",
"offset": offset,
"count": params.pageSize
}
)
response.raise_for_status()
data = response.json()
# Extract search results
decisions = []
web_results = data.get("web", {}).get("results", [])
for result in web_results:
title = result.get("title", "")
url = result.get("url", "")
description = result.get("description", "")
# Extract metadata from title
metadata = self._extract_decision_metadata_from_title(title)
# Extract decision ID from URL
decision_id = self._extract_decision_id_from_url(url)
decision = KvkkDecisionSummary(
title=title,
url=HttpUrl(url) if url else None,
description=description,
decision_id=decision_id,
publication_date=metadata.get("decision_date"),
decision_number=metadata.get("decision_number")
)
decisions.append(decision)
# Get total results if available
total_results = None
query_info = data.get("query", {})
if "total_results" in query_info:
total_results = query_info["total_results"]
return KvkkSearchResult(
decisions=decisions,
total_results=total_results,
page=params.page,
pageSize=params.pageSize,
query=search_query
)
except httpx.RequestError as e:
logger.error(f"KvkkApiClient: HTTP request error during search: {e}")
return KvkkSearchResult(
decisions=[],
total_results=0,
page=params.page,
pageSize=params.pageSize,
query=search_query
)
except Exception as e:
logger.error(f"KvkkApiClient: Unexpected error during search: {e}")
return KvkkSearchResult(
decisions=[],
total_results=0,
page=params.page,
pageSize=params.pageSize,
query=search_query
)
def _extract_decision_content_from_html(self, html: str, url: str) -> Dict[str, Any]:
"""Extract decision content from KVKK decision page HTML."""
try:
soup = BeautifulSoup(html, 'html.parser')
# Extract title
title = None
title_element = soup.find('h3', class_='blog-post-title')
if title_element:
title = title_element.get_text(strip=True)
elif soup.title:
title = soup.title.get_text(strip=True)
# Extract decision content from the main content div
content_div = soup.find('div', class_='blog-post-inner')
if not content_div:
# Fallback to other possible content containers
content_div = soup.find('div', style='text-align:justify;')
if not content_div:
logger.warning(f"Could not find decision content div in {url}")
return {
"title": title,
"decision_date": None,
"decision_number": None,
"subject_summary": None,
"html_content": None
}
# Extract decision metadata from table
decision_date = None
decision_number = None
subject_summary = None
table = content_div.find('table')
if table:
rows = table.find_all('tr')
for row in rows:
cells = row.find_all('td')
if len(cells) >= 3:
field_name = cells[0].get_text(strip=True)
field_value = cells[2].get_text(strip=True)
if 'Karar Tarihi' in field_name:
decision_date = field_value
elif 'Karar No' in field_name:
decision_number = field_value
elif 'Konu Özeti' in field_name:
subject_summary = field_value
return {
"title": title,
"decision_date": decision_date,
"decision_number": decision_number,
"subject_summary": subject_summary,
"html_content": str(content_div)
}
except Exception as e:
logger.error(f"Error extracting content from HTML for {url}: {e}")
return {
"title": None,
"decision_date": None,
"decision_number": None,
"subject_summary": None,
"html_content": None
}
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
"""Convert HTML content to Markdown using MarkItDown with BytesIO to avoid filename length issues."""
if not html_content:
return None
try:
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_content.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown(enable_plugins=False)
result = md_converter.convert(html_stream)
return result.text_content
except Exception as e:
logger.error(f"Error converting HTML to Markdown: {e}")
return None
async def get_decision_document(self, decision_url: str, page_number: int = 1) -> KvkkDocumentMarkdown:
"""Retrieve and convert a KVKK decision document to paginated Markdown."""
logger.info(f"KvkkApiClient: Getting decision document from: {decision_url}, page: {page_number}")
try:
# Fetch the decision page
response = await self.http_client.get(decision_url)
response.raise_for_status()
# Extract content from HTML
extracted_data = self._extract_decision_content_from_html(response.text, decision_url)
# Convert HTML content to Markdown
full_markdown_content = None
if extracted_data["html_content"]:
full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, extracted_data["html_content"])
if not full_markdown_content:
return KvkkDocumentMarkdown(
source_url=HttpUrl(decision_url),
title=extracted_data["title"],
decision_date=extracted_data["decision_date"],
decision_number=extracted_data["decision_number"],
subject_summary=extracted_data["subject_summary"],
markdown_chunk=None,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message="Could not convert document content to Markdown"
)
# Calculate pagination
content_length = len(full_markdown_content)
total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
if total_pages == 0:
total_pages = 1
# Clamp page number to valid range
current_page_clamped = max(1, min(page_number, total_pages))
# Extract the requested chunk
start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
markdown_chunk = full_markdown_content[start_index:end_index]
return KvkkDocumentMarkdown(
source_url=HttpUrl(decision_url),
title=extracted_data["title"],
decision_date=extracted_data["decision_date"],
decision_number=extracted_data["decision_number"],
subject_summary=extracted_data["subject_summary"],
markdown_chunk=markdown_chunk,
current_page=current_page_clamped,
total_pages=total_pages,
is_paginated=(total_pages > 1),
error_message=None
)
except httpx.HTTPStatusError as e:
error_msg = f"HTTP error {e.response.status_code} when fetching decision document"
logger.error(f"KvkkApiClient: {error_msg}")
return KvkkDocumentMarkdown(
source_url=HttpUrl(decision_url),
title=None,
decision_date=None,
decision_number=None,
subject_summary=None,
markdown_chunk=None,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message=error_msg
)
except Exception as e:
error_msg = f"Unexpected error when fetching decision document: {str(e)}"
logger.error(f"KvkkApiClient: {error_msg}")
return KvkkDocumentMarkdown(
source_url=HttpUrl(decision_url),
title=None,
decision_date=None,
decision_number=None,
subject_summary=None,
markdown_chunk=None,
current_page=page_number,
total_pages=0,
is_paginated=False,
error_message=error_msg
)
async def close_client_session(self):
"""Close the HTTP client session."""
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("KvkkApiClient: HTTP client session closed.")
+49
View File
@@ -0,0 +1,49 @@
# kvkk_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Any
class KvkkSearchRequest(BaseModel):
"""Model for KVKK (Personal Data Protection Authority) search request via Brave API."""
keywords: str = Field(..., description="""
Keywords to search for in KVKK decisions.
The search will automatically include 'site:kvkk.gov.tr "karar özeti"' to target KVKK decision summaries.
Examples: "açık rıza", "veri güvenliği", "kişisel veri işleme"
""")
page: int = Field(1, ge=1, le=50, description="Page number for search results (1-50).")
pageSize: int = Field(10, ge=1, le=10, description="Number of results per page (1-10).")
class KvkkDecisionSummary(BaseModel):
"""Model for a single KVKK decision summary from Brave search results."""
title: Optional[str] = Field(None, description="Decision title from search results.")
url: Optional[HttpUrl] = Field(None, description="URL to the KVKK decision page.")
description: Optional[str] = Field(None, description="Brief description or snippet from search results.")
decision_id: Optional[str] = Field(None, description="Value")
publication_date: Optional[str] = Field(None, description="Value")
decision_number: Optional[str] = Field(None, description="Value")
class KvkkSearchResult(BaseModel):
"""Model for the overall search result for KVKK decisions."""
decisions: List[KvkkDecisionSummary] = Field(default_factory=list, description="List of KVKK decisions found.")
total_results: Optional[int] = Field(None, description="Value")
page: int = Field(1, description="Current page number of results.")
pageSize: int = Field(10, description="Number of results per page.")
query: Optional[str] = Field(None, description="The actual search query sent to Brave API.")
class KvkkDocumentMarkdown(BaseModel):
"""Model for KVKK decision document content converted to paginated Markdown."""
source_url: HttpUrl = Field(description="URL of the original KVKK decision page.")
title: Optional[str] = Field(None, description="Title of the KVKK decision.")
decision_date: Optional[str] = Field(None, description="Decision date (Karar Tarihi).")
decision_number: Optional[str] = Field(None, description="Decision number (Karar No).")
subject_summary: Optional[str] = Field(None, description="Subject summary (Konu Özeti).")
markdown_chunk: Optional[str] = Field(None, description="A 5,000 character chunk of the Markdown content.")
current_page: int = Field(description="The current page number of the markdown chunk (1-indexed).")
total_pages: int = Field(description="Total number of pages for the full markdown content.")
is_paginated: bool = Field(description="True if the full markdown content is split into multiple pages.")
error_message: Optional[str] = Field(None, description="Value")
class Config:
json_encoders = {
HttpUrl: str
}
+2562 -176
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
"""
Migration stub for the deprecated Yargı MCP endpoint.
Exposes a single tool that informs the MCP client the server has moved
and the user must update their configuration.
Entrypoint variable `app` is a FastMCP instance so it works with
Dokploy's FastMCP build pipeline (`fastmcp inspect`, `fastmcp run`).
Run with:
fastmcp run migration_app.py:app --transport http --port 8000
"""
from starlette.responses import JSONResponse
from fastmcp import FastMCP
NEW_URL = "https://yargimcp.surucu.dev/mcp"
app = FastMCP(
name="Yargı MCP (taşındı / moved)",
instructions=(
f"Bu Yargı MCP endpoint'i kullanımdan kaldırıldı. "
f"Sunucu yeni adrese taşındı: {NEW_URL}. "
f"Kullanıcıya MCP istemci ayarlarındaki sunucu URL'sini güncellemesini söyle."
),
)
@app.tool(
description=(
"DEPRECATED ENDPOINT — Yargı MCP sunucusu yeni adrese taşındı. "
"Bu endpoint'teki eski araçlar (Yargıtay, Danıştay, Anayasa Mahkemesi, "
"KVKK, BDDK, Rekabet Kurumu, Sayıştay, KİK, Emsal, Uyuşmazlık, GİB, "
"Sigorta Tahkim, Bedesten vb.) artık burada çalışmıyor. "
"Bu aracı çağırarak yeni sunucu adresini ve migrasyon talimatını al, "
"ardından kullanıcıya MCP istemci (Claude Desktop, 5ire, ChatGPT vb.) "
"ayarlarındaki sunucu URL'sini güncellemesi gerektiğini açıkça söyle."
)
)
def migration_notice() -> dict:
"""Yargı MCP sunucusunun yeni adresini ve migrasyon talimatını döner."""
return {
"status": "moved",
"new_url": NEW_URL,
"message": (
f"Yargı MCP sunucusu yeni adrese taşındı: {NEW_URL}\n\n"
f"Lütfen MCP istemcinin (Claude Desktop, 5ire, ChatGPT vb.) "
f"ayarlarındaki sunucu URL'sini yukarıdaki yeni adresle güncelleyin. "
f"Mevcut endpoint artık kullanım dışıdır ve sadece bu uyarıyı döner."
),
}
@app.custom_route("/health", methods=["GET"])
async def health(request):
"""Health check endpoint for monitoring services."""
return JSONResponse({"status": "deprecated", "new_url": NEW_URL})
+61
View File
@@ -0,0 +1,61 @@
[project]
name = "yargi-mcp"
version = "0.2.1"
description = "MCP Server For Turkish Legal Databases"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [{name = "Said Surucu", email = "saidsrc@gmail.com"}]
keywords = ["mcp", "turkish-law", "legal", "yargitay", "danistay", "bddk", "kvkk", "turkish", "law", "court", "decisions"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Legal Industry",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Text Processing :: Markup :: Markdown",
"Operating System :: OS Independent",
]
urls = {Homepage = "https://github.com/saidsurucu/yargi-mcp", Issues = "https://github.com/saidsurucu/yargi-mcp/issues"}
dependencies = [
"beautifulsoup4>=4.13.4",
"httpx>=0.28.1",
"markitdown[pdf]>=0.1.1",
"pydantic>=2.11.4",
"aiohttp>=3.11.18",
"fastmcp>=2.10.5",
"pypdf>=5.5.0",
"fastapi>=0.115.14",
"cryptography>=44.0.0",
"openai>=1.0.0",
"numpy>=1.24.0",
]
[project.optional-dependencies]
asgi = [
"uvicorn[standard]>=0.30.0",
"starlette>=0.37.0",
]
api = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
]
production = [
"gunicorn>=22.0.0",
"uvicorn[standard]>=0.30.0",
]
[project.scripts]
yargi-mcp = "mcp_server_main:main"
[tool.setuptools]
py-modules = ["mcp_server_main", "asgi_app"]
[tool.setuptools.packages.find]
include = ["*_mcp_module", "semantic_search"]
[build-system]
requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta"
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "pip install -e .[asgi]"
},
"deploy": {
"startCommand": "uvicorn asgi_app:app --host 0.0.0.0 --port $PORT",
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
},
"variables": {
"ALLOWED_ORIGINS": "*",
"LOG_LEVEL": "info"
}
}
+464
View File
@@ -0,0 +1,464 @@
"""
Redis Session Store for OAuth Authorization Codes and User Sessions
This module provides Redis-based storage for OAuth authorization codes and user sessions,
enabling multi-machine deployment support by replacing in-memory storage.
Uses Upstash Redis via REST API for serverless-friendly operation.
"""
import os
import json
import time
import logging
from typing import Optional, Dict, Any, Union
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
try:
from upstash_redis import Redis
UPSTASH_AVAILABLE = True
except ImportError:
UPSTASH_AVAILABLE = False
Redis = None
# Use standard Python exceptions for Redis connection errors
import socket
from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout as RequestsTimeout
class RedisSessionStore:
"""
Redis-based session store for OAuth flows and user sessions.
Uses Upstash Redis REST API for connection-free operation suitable for
multi-instance deployments on platforms like Fly.io.
"""
def __init__(self):
"""Initialize Redis connection using environment variables."""
if not UPSTASH_AVAILABLE:
raise ImportError("upstash-redis package is required. Install with: pip install upstash-redis")
# Initialize Upstash Redis client from environment with optimized connection settings
try:
# Get Upstash Redis configuration
redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
if not redis_url or not redis_token:
raise ValueError("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be set")
logger.info(f"Connecting to Upstash Redis at {redis_url[:30]}...")
# Initialize with explicit configuration for better SSL handling
self.redis = Redis(
url=redis_url,
token=redis_token
)
logger.info("Upstash Redis client created")
# Skip connection test during initialization to prevent server hang
# Connection will be tested during first actual operation
logger.info("Redis client initialized - connection will be tested on first use")
except Exception as e:
logger.error(f"Failed to initialize Upstash Redis: {e}")
raise
# TTL values (in seconds)
self.oauth_code_ttl = int(os.getenv("OAUTH_CODE_TTL", "600")) # 10 minutes
self.session_ttl = int(os.getenv("SESSION_TTL", "3600")) # 1 hour
def _serialize_data(self, data: Dict[str, Any]) -> Dict[str, str]:
"""Convert data to Redis-compatible string format."""
serialized = {}
for key, value in data.items():
if isinstance(value, (dict, list)):
serialized[key] = json.dumps(value)
elif isinstance(value, (int, float)):
serialized[key] = str(value)
elif isinstance(value, bool):
serialized[key] = "true" if value else "false"
else:
serialized[key] = str(value)
return serialized
def _deserialize_data(self, data: Dict[str, str]) -> Dict[str, Any]:
"""Convert Redis string data back to original types."""
if not data:
return {}
deserialized = {}
for key, value in data.items():
if not isinstance(value, str):
deserialized[key] = value
continue
# Try to deserialize JSON
if value.startswith(('[', '{')):
try:
deserialized[key] = json.loads(value)
continue
except json.JSONDecodeError:
pass
# Try to convert numbers
if value.isdigit():
deserialized[key] = int(value)
continue
if value.replace('.', '').isdigit():
try:
deserialized[key] = float(value)
continue
except ValueError:
pass
# Handle booleans
if value in ("true", "false"):
deserialized[key] = value == "true"
continue
# Keep as string
deserialized[key] = value
return deserialized
# OAuth Authorization Code Methods
def set_oauth_code(self, code: str, data: Dict[str, Any]) -> bool:
"""
Store OAuth authorization code with automatic expiration.
Args:
code: Authorization code string
data: Code data including user_id, client_id, etc.
Returns:
True if stored successfully, False otherwise
"""
try:
key = f"oauth:code:{code}"
# Add timestamp for debugging
data_with_timestamp = data.copy()
data_with_timestamp.update({
"created_at": time.time(),
"expires_at": time.time() + self.oauth_code_ttl
})
# Serialize and store - Upstash Redis doesn't support mapping parameter
serialized_data = self._serialize_data(data_with_timestamp)
# Use individual hset calls for each field with retry logic
max_retries = 3
for attempt in range(max_retries):
try:
# Clear any existing data first
self.redis.delete(key)
# Set all fields in a pipeline-like manner
for field, value in serialized_data.items():
self.redis.hset(key, field, value)
# Set expiration
self.redis.expire(key, self.oauth_code_ttl)
logger.info(f"Stored OAuth code {code[:10]}... with TTL {self.oauth_code_ttl}s (attempt {attempt + 1})")
return True
except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
logger.warning(f"Redis connection error on attempt {attempt + 1}: {e}")
if attempt == max_retries - 1:
raise # Re-raise on final attempt
time.sleep(0.5 * (attempt + 1)) # Exponential backoff
except Exception as e:
logger.error(f"Failed to store OAuth code {code[:10]}... after {max_retries} attempts: {e}")
return False
def get_oauth_code(self, code: str, delete_after_use: bool = True) -> Optional[Dict[str, Any]]:
"""
Retrieve OAuth authorization code data.
Args:
code: Authorization code string
delete_after_use: If True, delete the code after retrieval (one-time use)
Returns:
Code data dictionary or None if not found/expired
"""
max_retries = 3
for attempt in range(max_retries):
try:
key = f"oauth:code:{code}"
# Get all hash fields with retry
data = self.redis.hgetall(key)
if not data:
logger.warning(f"OAuth code {code[:10]}... not found or expired (attempt {attempt + 1})")
return None
# Deserialize data
deserialized_data = self._deserialize_data(data)
# Check manual expiration (in case Redis TTL failed)
expires_at = deserialized_data.get("expires_at", 0)
if expires_at and time.time() > expires_at:
logger.warning(f"OAuth code {code[:10]}... manually expired")
try:
self.redis.delete(key)
except Exception as del_error:
logger.warning(f"Failed to delete expired code: {del_error}")
return None
# Delete after use for security (one-time use)
if delete_after_use:
try:
self.redis.delete(key)
logger.info(f"Retrieved and deleted OAuth code {code[:10]}... (attempt {attempt + 1})")
except Exception as del_error:
logger.warning(f"Failed to delete code after use: {del_error}")
# Continue anyway since we got the data
else:
logger.info(f"Retrieved OAuth code {code[:10]}... (not deleted, attempt {attempt + 1})")
return deserialized_data
except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
logger.warning(f"Redis connection error on retrieval attempt {attempt + 1}: {e}")
if attempt == max_retries - 1:
logger.error(f"Failed to retrieve OAuth code {code[:10]}... after {max_retries} attempts: {e}")
return None
time.sleep(0.5 * (attempt + 1)) # Exponential backoff
except Exception as e:
logger.error(f"Failed to retrieve OAuth code {code[:10]}... on attempt {attempt + 1}: {e}")
if attempt == max_retries - 1:
return None
time.sleep(0.5 * (attempt + 1))
return None
# User Session Methods
def set_session(self, session_id: str, user_data: Dict[str, Any]) -> bool:
"""
Store user session data with sliding expiration.
Args:
session_id: Unique session identifier
user_data: User session data (user_id, email, scopes, etc.)
Returns:
True if stored successfully, False otherwise
"""
try:
key = f"session:{session_id}"
# Add session metadata
session_data = user_data.copy()
session_data.update({
"session_id": session_id,
"created_at": time.time(),
"last_accessed": time.time()
})
# Serialize and store - Upstash Redis doesn't support mapping parameter
serialized_data = self._serialize_data(session_data)
# Use individual hset calls for each field (Upstash compatibility)
for field, value in serialized_data.items():
self.redis.hset(key, field, value)
self.redis.expire(key, self.session_ttl)
logger.info(f"Stored session {session_id[:10]}... with TTL {self.session_ttl}s")
return True
except Exception as e:
logger.error(f"Failed to store session {session_id[:10]}...: {e}")
return False
def get_session(self, session_id: str, refresh_ttl: bool = True) -> Optional[Dict[str, Any]]:
"""
Retrieve user session data.
Args:
session_id: Session identifier
refresh_ttl: If True, extend session TTL on access
Returns:
Session data dictionary or None if not found/expired
"""
try:
key = f"session:{session_id}"
# Get session data
data = self.redis.hgetall(key)
if not data:
logger.warning(f"Session {session_id[:10]}... not found or expired")
return None
# Deserialize data
session_data = self._deserialize_data(data)
# Update last accessed time and refresh TTL
if refresh_ttl:
session_data["last_accessed"] = time.time()
self.redis.hset(key, "last_accessed", str(time.time()))
self.redis.expire(key, self.session_ttl)
logger.debug(f"Refreshed session {session_id[:10]}... TTL")
return session_data
except Exception as e:
logger.error(f"Failed to retrieve session {session_id[:10]}...: {e}")
return None
def delete_session(self, session_id: str) -> bool:
"""
Delete user session (logout).
Args:
session_id: Session identifier
Returns:
True if deleted successfully, False otherwise
"""
try:
key = f"session:{session_id}"
result = self.redis.delete(key)
if result:
logger.info(f"Deleted session {session_id[:10]}...")
return True
else:
logger.warning(f"Session {session_id[:10]}... not found for deletion")
return False
except Exception as e:
logger.error(f"Failed to delete session {session_id[:10]}...: {e}")
return False
# Health Check Methods
def health_check(self) -> Dict[str, Any]:
"""
Perform Redis health check.
Returns:
Health status dictionary
"""
try:
# Test basic operations
test_key = f"health:check:{int(time.time())}"
test_value = {"timestamp": time.time(), "test": True}
# Test set - Use individual hset calls for Upstash compatibility
serialized_test = self._serialize_data(test_value)
for field, value in serialized_test.items():
self.redis.hset(test_key, field, value)
# Test get
retrieved = self.redis.hgetall(test_key)
# Test delete
self.redis.delete(test_key)
return {
"status": "healthy",
"redis_connected": True,
"operations_working": bool(retrieved),
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Redis health check failed: {e}")
return {
"status": "unhealthy",
"redis_connected": False,
"error": str(e),
"timestamp": datetime.utcnow().isoformat()
}
def get_stats(self) -> Dict[str, Any]:
"""
Get Redis usage statistics.
Returns:
Statistics dictionary
"""
try:
# Get basic info (not all Upstash plans support INFO command)
stats = {
"oauth_codes_pattern": "oauth:code:*",
"sessions_pattern": "session:*",
"timestamp": datetime.utcnow().isoformat()
}
try:
# Try to get counts (may fail on some Upstash plans)
oauth_keys = self.redis.keys("oauth:code:*")
session_keys = self.redis.keys("session:*")
stats.update({
"active_oauth_codes": len(oauth_keys) if oauth_keys else 0,
"active_sessions": len(session_keys) if session_keys else 0
})
except Exception as e:
logger.warning(f"Could not get detailed stats: {e}")
stats["warning"] = "Detailed stats not available on this Redis plan"
return stats
except Exception as e:
logger.error(f"Failed to get Redis stats: {e}")
return {"error": str(e), "timestamp": datetime.utcnow().isoformat()}
# Global instance for easy importing
redis_store = None
def get_redis_store() -> Optional[RedisSessionStore]:
"""
Get global Redis store instance (singleton pattern).
Returns:
RedisSessionStore instance or None if initialization fails
"""
global redis_store
if redis_store is None:
try:
logger.info("Initializing Redis store...")
redis_store = RedisSessionStore()
logger.info("Redis store initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize Redis store: {e}")
redis_store = None
return redis_store
def init_redis_store() -> RedisSessionStore:
"""
Initialize Redis store and perform health check.
Returns:
RedisSessionStore instance
Raises:
Exception if Redis is not available or unhealthy
"""
store = get_redis_store()
# Perform health check
health = store.health_check()
if health["status"] != "healthy":
raise Exception(f"Redis health check failed: {health}")
logger.info("Redis session store initialized and healthy")
return store
View File
+404
View File
@@ -0,0 +1,404 @@
# rekabet_mcp_module/client.py
import asyncio
import httpx
from bs4 import BeautifulSoup
from typing import List, Optional, Tuple, Dict, Any
import logging
import html
import re
import io # For io.BytesIO
from urllib.parse import urlencode, urljoin, quote, parse_qs, urlparse
from markitdown import MarkItDown
import math
# pypdf for PDF processing (lighter alternative to PyMuPDF)
from pypdf import PdfReader, PdfWriter # PyPDF2'nin devamı niteliğindeki pypdf
from .models import (
RekabetKurumuSearchRequest,
RekabetDecisionSummary,
RekabetSearchResult,
RekabetDocument,
RekabetKararTuruGuidEnum
)
from pydantic import HttpUrl # Ensure HttpUrl is imported from pydantic
logger = logging.getLogger(__name__)
if not logger.hasHandlers(): # Pragma: no cover
logging.basicConfig(
level=logging.INFO, # Varsayılan log seviyesi
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Debug betiğinde daha detaylı loglama için seviye ayrıca ayarlanabilir.
class RekabetKurumuApiClient:
BASE_URL = "https://www.rekabet.gov.tr"
SEARCH_PATH = "/tr/Kararlar"
DECISION_LANDING_PATH_TEMPLATE = "/Karar"
# PDF sayfa bazlı Markdown döndürüldüğü için bu sabit artık doğrudan kullanılmıyor.
# DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
timeout=request_timeout,
verify=True,
follow_redirects=True
)
def _build_search_query_params(self, params: RekabetKurumuSearchRequest) -> List[Tuple[str, str]]:
query_params: List[Tuple[str, str]] = []
query_params.append(("sayfaAdi", params.sayfaAdi if params.sayfaAdi is not None else ""))
query_params.append(("YayinlanmaTarihi", params.YayinlanmaTarihi if params.YayinlanmaTarihi is not None else ""))
query_params.append(("PdfText", params.PdfText if params.PdfText is not None else ""))
karar_turu_id_value = ""
if params.KararTuruID is not None:
karar_turu_id_value = params.KararTuruID.value if params.KararTuruID.value != "ALL" else ""
query_params.append(("KararTuruID", karar_turu_id_value))
query_params.append(("KararSayisi", params.KararSayisi if params.KararSayisi is not None else ""))
query_params.append(("KararTarihi", params.KararTarihi if params.KararTarihi is not None else ""))
if params.page and params.page > 1:
query_params.append(("page", str(params.page)))
return query_params
async def search_decisions(self, params: RekabetKurumuSearchRequest) -> RekabetSearchResult:
request_path = self.SEARCH_PATH
final_query_params = self._build_search_query_params(params)
logger.info(f"RekabetKurumuApiClient: Performing search. Path: {request_path}, Parameters: {final_query_params}")
try:
response = await self.http_client.get(request_path, params=final_query_params)
response.raise_for_status()
html_content = response.text
except httpx.RequestError as e:
logger.error(f"RekabetKurumuApiClient: HTTP request error during search: {e}")
raise
soup = BeautifulSoup(html_content, 'html.parser')
processed_decisions: List[RekabetDecisionSummary] = []
total_records: Optional[int] = None
total_pages: Optional[int] = None
pagination_div = soup.find("div", class_="yazi01")
if pagination_div:
text_content = pagination_div.get_text(separator=" ", strip=True)
total_match = re.search(r"Toplam\s*:\s*(\d+)", text_content)
if total_match:
try:
total_records = int(total_match.group(1))
logger.debug(f"Total records found from pagination: {total_records}")
except ValueError:
logger.warning(f"Could not convert 'Toplam' value to int: {total_match.group(1)}")
else:
logger.warning("'Toplam :' string not found in pagination section.")
results_per_page_assumed = 10
if total_records is not None:
calculated_total_pages = math.ceil(total_records / results_per_page_assumed)
total_pages = calculated_total_pages if calculated_total_pages > 0 else (1 if total_records > 0 else 0)
logger.debug(f"Calculated total pages: {total_pages}")
if total_pages is None: # Fallback if total_records couldn't be parsed
last_page_link = pagination_div.select_one("li.PagedList-skipToLast a")
if last_page_link and last_page_link.has_attr('href'):
qs = parse_qs(urlparse(last_page_link['href']).query)
if 'page' in qs and qs['page']:
try:
total_pages = int(qs['page'][0])
logger.debug(f"Total pages found from 'Last >>' link: {total_pages}")
except ValueError:
logger.warning(f"Could not convert page value from 'Last >>' link to int: {qs['page'][0]}")
elif total_records == 0 : total_pages = 0 # If no records, 0 pages
elif total_records is not None and total_records > 0 : total_pages = 1 # If records exist but no last page link (e.g. single page)
else: logger.warning("'Last >>' link not found in pagination section.")
decision_tables_container = soup.find("div", id="kararList")
if not decision_tables_container:
logger.warning("`div#kararList` (decision list container) not found. HTML structure might have changed or no decisions on this page.")
else:
decision_tables = decision_tables_container.find_all("table", class_="equalDivide")
logger.info(f"Found {len(decision_tables)} 'table' elements with class='equalDivide' for parsing.")
if not decision_tables and total_records is not None and total_records > 0 :
logger.warning(f"Page indicates {total_records} records but no decision tables found with class='equalDivide'.")
for idx, table in enumerate(decision_tables):
logger.debug(f"Processing table {idx + 1}...")
try:
rows = table.find_all("tr")
if len(rows) != 3:
logger.warning(f"Table {idx + 1} has an unexpected number of rows ({len(rows)} instead of 3). Skipping. HTML snippet:\n{table.prettify()[:500]}")
continue
# Row 1: Publication Date, Decision Number, Related Cases Link
td_elements_r1 = rows[0].find_all("td")
pub_date = td_elements_r1[0].get_text(strip=True) if len(td_elements_r1) > 0 else ""
dec_num = td_elements_r1[1].get_text(strip=True) if len(td_elements_r1) > 1 else ""
related_cases_link_tag = td_elements_r1[2].find("a", href=True) if len(td_elements_r1) > 2 else None
related_cases_url_str: str = ""
karar_id_from_related: str = ""
if related_cases_link_tag and related_cases_link_tag.has_attr('href'):
related_cases_url_str = urljoin(self.BASE_URL, related_cases_link_tag['href'])
qs_related = parse_qs(urlparse(related_cases_link_tag['href']).query)
if 'kararId' in qs_related and qs_related['kararId']:
karar_id_from_related = qs_related['kararId'][0]
# Row 2: Decision Date, Decision Type
td_elements_r2 = rows[1].find_all("td")
dec_date = td_elements_r2[0].get_text(strip=True) if len(td_elements_r2) > 0 else ""
dec_type_text = td_elements_r2[1].get_text(strip=True) if len(td_elements_r2) > 1 else ""
# Row 3: Title and Main Decision Link
title_cell = rows[2].find("td", colspan="5")
decision_link_tag = title_cell.find("a", href=True) if title_cell else None
title_text: str = ""
decision_landing_url_str: str = ""
karar_id_from_main_link: str = ""
if decision_link_tag and decision_link_tag.has_attr('href'):
title_text = decision_link_tag.get_text(strip=True)
href_val = decision_link_tag['href']
if href_val.startswith(self.DECISION_LANDING_PATH_TEMPLATE + "?kararId="): # Ensure it's a decision link
decision_landing_url_str = urljoin(self.BASE_URL, href_val)
qs_main = parse_qs(urlparse(href_val).query)
if 'kararId' in qs_main and qs_main['kararId']:
karar_id_from_main_link = qs_main['kararId'][0]
else:
logger.warning(f"Table {idx+1} decision link has unexpected format: {href_val}")
else:
logger.warning(f"Table {idx+1} could not find title/decision link tag.")
current_karar_id = karar_id_from_main_link or karar_id_from_related
if not current_karar_id:
logger.warning(f"Table {idx+1} Karar ID not found. Skipping. Title (if any): {title_text}")
continue
processed_decisions.append(RekabetDecisionSummary(
publication_date=pub_date, decision_number=dec_num, decision_date=dec_date,
decision_type_text=dec_type_text, title=title_text,
decision_url=decision_landing_url_str,
karar_id=current_karar_id,
related_cases_url=related_cases_url_str
))
logger.debug(f"Table {idx+1} parsed successfully: Karar ID '{current_karar_id}', Title '{title_text[:50] if title_text else 'N/A'}...'")
except Exception as e:
logger.warning(f"RekabetKurumuApiClient: Error parsing decision summary {idx+1}: {e}. Problematic Table HTML:\n{table.prettify()}", exc_info=True)
continue
return RekabetSearchResult(
decisions=processed_decisions, total_records_found=total_records,
retrieved_page_number=params.page, total_pages=total_pages if total_pages is not None else 0
)
async def _extract_pdf_url_and_landing_page_metadata(self, karar_id: str, landing_page_html: str, landing_page_url: str) -> Dict[str, Any]:
soup = BeautifulSoup(landing_page_html, 'html.parser')
data: Dict[str, Any] = {
"pdf_url": None,
"title_on_landing_page": soup.title.string.strip() if soup.title and soup.title.string else f"Rekabet Kurumu Kararı {karar_id}",
}
# This part needs to be robust and specific to Rekabet Kurumu's landing page structure.
# Look for common patterns: direct links, download buttons, embedded viewers.
pdf_anchor = soup.find("a", href=re.compile(r"\.pdf(\?|$)", re.IGNORECASE)) # Basic PDF link
if not pdf_anchor: # Try other common patterns if the basic one fails
# Example: Look for links with specific text or class
pdf_anchor = soup.find("a", string=re.compile(r"karar metni|pdf indir", re.IGNORECASE))
if pdf_anchor and pdf_anchor.has_attr('href'):
pdf_path = pdf_anchor['href']
data["pdf_url"] = urljoin(landing_page_url, pdf_path)
logger.info(f"PDF link found on landing page (<a>): {data['pdf_url']}")
else:
iframe_pdf = soup.find("iframe", src=re.compile(r"\.pdf(\?|$)", re.IGNORECASE))
if iframe_pdf and iframe_pdf.has_attr('src'):
pdf_path = iframe_pdf['src']
data["pdf_url"] = urljoin(landing_page_url, pdf_path)
logger.info(f"PDF link found on landing page (<iframe>): {data['pdf_url']}")
else:
embed_pdf = soup.find("embed", src=re.compile(r"\.pdf(\?|$)", re.IGNORECASE), type="application/pdf")
if embed_pdf and embed_pdf.has_attr('src'):
pdf_path = embed_pdf['src']
data["pdf_url"] = urljoin(landing_page_url, pdf_path)
logger.info(f"PDF link found on landing page (<embed>): {data['pdf_url']}")
else:
logger.warning(f"No PDF link found on landing page {landing_page_url} for kararId {karar_id} using common selectors.")
return data
async def _download_pdf_bytes(self, pdf_url: str) -> Optional[bytes]:
try:
url_to_fetch = pdf_url if pdf_url.startswith(('http://', 'https://')) else urljoin(self.BASE_URL, pdf_url)
logger.info(f"Downloading PDF from: {url_to_fetch}")
response = await self.http_client.get(url_to_fetch)
response.raise_for_status()
pdf_bytes = await response.aread()
logger.info(f"PDF content downloaded ({len(pdf_bytes)} bytes) from: {url_to_fetch}")
return pdf_bytes
except httpx.RequestError as e:
logger.error(f"HTTP error downloading PDF from {pdf_url}: {e}")
except Exception as e:
logger.error(f"General error downloading PDF from {pdf_url}: {e}")
return None
def _extract_single_pdf_page_as_pdf_bytes(self, original_pdf_bytes: bytes, page_number_to_extract: int) -> Tuple[Optional[bytes], int]:
total_pages_in_original_pdf = 0
single_page_pdf_bytes: Optional[bytes] = None
if not original_pdf_bytes:
logger.warning("No original PDF bytes provided for page extraction.")
return None, 0
try:
pdf_stream = io.BytesIO(original_pdf_bytes)
reader = PdfReader(pdf_stream)
total_pages_in_original_pdf = len(reader.pages)
if not (0 < page_number_to_extract <= total_pages_in_original_pdf):
logger.warning(f"Requested page number ({page_number_to_extract}) is out of PDF page range (1-{total_pages_in_original_pdf}).")
return None, total_pages_in_original_pdf
writer = PdfWriter()
writer.add_page(reader.pages[page_number_to_extract - 1]) # pypdf is 0-indexed
output_pdf_stream = io.BytesIO()
writer.write(output_pdf_stream)
single_page_pdf_bytes = output_pdf_stream.getvalue()
logger.debug(f"Page {page_number_to_extract} of original PDF (total {total_pages_in_original_pdf} pages) extracted as new PDF using pypdf.")
except Exception as e:
logger.error(f"Error extracting PDF page using pypdf: {e}", exc_info=True)
return None, total_pages_in_original_pdf
return single_page_pdf_bytes, total_pages_in_original_pdf
def _convert_pdf_bytes_to_markdown(self, pdf_bytes: bytes, source_url_for_logging: str) -> Optional[str]:
if not pdf_bytes:
logger.warning(f"No PDF bytes provided for Markdown conversion (source: {source_url_for_logging}).")
return None
pdf_stream = io.BytesIO(pdf_bytes)
try:
md_converter = MarkItDown(enable_plugins=False)
conversion_result = md_converter.convert(pdf_stream)
markdown_text = conversion_result.text_content
if not markdown_text:
logger.warning(f"MarkItDown returned empty content from PDF byte stream (source: {source_url_for_logging}). PDF page might be image-based or MarkItDown could not process the PDF stream.")
return markdown_text
except Exception as e:
logger.error(f"MarkItDown conversion error for PDF byte stream (source: {source_url_for_logging}): {e}", exc_info=True)
return None
async def get_decision_document(self, karar_id: str, page_number: int = 1) -> RekabetDocument:
if not karar_id:
return RekabetDocument(
source_landing_page_url=HttpUrl(f"{self.BASE_URL}"),
karar_id=karar_id or "UNKNOWN_KARAR_ID",
error_message="karar_id is required.",
current_page=1, total_pages=0, is_paginated=False )
decision_url_path = f"{self.DECISION_LANDING_PATH_TEMPLATE}?kararId={karar_id}"
full_landing_page_url = urljoin(self.BASE_URL, decision_url_path)
logger.info(f"RekabetKurumuApiClient: Getting decision document: {full_landing_page_url}, Requested PDF Page: {page_number}")
pdf_url_to_report: Optional[HttpUrl] = None
title_to_report: Optional[str] = f"Rekabet Kurumu Kararı {karar_id}" # Default
error_message: Optional[str] = None
markdown_for_requested_page: Optional[str] = None
total_pdf_pages: int = 0
try:
async with self.http_client.stream("GET", full_landing_page_url) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").lower()
final_url_of_response = HttpUrl(str(response.url))
original_pdf_bytes: Optional[bytes] = None
if "application/pdf" in content_type:
logger.info(f"URL {final_url_of_response} is a direct PDF. Processing content.")
pdf_url_to_report = final_url_of_response
original_pdf_bytes = await response.aread()
elif "text/html" in content_type:
logger.info(f"URL {final_url_of_response} is an HTML landing page. Looking for PDF link.")
landing_page_html_bytes = await response.aread()
detected_charset = response.charset_encoding or 'utf-8'
try: landing_page_html = landing_page_html_bytes.decode(detected_charset)
except UnicodeDecodeError: landing_page_html = landing_page_html_bytes.decode('utf-8', errors='replace')
if landing_page_html.strip():
landing_page_data = self._extract_pdf_url_and_landing_page_metadata(karar_id, landing_page_html, str(final_url_of_response))
pdf_url_str_from_html = landing_page_data.get("pdf_url")
if landing_page_data.get("title_on_landing_page"): title_to_report = landing_page_data.get("title_on_landing_page")
if pdf_url_str_from_html:
pdf_url_to_report = HttpUrl(pdf_url_str_from_html)
original_pdf_bytes = await self._download_pdf_bytes(str(pdf_url_to_report))
else: error_message = (error_message or "") + " PDF URL not found on HTML landing page."
else: error_message = "Decision landing page content is empty."
else: error_message = f"Unexpected content type ({content_type}) for URL: {final_url_of_response}"
if original_pdf_bytes:
single_page_pdf_bytes, total_pdf_pages_from_extraction = self._extract_single_pdf_page_as_pdf_bytes(original_pdf_bytes, page_number)
total_pdf_pages = total_pdf_pages_from_extraction
if single_page_pdf_bytes:
markdown_for_requested_page = await asyncio.to_thread(self._convert_pdf_bytes_to_markdown, single_page_pdf_bytes, str(pdf_url_to_report or full_landing_page_url))
if not markdown_for_requested_page:
error_message = (error_message or "") + f"; Could not convert page {page_number} of PDF to Markdown."
elif total_pdf_pages > 0 :
error_message = (error_message or "") + f"; Could not extract page {page_number} from PDF (page may be out of range or extraction failed)."
else:
error_message = (error_message or "") + "; PDF could not be processed or page count was zero (original PDF might be invalid)."
elif not error_message:
error_message = "PDF content could not be downloaded or identified."
is_paginated = total_pdf_pages > 1
current_page_final = page_number
if total_pdf_pages > 0:
current_page_final = max(1, min(page_number, total_pdf_pages))
elif markdown_for_requested_page is None:
current_page_final = 1
# If markdown is None but there was no specific error for markdown conversion (e.g. PDF not found first)
# make sure error_message reflects that.
if markdown_for_requested_page is None and pdf_url_to_report and not error_message:
error_message = (error_message or "") + "; Failed to produce Markdown from PDF page."
return RekabetDocument(
source_landing_page_url=full_landing_page_url, karar_id=karar_id,
title_on_landing_page=title_to_report, pdf_url=pdf_url_to_report,
markdown_chunk=markdown_for_requested_page, current_page=current_page_final,
total_pages=total_pdf_pages, is_paginated=is_paginated,
error_message=error_message.strip("; ") if error_message else None )
except httpx.HTTPStatusError as e: error_msg_detail = f"HTTP Status error {e.response.status_code} while processing decision page."
except httpx.RequestError as e: error_msg_detail = f"HTTP Request error while processing decision page: {str(e)}"
except Exception as e: error_msg_detail = f"General error while processing decision: {str(e)}"
exc_info_flag = not isinstance(e, (httpx.HTTPStatusError, httpx.RequestError)) if 'e' in locals() else True
logger.error(f"RekabetKurumuApiClient: Error processing decision {karar_id} from {full_landing_page_url}: {error_msg_detail}", exc_info=exc_info_flag)
error_message = (error_message + "; " if error_message else "") + error_msg_detail
return RekabetDocument(
source_landing_page_url=full_landing_page_url, karar_id=karar_id,
title_on_landing_page=title_to_report, pdf_url=pdf_url_to_report,
markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False,
error_message=error_message.strip("; ") if error_message else "An unexpected error occurred." )
async def close_client_session(self): # Pragma: no cover
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("RekabetKurumuApiClient: HTTP client session closed.")
+71
View File
@@ -0,0 +1,71 @@
# rekabet_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Any
from enum import Enum
# Enum for decision type GUIDs (used by the client and expected by the website)
class RekabetKararTuruGuidEnum(str, Enum):
TUMU = "ALL" # Represents "All" or "Select Decision Type"
BIRLESME_DEVRALMA = "2fff0979-9f9d-42d7-8c2e-a30705889542" # Merger and Acquisition
DIGER = "dda8feaf-c919-405c-9da1-823f22b45ad9" # Other
MENFI_TESPIT_MUAFIYET = "95ccd210-5304-49c5-b9e0-8ee53c50d4e8" # Negative Clearance and Exemption
OZELLESTIRME = "e1f14505-842b-4af5-95d1-312d6de1a541" # Privatization
REKABET_IHLALI = "720614bf-efd1-4dca-9785-b98eb65f2677" # Competition Infringement
# Enum for user-friendly decision type names (for server tool parameters)
# These correspond to the display names on the website's select dropdown.
class RekabetKararTuruAdiEnum(str, Enum):
TUMU = "Tümü" # Corresponds to the empty value "" for GUID, meaning "All"
BIRLESME_VE_DEVRALMA = "Birleşme ve Devralma"
DIGER = "Diğer"
MENFI_TESPIT_VE_MUAFIYET = "Menfi Tespit ve Muafiyet"
OZELLESTIRME = "Özelleştirme"
REKABET_IHLALI = "Rekabet İhlali"
class RekabetKurumuSearchRequest(BaseModel):
"""Model for Rekabet Kurumu (Turkish Competition Authority) search request."""
sayfaAdi: str = Field("", description="Title")
YayinlanmaTarihi: str = Field("", description="Date")
PdfText: str = Field("", description="Text")
KararTuruID: RekabetKararTuruGuidEnum = Field(RekabetKararTuruGuidEnum.TUMU, description="Type")
KararSayisi: str = Field("", description="No")
KararTarihi: str = Field("", description="Date")
page: int = Field(1, ge=1, description="Page")
class RekabetDecisionSummary(BaseModel):
"""Model for a single Rekabet Kurumu decision summary from search results."""
publication_date: str = Field("", description="Pub date")
decision_number: str = Field("", description="Number")
decision_date: str = Field("", description="Date")
decision_type_text: str = Field("", description="Type")
title: str = Field("", description="Title")
decision_url: str = Field("", description="URL")
karar_id: str = Field("", description="ID")
related_cases_url: str = Field("", description="Cases URL")
class RekabetSearchResult(BaseModel):
"""Model for the overall search result for Rekabet Kurumu decisions."""
decisions: List[RekabetDecisionSummary]
total_records_found: int = Field(0, description="Total")
retrieved_page_number: int = Field(description="Page")
total_pages: int = Field(0, description="Pages")
class RekabetDocument(BaseModel):
"""
Model for a Rekabet Kurumu decision document.
Contains metadata from the landing page, a link to the PDF,
and the PDF's content converted to paginated Markdown.
"""
source_landing_page_url: HttpUrl = Field(description="Source URL")
karar_id: str = Field(description="ID")
title_on_landing_page: Optional[str] = Field(None, description="Title")
pdf_url: Optional[HttpUrl] = Field(None, description="PDF URL")
markdown_chunk: Optional[str] = Field(None, description="Content")
current_page: int = Field(1, description="Page")
total_pages: int = Field(1, description="Total pages")
is_paginated: bool = Field(False, description="Paginated")
error_message: Optional[str] = Field(None, description="Error")
-6
View File
@@ -1,6 +0,0 @@
fastmcp
httpx
beautifulsoup4
markitdown
pydantic
aiohttp
+56
View File
@@ -0,0 +1,56 @@
# sayistay_mcp_module/__init__.py
"""
Sayıştay (Turkish Court of Accounts) MCP Module
This module provides access to three types of Sayıştay decisions:
- Genel Kurul (General Assembly) decisions
- Temyiz Kurulu (Appeals Board) decisions
- Daire (Chamber) decisions
The module handles ASP.NET WebForms authentication with CSRF tokens
and DataTables-based pagination for comprehensive decision search.
"""
from .client import SayistayApiClient
from .models import (
# Genel Kurul models
GenelKurulSearchRequest,
GenelKurulSearchResponse,
GenelKurulDecision,
# Temyiz Kurulu models
TemyizKuruluSearchRequest,
TemyizKuruluSearchResponse,
TemyizKuruluDecision,
# Daire models
DaireSearchRequest,
DaireSearchResponse,
DaireDecision,
# Document models
SayistayDocumentMarkdown
)
from .enums import (
DaireEnum,
KamuIdaresiTuruEnum,
WebKararKonusuEnum
)
__all__ = [
"SayistayApiClient",
"GenelKurulSearchRequest",
"GenelKurulSearchResponse",
"GenelKurulDecision",
"TemyizKuruluSearchRequest",
"TemyizKuruluSearchResponse",
"TemyizKuruluDecision",
"DaireSearchRequest",
"DaireSearchResponse",
"DaireDecision",
"SayistayDocumentMarkdown",
"DaireEnum",
"KamuIdaresiTuruEnum",
"WebKararKonusuEnum"
]
+716
View File
@@ -0,0 +1,716 @@
# sayistay_mcp_module/client.py
import asyncio
import httpx
import re
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Tuple
import logging
import html
import io
from urllib.parse import urlencode, urljoin
from markitdown import MarkItDown
from .models import (
GenelKurulSearchRequest, GenelKurulSearchResponse, GenelKurulDecision,
TemyizKuruluSearchRequest, TemyizKuruluSearchResponse, TemyizKuruluDecision,
DaireSearchRequest, DaireSearchResponse, DaireDecision,
SayistayDocumentMarkdown
)
from .enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum, WEB_KARAR_KONUSU_MAPPING
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
class SayistayApiClient:
"""
API Client for Sayıştay (Turkish Court of Accounts) decision search system.
Handles three types of decisions:
- Genel Kurul (General Assembly): Precedent-setting interpretive decisions
- Temyiz Kurulu (Appeals Board): Appeals against chamber decisions
- Daire (Chamber): First-instance audit findings and sanctions
Features:
- ASP.NET WebForms session management with CSRF tokens
- DataTables-based pagination and filtering
- Automatic session refresh on expiration
- Document retrieval with Markdown conversion
"""
BASE_URL = "https://www.sayistay.gov.tr"
# Search endpoints for each decision type
GENEL_KURUL_ENDPOINT = "/KararlarGenelKurul/DataTablesList"
TEMYIZ_KURULU_ENDPOINT = "/KararlarTemyiz/DataTablesList"
DAIRE_ENDPOINT = "/KararlarDaire/DataTablesList"
# Marker present in the upstream WAF block page (also returns HTTP 418).
# Verified 2026-05-03 against real Chrome — the block targets POSTs to
# the DataTablesList endpoints regardless of headers/cookies/CSRF, so
# we surface a specific error instead of the generic "I'm a teapot".
_WAF_BLOCK_MARKER = "Bilgi Güvenliği Politikaları Gereği Kısıtlanmıştır"
# Page endpoints for session initialization and document access
GENEL_KURUL_PAGE = "/KararlarGenelKurul"
TEMYIZ_KURULU_PAGE = "/KararlarTemyiz"
DAIRE_PAGE = "/KararlarDaire"
def __init__(self, request_timeout: float = 60.0):
self.request_timeout = request_timeout
self.session_cookies: Dict[str, str] = {}
self.csrf_tokens: Dict[str, str] = {} # Store tokens for each endpoint
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"X-Requested-With": "XMLHttpRequest",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
},
timeout=request_timeout,
follow_redirects=True
)
async def _initialize_session_for_endpoint(self, endpoint_type: str) -> bool:
"""
Initialize session and obtain CSRF token for specific endpoint.
Args:
endpoint_type: One of 'genel_kurul', 'temyiz_kurulu', 'daire'
Returns:
True if session initialized successfully, False otherwise
"""
page_mapping = {
'genel_kurul': self.GENEL_KURUL_PAGE,
'temyiz_kurulu': self.TEMYIZ_KURULU_PAGE,
'daire': self.DAIRE_PAGE
}
if endpoint_type not in page_mapping:
logger.error(f"Invalid endpoint type: {endpoint_type}")
return False
page_url = page_mapping[endpoint_type]
logger.info(f"Initializing session for {endpoint_type} endpoint: {page_url}")
try:
response = await self.http_client.get(page_url)
response.raise_for_status()
# Extract session cookies
for cookie_name, cookie_value in response.cookies.items():
self.session_cookies[cookie_name] = cookie_value
logger.debug(f"Stored session cookie: {cookie_name}")
# Extract CSRF token from form
soup = BeautifulSoup(response.text, 'html.parser')
csrf_input = soup.find('input', {'name': '__RequestVerificationToken'})
if csrf_input and csrf_input.get('value'):
self.csrf_tokens[endpoint_type] = csrf_input['value']
logger.info(f"Extracted CSRF token for {endpoint_type}")
return True
else:
logger.warning(f"CSRF token not found in {endpoint_type} page")
return False
except httpx.RequestError as e:
logger.error(f"HTTP error during session initialization for {endpoint_type}: {e}")
return False
except Exception as e:
logger.error(f"Error initializing session for {endpoint_type}: {e}")
return False
def _enum_to_form_value(self, enum_value: str, enum_type: str) -> str:
"""Convert enum values to form values expected by the API."""
if enum_value == "ALL":
if enum_type == "daire":
return "Tüm Daireler"
elif enum_type == "kamu_idaresi":
return "Tüm Kurumlar"
elif enum_type == "web_karar_konusu":
return "Tüm Konular"
# Apply web_karar_konusu mapping
if enum_type == "web_karar_konusu":
return WEB_KARAR_KONUSU_MAPPING.get(enum_value, enum_value)
return enum_value
def _raise_if_waf_blocked(self, response: httpx.Response, endpoint_label: str) -> None:
"""
Sayıştay's upstream WAF returns HTTP 418 with a Turkish HTML block
page for POSTs to the DataTablesList endpoints. This affects every
client (verified with real Chrome on 2026-05-03), so there is no
client-side workaround. Detect it and raise a clear error.
"""
if response.status_code == 418 or self._WAF_BLOCK_MARKER in response.text:
raise RuntimeError(
f"Sayıştay upstream WAF blocked the {endpoint_label} request "
f"(HTTP {response.status_code} from {response.request.url}). "
"This is a server-side restriction at sayistay.gov.tr — affects "
"all clients including a real browser — and cannot be worked "
"around from yargi-mcp. Try again later or contact Sayıştay if "
"the block persists."
)
def _build_datatables_params(self, start: int, length: int, draw: int = 1) -> List[Tuple[str, str]]:
"""Build standard DataTables parameters for all endpoints."""
params = [
("draw", str(draw)),
("start", str(start)),
("length", str(length)),
("search[value]", ""),
("search[regex]", "false")
]
return params
def _build_genel_kurul_form_data(self, params: GenelKurulSearchRequest, draw: int = 1) -> List[Tuple[str, str]]:
"""Build form data for Genel Kurul search request."""
form_data = self._build_datatables_params(params.start, params.length, draw)
# Add DataTables column definitions (from actual request)
column_defs = [
("columns[0][data]", "KARARNO"),
("columns[0][name]", ""),
("columns[0][searchable]", "true"),
("columns[0][orderable]", "false"),
("columns[0][search][value]", ""),
("columns[0][search][regex]", "false"),
("columns[1][data]", "KARARNO"),
("columns[1][name]", ""),
("columns[1][searchable]", "true"),
("columns[1][orderable]", "true"),
("columns[1][search][value]", ""),
("columns[1][search][regex]", "false"),
("columns[2][data]", "KARARTARIH"),
("columns[2][name]", ""),
("columns[2][searchable]", "true"),
("columns[2][orderable]", "true"),
("columns[2][search][value]", ""),
("columns[2][search][regex]", "false"),
("columns[3][data]", "KARAROZETI"),
("columns[3][name]", ""),
("columns[3][searchable]", "true"),
("columns[3][orderable]", "false"),
("columns[3][search][value]", ""),
("columns[3][search][regex]", "false"),
("columns[4][data]", ""),
("columns[4][name]", ""),
("columns[4][searchable]", "true"),
("columns[4][orderable]", "false"),
("columns[4][search][value]", ""),
("columns[4][search][regex]", "false"),
("order[0][column]", "2"),
("order[0][dir]", "desc")
]
form_data.extend(column_defs)
# Add search parameters
form_data.extend([
("KararlarGenelKurulAra.KARARNO", params.karar_no or ""),
("__Invariant[]", "KararlarGenelKurulAra.KARARNO"),
("__Invariant[]", "KararlarGenelKurulAra.KARAREK"),
("KararlarGenelKurulAra.KARAREK", params.karar_ek or ""),
("KararlarGenelKurulAra.KARARTARIHBaslangic", params.karar_tarih_baslangic or "Başlangıç Tarihi"),
("KararlarGenelKurulAra.KARARTARIHBitis", params.karar_tarih_bitis or "Bitiş Tarihi"),
("KararlarGenelKurulAra.KARARTAMAMI", params.karar_tamami or ""),
("__RequestVerificationToken", self.csrf_tokens.get('genel_kurul', ''))
])
return form_data
def _build_temyiz_kurulu_form_data(self, params: TemyizKuruluSearchRequest, draw: int = 1) -> List[Tuple[str, str]]:
"""Build form data for Temyiz Kurulu search request."""
form_data = self._build_datatables_params(params.start, params.length, draw)
# Add DataTables column definitions (from actual request)
column_defs = [
("columns[0][data]", "TEMYIZTUTANAKTARIHI"),
("columns[0][name]", ""),
("columns[0][searchable]", "true"),
("columns[0][orderable]", "false"),
("columns[0][search][value]", ""),
("columns[0][search][regex]", "false"),
("columns[1][data]", "TEMYIZTUTANAKTARIHI"),
("columns[1][name]", ""),
("columns[1][searchable]", "true"),
("columns[1][orderable]", "true"),
("columns[1][search][value]", ""),
("columns[1][search][regex]", "false"),
("columns[2][data]", "ILAMDAIRESI"),
("columns[2][name]", ""),
("columns[2][searchable]", "true"),
("columns[2][orderable]", "true"),
("columns[2][search][value]", ""),
("columns[2][search][regex]", "false"),
("columns[3][data]", "TEMYIZKARAR"),
("columns[3][name]", ""),
("columns[3][searchable]", "true"),
("columns[3][orderable]", "false"),
("columns[3][search][value]", ""),
("columns[3][search][regex]", "false"),
("columns[4][data]", ""),
("columns[4][name]", ""),
("columns[4][searchable]", "true"),
("columns[4][orderable]", "false"),
("columns[4][search][value]", ""),
("columns[4][search][regex]", "false"),
("order[0][column]", "1"),
("order[0][dir]", "desc")
]
form_data.extend(column_defs)
# Add search parameters
daire_value = self._enum_to_form_value(params.ilam_dairesi, "daire")
kamu_idaresi_value = self._enum_to_form_value(params.kamu_idaresi_turu, "kamu_idaresi")
web_karar_konusu_value = self._enum_to_form_value(params.web_karar_konusu, "web_karar_konusu")
form_data.extend([
("KararlarTemyizAra.ILAMDAIRESI", daire_value),
("KararlarTemyizAra.YILI", params.yili or ""),
("KararlarTemyizAra.KARARTRHBaslangic", params.karar_tarih_baslangic or ""),
("KararlarTemyizAra.KARARTRHBitis", params.karar_tarih_bitis or ""),
("KararlarTemyizAra.KAMUIDARESITURU", kamu_idaresi_value if kamu_idaresi_value != "Tüm Kurumlar" else ""),
("KararlarTemyizAra.ILAMNO", params.ilam_no or ""),
("KararlarTemyizAra.DOSYANO", params.dosya_no or ""),
("KararlarTemyizAra.TEMYIZTUTANAKNO", params.temyiz_tutanak_no or ""),
("__Invariant", "KararlarTemyizAra.TEMYIZTUTANAKNO"),
("KararlarTemyizAra.TEMYIZKARAR", params.temyiz_karar or ""),
("KararlarTemyizAra.WEBKARARKONUSU", web_karar_konusu_value if web_karar_konusu_value != "Tüm Konular" else ""),
("__RequestVerificationToken", self.csrf_tokens.get('temyiz_kurulu', ''))
])
return form_data
def _build_daire_form_data(self, params: DaireSearchRequest, draw: int = 1) -> List[Tuple[str, str]]:
"""Build form data for Daire search request."""
form_data = self._build_datatables_params(params.start, params.length, draw)
# Add DataTables column definitions (from actual request)
column_defs = [
("columns[0][data]", "YARGILAMADAIRESI"),
("columns[0][name]", ""),
("columns[0][searchable]", "true"),
("columns[0][orderable]", "false"),
("columns[0][search][value]", ""),
("columns[0][search][regex]", "false"),
("columns[1][data]", "KARARTRH"),
("columns[1][name]", ""),
("columns[1][searchable]", "true"),
("columns[1][orderable]", "true"),
("columns[1][search][value]", ""),
("columns[1][search][regex]", "false"),
("columns[2][data]", "KARARNO"),
("columns[2][name]", ""),
("columns[2][searchable]", "true"),
("columns[2][orderable]", "true"),
("columns[2][search][value]", ""),
("columns[2][search][regex]", "false"),
("columns[3][data]", "YARGILAMADAIRESI"),
("columns[3][name]", ""),
("columns[3][searchable]", "true"),
("columns[3][orderable]", "true"),
("columns[3][search][value]", ""),
("columns[3][search][regex]", "false"),
("columns[4][data]", "WEBKARARMETNI"),
("columns[4][name]", ""),
("columns[4][searchable]", "true"),
("columns[4][orderable]", "false"),
("columns[4][search][value]", ""),
("columns[4][search][regex]", "false"),
("columns[5][data]", ""),
("columns[5][name]", ""),
("columns[5][searchable]", "true"),
("columns[5][orderable]", "false"),
("columns[5][search][value]", ""),
("columns[5][search][regex]", "false"),
("order[0][column]", "2"),
("order[0][dir]", "desc")
]
form_data.extend(column_defs)
# Add search parameters
daire_value = self._enum_to_form_value(params.yargilama_dairesi, "daire")
kamu_idaresi_value = self._enum_to_form_value(params.kamu_idaresi_turu, "kamu_idaresi")
web_karar_konusu_value = self._enum_to_form_value(params.web_karar_konusu, "web_karar_konusu")
form_data.extend([
("KararlarDaireAra.YARGILAMADAIRESI", daire_value),
("KararlarDaireAra.KARARTRHBaslangic", params.karar_tarih_baslangic or ""),
("KararlarDaireAra.KARARTRHBitis", params.karar_tarih_bitis or ""),
("KararlarDaireAra.ILAMNO", params.ilam_no or ""),
("KararlarDaireAra.KAMUIDARESITURU", kamu_idaresi_value if kamu_idaresi_value != "Tüm Kurumlar" else ""),
("KararlarDaireAra.HESAPYILI", params.hesap_yili or ""),
("KararlarDaireAra.WEBKARARKONUSU", web_karar_konusu_value if web_karar_konusu_value != "Tüm Konular" else ""),
("KararlarDaireAra.WEBKARARMETNI", params.web_karar_metni or ""),
("__RequestVerificationToken", self.csrf_tokens.get('daire', ''))
])
return form_data
async def search_genel_kurul_decisions(self, params: GenelKurulSearchRequest) -> GenelKurulSearchResponse:
"""
Search Sayıştay Genel Kurul (General Assembly) decisions.
Args:
params: Search parameters for Genel Kurul decisions
Returns:
GenelKurulSearchResponse with matching decisions
"""
# Initialize session if needed
if 'genel_kurul' not in self.csrf_tokens:
if not await self._initialize_session_for_endpoint('genel_kurul'):
raise Exception("Failed to initialize session for Genel Kurul endpoint")
form_data = self._build_genel_kurul_form_data(params)
encoded_data = urlencode(form_data, encoding='utf-8')
logger.info(f"Searching Genel Kurul decisions with parameters: {params.model_dump(exclude_none=True)}")
try:
# Update headers with cookies
headers = self.http_client.headers.copy()
if self.session_cookies:
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
headers["Cookie"] = cookie_header
response = await self.http_client.post(
self.GENEL_KURUL_ENDPOINT,
data=encoded_data,
headers=headers
)
self._raise_if_waf_blocked(response, "Genel Kurul")
response.raise_for_status()
response_json = response.json()
# Parse response
decisions = []
for item in response_json.get('data', []):
decisions.append(GenelKurulDecision(
id=item['Id'],
karar_no=item['KARARNO'],
karar_tarih=item['KARARTARIH'],
karar_ozeti=item['KARAROZETI']
))
return GenelKurulSearchResponse(
decisions=decisions,
total_records=response_json.get('recordsTotal', 0),
total_filtered=response_json.get('recordsFiltered', 0),
draw=response_json.get('draw', 1)
)
except httpx.RequestError as e:
logger.error(f"HTTP error during Genel Kurul search: {e}")
raise
except Exception as e:
logger.error(f"Error processing Genel Kurul search: {e}")
raise
async def search_temyiz_kurulu_decisions(self, params: TemyizKuruluSearchRequest) -> TemyizKuruluSearchResponse:
"""
Search Sayıştay Temyiz Kurulu (Appeals Board) decisions.
Args:
params: Search parameters for Temyiz Kurulu decisions
Returns:
TemyizKuruluSearchResponse with matching decisions
"""
# Initialize session if needed
if 'temyiz_kurulu' not in self.csrf_tokens:
if not await self._initialize_session_for_endpoint('temyiz_kurulu'):
raise Exception("Failed to initialize session for Temyiz Kurulu endpoint")
form_data = self._build_temyiz_kurulu_form_data(params)
encoded_data = urlencode(form_data, encoding='utf-8')
logger.info(f"Searching Temyiz Kurulu decisions with parameters: {params.model_dump(exclude_none=True)}")
try:
# Update headers with cookies
headers = self.http_client.headers.copy()
if self.session_cookies:
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
headers["Cookie"] = cookie_header
response = await self.http_client.post(
self.TEMYIZ_KURULU_ENDPOINT,
data=encoded_data,
headers=headers
)
self._raise_if_waf_blocked(response, "Temyiz Kurulu")
response.raise_for_status()
response_json = response.json()
# Parse response
decisions = []
for item in response_json.get('data', []):
decisions.append(TemyizKuruluDecision(
id=item['Id'],
temyiz_tutanak_tarihi=item['TEMYIZTUTANAKTARIHI'],
ilam_dairesi=item['ILAMDAIRESI'],
temyiz_karar=item['TEMYIZKARAR']
))
return TemyizKuruluSearchResponse(
decisions=decisions,
total_records=response_json.get('recordsTotal', 0),
total_filtered=response_json.get('recordsFiltered', 0),
draw=response_json.get('draw', 1)
)
except httpx.RequestError as e:
logger.error(f"HTTP error during Temyiz Kurulu search: {e}")
raise
except Exception as e:
logger.error(f"Error processing Temyiz Kurulu search: {e}")
raise
async def search_daire_decisions(self, params: DaireSearchRequest) -> DaireSearchResponse:
"""
Search Sayıştay Daire (Chamber) decisions.
Args:
params: Search parameters for Daire decisions
Returns:
DaireSearchResponse with matching decisions
"""
# Initialize session if needed
if 'daire' not in self.csrf_tokens:
if not await self._initialize_session_for_endpoint('daire'):
raise Exception("Failed to initialize session for Daire endpoint")
form_data = self._build_daire_form_data(params)
encoded_data = urlencode(form_data, encoding='utf-8')
logger.info(f"Searching Daire decisions with parameters: {params.model_dump(exclude_none=True)}")
try:
# Update headers with cookies
headers = self.http_client.headers.copy()
if self.session_cookies:
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
headers["Cookie"] = cookie_header
response = await self.http_client.post(
self.DAIRE_ENDPOINT,
data=encoded_data,
headers=headers
)
self._raise_if_waf_blocked(response, "Daire")
response.raise_for_status()
response_json = response.json()
# Parse response
decisions = []
for item in response_json.get('data', []):
decisions.append(DaireDecision(
id=item['Id'],
yargilama_dairesi=item['YARGILAMADAIRESI'],
karar_tarih=item['KARARTRH'],
karar_no=item['KARARNO'],
ilam_no=item.get('ILAMNO'), # Use get() to handle None values
madde_no=item['MADDENO'],
kamu_idaresi_turu=item['KAMUIDARESITURU'],
hesap_yili=item['HESAPYILI'],
web_karar_konusu=item['WEBKARARKONUSU'],
web_karar_metni=item['WEBKARARMETNI']
))
return DaireSearchResponse(
decisions=decisions,
total_records=response_json.get('recordsTotal', 0),
total_filtered=response_json.get('recordsFiltered', 0),
draw=response_json.get('draw', 1)
)
except httpx.RequestError as e:
logger.error(f"HTTP error during Daire search: {e}")
raise
except Exception as e:
logger.error(f"Error processing Daire search: {e}")
raise
def _convert_html_to_markdown(self, html_content: str) -> Optional[str]:
"""Convert HTML content to Markdown using MarkItDown with BytesIO to avoid filename length issues."""
if not html_content:
return None
try:
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_content.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
result = md_converter.convert(html_stream)
markdown_content = result.text_content
logger.info("Successfully converted HTML to Markdown")
return markdown_content
except Exception as e:
logger.error(f"Error converting HTML to Markdown: {e}")
return f"Error converting HTML content: {str(e)}"
async def get_document_as_markdown(self, decision_id: str, decision_type: str) -> SayistayDocumentMarkdown:
"""
Retrieve full text of a Sayıştay decision and convert to Markdown.
Args:
decision_id: Unique decision identifier
decision_type: Type of decision ('genel_kurul', 'temyiz_kurulu', 'daire')
Returns:
SayistayDocumentMarkdown with converted content
"""
logger.info(f"Retrieving document for {decision_type} decision ID: {decision_id}")
# Validate decision_id
if not decision_id or not decision_id.strip():
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url="",
markdown_content=None,
error_message="Decision ID cannot be empty"
)
# Map decision type to URL path
url_path_mapping = {
'genel_kurul': 'KararlarGenelKurul',
'temyiz_kurulu': 'KararlarTemyiz',
'daire': 'KararlarDaire'
}
if decision_type not in url_path_mapping:
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url="",
markdown_content=None,
error_message=f"Invalid decision type: {decision_type}. Must be one of: {list(url_path_mapping.keys())}"
)
# Build document URL
url_path = url_path_mapping[decision_type]
document_url = f"{self.BASE_URL}/{url_path}/Detay/{decision_id}/"
try:
# Make HTTP GET request to document URL
headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin"
}
# Include session cookies if available
if self.session_cookies:
cookie_header = "; ".join([f"{k}={v}" for k, v in self.session_cookies.items()])
headers["Cookie"] = cookie_header
response = await self.http_client.get(document_url, headers=headers)
response.raise_for_status()
html_content = response.text
if not html_content or not html_content.strip():
logger.warning(f"Received empty HTML content from {document_url}")
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url=document_url,
markdown_content=None,
error_message="Document content is empty"
)
# Convert HTML to Markdown using existing method
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, html_content)
if markdown_content and "Error converting HTML content" not in markdown_content:
logger.info(f"Successfully retrieved and converted document {decision_id} to Markdown")
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url=document_url,
markdown_content=markdown_content,
retrieval_date=None # Could add datetime.now().isoformat() if needed
)
else:
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url=document_url,
markdown_content=None,
error_message=f"Failed to convert HTML to Markdown: {markdown_content}"
)
except httpx.HTTPStatusError as e:
error_msg = f"HTTP error {e.response.status_code} when fetching document: {e}"
logger.error(f"HTTP error fetching document {decision_id}: {error_msg}")
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url=document_url,
markdown_content=None,
error_message=error_msg
)
except httpx.RequestError as e:
error_msg = f"Network error when fetching document: {e}"
logger.error(f"Network error fetching document {decision_id}: {error_msg}")
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url=document_url,
markdown_content=None,
error_message=error_msg
)
except Exception as e:
error_msg = f"Unexpected error when fetching document: {e}"
logger.error(f"Unexpected error fetching document {decision_id}: {error_msg}")
return SayistayDocumentMarkdown(
decision_id=decision_id,
decision_type=decision_type,
source_url=document_url,
markdown_content=None,
error_message=error_msg
)
async def close_client_session(self):
"""Close HTTP client session."""
if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
await self.http_client.aclose()
logger.info("SayistayApiClient: HTTP client session closed.")
+61
View File
@@ -0,0 +1,61 @@
# sayistay_mcp_module/enums.py
from typing import Literal
# Chamber/Daire options for Temyiz Kurulu and Daire endpoints (1-8 + All)
DaireEnum = Literal[
"ALL", # All chambers/departments
"1", # 1. Daire
"2", # 2. Daire
"3", # 3. Daire
"4", # 4. Daire
"5", # 5. Daire
"6", # 6. Daire
"7", # 7. Daire
"8" # 8. Daire
]
# Public Administration Types (Kamu İdaresi Türü)
KamuIdaresiTuruEnum = Literal[
"ALL", # All institutions
"Genel Bütçe Kapsamındaki İdareler", # General Budget Administrations
"Yüksek Öğretim Kurumları", # Higher Education Institutions
"Diğer Özel Bütçeli İdareler", # Other Special Budget Administrations
"Düzenleyici ve Denetleyici Kurumlar", # Regulatory and Supervisory Institutions
"Sosyal Güvenlik Kurumları", # Social Security Institutions
"Özel İdareler", # Special Administrations
"Belediyeler ve Bağlı İdareler", # Municipalities and Affiliated Administrations
"Diğer" # Other
]
# Decision Subject Categories (Web Karar Konusu) - Shortened for token efficiency
WebKararKonusuEnum = Literal[
"ALL", # All subjects
"Harcırah Mevzuatı", # Travel Allowance Legislation
"İhale Mevzuatı", # Procurement Legislation
"İş Mevzuatı", # Labor Legislation
"Personel Mevzuatı", # Personnel Legislation
"Sorumluluk ve Yargılama Usulleri", # Liability and Trial Procedures
"Vergi Resmi Harç ve Diğer Gelirler", # Tax, Official Fee and Other Revenue
"Çeşitli Konular" # Various Topics
]
# Mapping from shortened enum values to full API values
WEB_KARAR_KONUSU_MAPPING = {
"ALL": "ALL",
"Harcırah Mevzuatı": "Harcırah Mevzuatı ile İlgili Kararlar",
"İhale Mevzuatı": "İhale Mevzuatı ile İlgili Kararlar",
"İş Mevzuatı": "İş Mevzuatı ile İlgili Kararlar",
"Personel Mevzuatı": "Personel Mevzuatı ile İlgili Kararlar",
"Sorumluluk ve Yargılama Usulleri": "Sorumluluk ve Yargılama Usulleri ile İlgili Kararlar",
"Vergi Resmi Harç ve Diğer Gelirler": "Vergi Resmi Harç ve Diğer Gelirlerle İlgili Kararlar",
"Çeşitli Konular": "Çeşitli Konuları İlgilendiren Kararlar"
}
# Year ranges for different endpoints
GENEL_KURUL_YEARS = [str(year) for year in range(2006, 2025)] # 2006-2024
TEMYIZ_KURULU_YEARS = [str(year) for year in range(1993, 2023)] # 1993-2022
DAIRE_YEARS = [str(year) for year in range(2012, 2026)] # 2012-2025
# Account years for Temyiz Kurulu and Daire endpoints
HESAP_YILLARI = [str(year) for year in range(1993, 2024)] # 1993-2023
+220
View File
@@ -0,0 +1,220 @@
# sayistay_mcp_module/models.py
from pydantic import BaseModel, Field
from typing import Optional, List, Union, Dict, Any, Literal
from enum import Enum
from .enums import DaireEnum, KamuIdaresiTuruEnum, WebKararKonusuEnum
# --- Unified Enums ---
class SayistayDecisionTypeEnum(str, Enum):
GENEL_KURUL = "genel_kurul"
TEMYIZ_KURULU = "temyiz_kurulu"
DAIRE = "daire"
# ============================================================================
# Genel Kurul (General Assembly) Models
# ============================================================================
class GenelKurulSearchRequest(BaseModel):
"""
Search request for Sayıştay Genel Kurul (General Assembly) decisions.
Genel Kurul decisions are precedent-setting rulings made by the full assembly
of the Turkish Court of Accounts, typically addressing interpretation of
audit and accountability regulations.
"""
karar_no: str = Field("", description="Decision no")
karar_ek: str = Field("", description="Appendix no")
karar_tarih_baslangic: str = Field("", description="Start year (YYYY)")
karar_tarih_bitis: str = Field("", description="End year")
karar_tamami: str = Field("", description="Value")
# DataTables pagination
start: int = Field(0, description="Starting record for pagination (0-based)")
length: int = Field(10, description="Number of records per page (1-10)")
class GenelKurulDecision(BaseModel):
"""Single Genel Kurul decision entry from search results."""
id: int = Field(..., description="Unique decision ID")
karar_no: str = Field(..., description="Decision number (e.g., '5415/1')")
karar_tarih: str = Field(..., description="Decision date in DD.MM.YYYY format")
karar_ozeti: str = Field(..., description="Decision summary/abstract")
class GenelKurulSearchResponse(BaseModel):
"""Response from Genel Kurul search endpoint."""
decisions: List[GenelKurulDecision] = Field(default_factory=list, description="List of matching decisions")
total_records: int = Field(0, description="Total number of matching records")
total_filtered: int = Field(0, description="Number of records after filtering")
draw: int = Field(1, description="DataTables draw counter")
# ============================================================================
# Temyiz Kurulu (Appeals Board) Models
# ============================================================================
class TemyizKuruluSearchRequest(BaseModel):
"""
Search request for Sayıştay Temyiz Kurulu (Appeals Board) decisions.
Temyiz Kurulu reviews appeals against audit chamber decisions,
providing higher-level review of audit findings and sanctions.
"""
ilam_dairesi: DaireEnum = Field("ALL", description="Value")
yili: str = Field("", description="Value")
karar_tarih_baslangic: str = Field("", description="Value")
karar_tarih_bitis: str = Field("", description="End year")
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Value")
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
dosya_no: str = Field("", description="File number for the case")
temyiz_tutanak_no: str = Field("", description="Appeals board meeting minutes number")
temyiz_karar: str = Field("", description="Value")
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Value")
# DataTables pagination
start: int = Field(0, description="Starting record for pagination (0-based)")
length: int = Field(10, description="Number of records per page (1-10)")
class TemyizKuruluDecision(BaseModel):
"""Single Temyiz Kurulu decision entry from search results."""
id: int = Field(..., description="Unique decision ID")
temyiz_tutanak_tarihi: str = Field(..., description="Appeals board meeting date in DD.MM.YYYY format")
ilam_dairesi: int = Field(..., description="Chamber number (1-8)")
temyiz_karar: str = Field(..., description="Appeals decision summary and reasoning")
class TemyizKuruluSearchResponse(BaseModel):
"""Response from Temyiz Kurulu search endpoint."""
decisions: List[TemyizKuruluDecision] = Field(default_factory=list, description="List of matching appeals decisions")
total_records: int = Field(0, description="Total number of matching records")
total_filtered: int = Field(0, description="Number of records after filtering")
draw: int = Field(1, description="DataTables draw counter")
# ============================================================================
# Daire (Chamber) Models
# ============================================================================
class DaireSearchRequest(BaseModel):
"""
Search request for Sayıştay Daire (Chamber) decisions.
Daire decisions are first-instance audit findings and sanctions
issued by individual audit chambers before potential appeals.
"""
yargilama_dairesi: DaireEnum = Field("ALL", description="Value")
karar_tarih_baslangic: str = Field("", description="Value")
karar_tarih_bitis: str = Field("", description="End year")
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Value")
hesap_yili: str = Field("", description="Value")
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Value")
web_karar_metni: str = Field("", description="Value")
# DataTables pagination
start: int = Field(0, description="Starting record for pagination (0-based)")
length: int = Field(10, description="Number of records per page (1-10)")
class DaireDecision(BaseModel):
"""Single Daire decision entry from search results."""
id: int = Field(..., description="Unique decision ID")
yargilama_dairesi: int = Field(..., description="Chamber number (1-8)")
karar_tarih: str = Field(..., description="Decision date in DD.MM.YYYY format")
karar_no: str = Field(..., description="Decision number")
ilam_no: str = Field("", description="Audit report number (may be null)")
madde_no: int = Field(..., description="Article/item number within the decision")
kamu_idaresi_turu: str = Field(..., description="Public administration type")
hesap_yili: int = Field(..., description="Account year being audited")
web_karar_konusu: str = Field(..., description="Decision subject category")
web_karar_metni: str = Field(..., description="Decision text/summary")
class DaireSearchResponse(BaseModel):
"""Response from Daire search endpoint."""
decisions: List[DaireDecision] = Field(default_factory=list, description="List of matching chamber decisions")
total_records: int = Field(0, description="Total number of matching records")
total_filtered: int = Field(0, description="Number of records after filtering")
draw: int = Field(1, description="DataTables draw counter")
# ============================================================================
# Document Models
# ============================================================================
class SayistayDocumentMarkdown(BaseModel):
"""
Sayıştay decision document converted to Markdown format.
Used for retrieving full text of decisions from any of the three
decision types (Genel Kurul, Temyiz Kurulu, Daire).
"""
decision_id: str = Field(..., description="Unique decision identifier")
decision_type: str = Field(..., description="Value")
source_url: str = Field(..., description="Original URL where the document was retrieved")
markdown_content: Optional[str] = Field(None, description="Full decision text converted to Markdown format")
retrieval_date: Optional[str] = Field(None, description="Date when document was retrieved (ISO format)")
error_message: Optional[str] = Field(None, description="Error message if document retrieval failed")
# ============================================================================
# Unified Models
# ============================================================================
class SayistayUnifiedSearchRequest(BaseModel):
"""Unified search request for all Sayıştay decision types."""
decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Decision type: genel_kurul, temyiz_kurulu, or daire")
# Common pagination parameters
start: int = Field(0, ge=0, description="Starting record for pagination (0-based)")
length: int = Field(10, ge=1, le=100, description="Number of records per page (1-100)")
# Common search parameters
karar_tarih_baslangic: str = Field("", description="Start date (DD.MM.YYYY format)")
karar_tarih_bitis: str = Field("", description="End date (DD.MM.YYYY format)")
kamu_idaresi_turu: KamuIdaresiTuruEnum = Field("ALL", description="Public administration type filter")
ilam_no: str = Field("", description="Audit report number (İlam No, max 50 chars)")
web_karar_konusu: WebKararKonusuEnum = Field("ALL", description="Decision subject category filter")
# Genel Kurul specific parameters (ignored for other types)
karar_no: str = Field("", description="Decision number (genel_kurul only)")
karar_ek: str = Field("", description="Decision appendix number (genel_kurul only)")
karar_tamami: str = Field("", description="Full text search (genel_kurul only)")
# Temyiz Kurulu specific parameters (ignored for other types)
ilam_dairesi: DaireEnum = Field("ALL", description="Audit chamber selection (temyiz_kurulu only)")
yili: str = Field("", description="Year (YYYY format, temyiz_kurulu only)")
dosya_no: str = Field("", description="File number (temyiz_kurulu only)")
temyiz_tutanak_no: str = Field("", description="Appeals board meeting minutes number (temyiz_kurulu only)")
temyiz_karar: str = Field("", description="Appeals decision text search (temyiz_kurulu only)")
# Daire specific parameters (ignored for other types)
yargilama_dairesi: DaireEnum = Field("ALL", description="Chamber selection (daire only)")
hesap_yili: str = Field("", description="Account year (daire only)")
web_karar_metni: str = Field("", description="Decision text search (daire only)")
class SayistayUnifiedSearchResult(BaseModel):
"""Unified search result containing decisions from any Sayıştay decision type."""
decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Type of decisions returned")
decisions: List[Dict[str, Any]] = Field(default_factory=list, description="Decision list (structure varies by type)")
total_records: int = Field(0, description="Total number of records found")
total_filtered: int = Field(0, description="Number of records after filtering")
draw: int = Field(1, description="DataTables draw counter")
class SayistayUnifiedDocumentMarkdown(BaseModel):
"""Unified document model for all Sayıştay decision types."""
decision_type: Literal["genel_kurul", "temyiz_kurulu", "daire"] = Field(..., description="Type of document")
decision_id: str = Field(..., description="Decision ID")
source_url: str = Field(..., description="Source URL of the document")
document_data: Dict[str, Any] = Field(default_factory=dict, description="Document content and metadata")
markdown_content: Optional[str] = Field(None, description="Markdown content")
error_message: Optional[str] = Field(None, description="Error message if retrieval failed")
+133
View File
@@ -0,0 +1,133 @@
# sayistay_mcp_module/unified_client.py
# Unified client for all three Sayıştay decision types
import logging
from typing import Optional, Dict, Any
from urllib.parse import urlparse
from .models import (
SayistayUnifiedSearchRequest,
SayistayUnifiedSearchResult,
SayistayUnifiedDocumentMarkdown,
GenelKurulSearchRequest,
TemyizKuruluSearchRequest,
DaireSearchRequest
)
from .client import SayistayApiClient
logger = logging.getLogger(__name__)
class SayistayUnifiedClient:
"""Unified client that handles all three Sayıştay decision types."""
def __init__(self, request_timeout: float = 60.0):
self.client = SayistayApiClient(request_timeout)
async def search_unified(self, params: SayistayUnifiedSearchRequest) -> SayistayUnifiedSearchResult:
"""Unified search that routes to appropriate search method based on decision_type."""
if params.decision_type == "genel_kurul":
# Convert to genel kurul request
genel_kurul_params = GenelKurulSearchRequest(
karar_no=params.karar_no,
karar_ek=params.karar_ek,
karar_tarih_baslangic=params.karar_tarih_baslangic,
karar_tarih_bitis=params.karar_tarih_bitis,
karar_tamami=params.karar_tamami,
start=params.start,
length=params.length
)
result = await self.client.search_genel_kurul_decisions(genel_kurul_params)
# Convert to unified format
decisions_list = [decision.model_dump() for decision in result.decisions]
return SayistayUnifiedSearchResult(
decision_type="genel_kurul",
decisions=decisions_list,
total_records=result.total_records,
total_filtered=result.total_filtered,
draw=result.draw
)
elif params.decision_type == "temyiz_kurulu":
# Convert to temyiz kurulu request
temyiz_params = TemyizKuruluSearchRequest(
ilam_dairesi=params.ilam_dairesi,
yili=params.yili,
karar_tarih_baslangic=params.karar_tarih_baslangic,
karar_tarih_bitis=params.karar_tarih_bitis,
kamu_idaresi_turu=params.kamu_idaresi_turu,
ilam_no=params.ilam_no,
dosya_no=params.dosya_no,
temyiz_tutanak_no=params.temyiz_tutanak_no,
temyiz_karar=params.temyiz_karar,
web_karar_konusu=params.web_karar_konusu,
start=params.start,
length=params.length
)
result = await self.client.search_temyiz_kurulu_decisions(temyiz_params)
# Convert to unified format
decisions_list = [decision.model_dump() for decision in result.decisions]
return SayistayUnifiedSearchResult(
decision_type="temyiz_kurulu",
decisions=decisions_list,
total_records=result.total_records,
total_filtered=result.total_filtered,
draw=result.draw
)
elif params.decision_type == "daire":
# Convert to daire request
daire_params = DaireSearchRequest(
yargilama_dairesi=params.yargilama_dairesi,
karar_tarih_baslangic=params.karar_tarih_baslangic,
karar_tarih_bitis=params.karar_tarih_bitis,
ilam_no=params.ilam_no,
kamu_idaresi_turu=params.kamu_idaresi_turu,
hesap_yili=params.hesap_yili,
web_karar_konusu=params.web_karar_konusu,
web_karar_metni=params.web_karar_metni,
start=params.start,
length=params.length
)
result = await self.client.search_daire_decisions(daire_params)
# Convert to unified format
decisions_list = [decision.model_dump() for decision in result.decisions]
return SayistayUnifiedSearchResult(
decision_type="daire",
decisions=decisions_list,
total_records=result.total_records,
total_filtered=result.total_filtered,
draw=result.draw
)
else:
raise ValueError(f"Unsupported decision type: {params.decision_type}")
async def get_document_unified(self, decision_id: str, decision_type: str) -> SayistayUnifiedDocumentMarkdown:
"""Unified document retrieval for all Sayıştay decision types."""
# Use existing client method (decision_type is already a string)
result = await self.client.get_document_as_markdown(decision_id, decision_type)
return SayistayUnifiedDocumentMarkdown(
decision_type=decision_type,
decision_id=result.decision_id,
source_url=result.source_url,
document_data=result.model_dump(),
markdown_content=result.markdown_content,
error_message=result.error_message
)
async def close_client_session(self):
"""Close the underlying client session."""
if hasattr(self.client, 'close_client_session'):
await self.client.close_client_session()
+23
View File
@@ -0,0 +1,23 @@
# semantic_search/__init__.py
from .embedder import (
OpenRouterEmbedder,
LocalEmbedder,
get_embedder,
is_openrouter_available,
is_local_embedding_configured,
is_semantic_search_available,
)
from .vector_store import VectorStore
from .processor import DocumentProcessor
__all__ = [
'OpenRouterEmbedder',
'LocalEmbedder',
'get_embedder',
'is_openrouter_available',
'is_local_embedding_configured',
'is_semantic_search_available',
'VectorStore',
'DocumentProcessor',
]
+348
View File
@@ -0,0 +1,348 @@
# semantic_search/embedder.py
import logging
import os
from typing import Dict, List, Optional
import numpy as np
logger = logging.getLogger(__name__)
# OpenRouter defaults (preserve backward compatibility)
DEFAULT_MODEL = "google/gemini-embedding-001"
DEFAULT_DIMENSION = 3072
# Local provider defaults — Ollama with nomic-embed-text out of the box.
# Override via LOCAL_EMBEDDING_BASE_URL / LOCAL_EMBEDDING_MODEL /
# LOCAL_EMBEDDING_DIMENSION when using a different server or model.
# For Turkish, intfloat/multilingual-e5-large (1024 dims, prompt_style=e5)
# served via HuggingFace TEI is the recommended setup — see README.
LOCAL_DEFAULT_BASE_URL = "http://localhost:11434/v1"
LOCAL_DEFAULT_MODEL = "nomic-embed-text"
LOCAL_DEFAULT_DIMENSION = 768
# Prompt-template styles. Embedding models are trained with specific
# prefixes — using the wrong style silently degrades retrieval quality.
# - "gemini": "task: {task} | query: {text}" / "title: {title} | text: {text}"
# (matches google/gemini-embedding-001, the OpenRouter default)
# - "e5": "query: {text}" / "passage: {text}"
# (matches intfloat/multilingual-e5-* models — best for Turkish)
# - "raw": no prefix; pass text through as-is
PROMPT_STYLES = ("gemini", "e5", "raw")
DEFAULT_PROMPT_STYLE = "gemini"
def _format_query(prompt_style: str, query: str, task: str) -> str:
if prompt_style == "e5":
return f"query: {query}"
if prompt_style == "raw":
return query
# gemini (default)
return f"task: {task} | query: {query}"
def _format_document(prompt_style: str, doc: str, title: str) -> str:
if prompt_style == "e5":
return f"passage: {doc}"
if prompt_style == "raw":
return doc
# gemini (default)
return f"title: {title} | text: {doc}"
def _resolve_prompt_style(explicit: Optional[str], default: str) -> str:
style = (explicit or os.getenv("EMBEDDING_PROMPT_STYLE") or default).strip().lower()
if style not in PROMPT_STYLES:
raise ValueError(
f"Unknown EMBEDDING_PROMPT_STYLE {style!r}; expected one of {PROMPT_STYLES}"
)
return style
def is_openrouter_available() -> bool:
"""Check if OpenRouter API key is available."""
return bool(os.getenv("OPENROUTER_API_KEY"))
def is_local_embedding_configured() -> bool:
"""Check if the user opted into a local embedding endpoint."""
return os.getenv("EMBEDDING_PROVIDER", "").strip().lower() == "local"
def is_semantic_search_available() -> bool:
"""Returns True if any embedding provider is configured."""
return is_local_embedding_configured() or is_openrouter_available()
def _coerce_dimension(value, env_name: str, default: int) -> int:
"""Parse a dimension value (int or str) with clear error messages."""
if value is None:
return default
try:
parsed = int(value)
except (TypeError, ValueError) as e:
raise ValueError(
f"{env_name} must be an integer, got {value!r}"
) from e
if parsed <= 0:
raise ValueError(f"Embedding dimension must be positive, got {parsed}")
return parsed
class _BaseOpenAICompatibleEmbedder:
"""
Shared encode/similarity logic for embedders backed by the OpenAI Python
SDK. Subclasses configure ``client``, ``model``, ``dimension``, and
optionally ``_extra_headers`` (e.g. OpenRouter ranking headers).
"""
# Subclasses may override; sent on every embeddings.create call when set.
_extra_headers: Dict[str, str] = {}
# Set by subclasses
client = None
model: str = ""
dimension: int = 0
prompt_style: str = DEFAULT_PROMPT_STYLE
def encode_query(self, query: str, task: str = "search result") -> np.ndarray:
"""
Encode a search query. Prefix is selected by ``self.prompt_style``.
Args:
query: The search query text
task: Task hint used by the gemini-style prefix; ignored for
e5/raw styles.
Returns:
Numpy array of embeddings (``self.dimension`` elements).
"""
text = _format_query(self.prompt_style, query, task)
try:
response = self.client.embeddings.create(
model=self.model,
input=text,
encoding_format="float",
extra_headers=self._extra_headers or None,
)
embedding = np.array(response.data[0].embedding, dtype=np.float32)
# L2 normalize for cosine similarity
norm = np.linalg.norm(embedding)
if norm > 0:
embedding = embedding / norm
logger.debug(f"Encoded query: {query[:50]}... -> shape: {embedding.shape}")
return embedding
except Exception as e:
logger.error(f"Failed to encode query: {e}")
raise
def encode_documents(self, documents: List[str], titles: Optional[List[str]] = None) -> np.ndarray:
"""
Encode multiple documents with a batch API call.
Args:
documents: List of document texts
titles: Optional list of document titles
Returns:
Numpy array of embeddings (N x ``self.dimension``).
"""
if not documents:
return np.array([])
texts = []
for i, doc in enumerate(documents):
title = titles[i] if titles and i < len(titles) else "none"
texts.append(_format_document(self.prompt_style, doc, title))
try:
response = self.client.embeddings.create(
model=self.model,
input=texts,
encoding_format="float",
extra_headers=self._extra_headers or None,
)
embeddings = np.array(
[d.embedding for d in sorted(response.data, key=lambda x: x.index)],
dtype=np.float32,
)
# L2 normalize each embedding for cosine similarity
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
embeddings = embeddings / (norms + 1e-8)
logger.info(f"Encoded {len(documents)} documents -> shape: {embeddings.shape}")
return embeddings
except Exception as e:
logger.error(f"Failed to encode documents: {e}")
raise
def compute_similarity(self, query_embedding: np.ndarray, document_embeddings: np.ndarray) -> np.ndarray:
"""
Compute cosine similarity between query and documents.
Args:
query_embedding: Query embedding (``self.dimension``,)
document_embeddings: Document embeddings (N x ``self.dimension``)
Returns:
Similarity scores (N,)
"""
if len(query_embedding.shape) == 1:
query_embedding = query_embedding.reshape(1, -1)
# Embeddings are already L2-normalized.
similarities = np.dot(document_embeddings, query_embedding.T).squeeze()
return similarities
class OpenRouterEmbedder(_BaseOpenAICompatibleEmbedder):
"""
Embedder using OpenRouter's embedding API.
The model and dimension are configurable so users can pick any OpenRouter
embedding model (e.g. when one becomes paid). Configuration precedence:
explicit constructor args > environment variables > defaults.
Environment variables:
OPENROUTER_API_KEY (required): OpenRouter credential
OPENROUTER_EMBEDDING_MODEL (optional): override the embedding model id
OPENROUTER_EMBEDDING_DIMENSION (optional): override the vector size
Defaults preserve backward compatibility: ``google/gemini-embedding-001``
at 3072 dimensions.
"""
_extra_headers = {
"HTTP-Referer": "https://yargimcp.com",
"X-Title": "Yargi MCP Server",
}
def __init__(
self,
model: Optional[str] = None,
dimension: Optional[int] = None,
prompt_style: Optional[str] = None,
):
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
raise ValueError("OPENROUTER_API_KEY environment variable is not set")
try:
from openai import OpenAI
except ImportError:
raise ImportError("openai package is required. Install with: pip install openai")
self.client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=api_key,
)
self.model = model or os.getenv("OPENROUTER_EMBEDDING_MODEL") or DEFAULT_MODEL
self.dimension = _coerce_dimension(
dimension if dimension is not None else os.getenv("OPENROUTER_EMBEDDING_DIMENSION"),
"OPENROUTER_EMBEDDING_DIMENSION",
DEFAULT_DIMENSION,
)
# Default to gemini-style prefix for OpenRouter — matches the default
# google/gemini-embedding-001 model. Override via constructor or
# EMBEDDING_PROMPT_STYLE env var when picking a different model.
self.prompt_style = _resolve_prompt_style(prompt_style, "gemini")
logger.info(
f"OpenRouter Embedder initialized with model: {self.model} "
f"(dimension={self.dimension}, prompt_style={self.prompt_style})"
)
class LocalEmbedder(_BaseOpenAICompatibleEmbedder):
"""
Embedder for a local OpenAI-compatible embedding server Ollama,
llama.cpp, vLLM, LM Studio, etc. Zero new Python dependencies; just
point the existing OpenAI SDK at a local base URL.
Environment variables:
EMBEDDING_PROVIDER=local (selects this provider)
LOCAL_EMBEDDING_BASE_URL (default: http://localhost:11434/v1)
LOCAL_EMBEDDING_MODEL (default: nomic-embed-text)
LOCAL_EMBEDDING_DIMENSION (default: 768)
LOCAL_EMBEDDING_API_KEY (optional; ignored by most local servers)
Setup (Ollama):
$ ollama serve
$ ollama pull nomic-embed-text # or bge-m3 for better Turkish
The dimension MUST match the model's actual output size (e.g. 768 for
nomic-embed-text, 1024 for bge-m3, 1024 for mxbai-embed-large).
"""
def __init__(
self,
base_url: Optional[str] = None,
model: Optional[str] = None,
dimension: Optional[int] = None,
api_key: Optional[str] = None,
prompt_style: Optional[str] = None,
):
try:
from openai import OpenAI
except ImportError:
raise ImportError("openai package is required. Install with: pip install openai")
self.base_url = (
base_url
or os.getenv("LOCAL_EMBEDDING_BASE_URL")
or LOCAL_DEFAULT_BASE_URL
)
# Most local servers don't validate the key — use a placeholder so
# the OpenAI SDK doesn't error on the missing-key check.
effective_key = (
api_key
or os.getenv("LOCAL_EMBEDDING_API_KEY")
or "no-key-needed"
)
self.client = OpenAI(base_url=self.base_url, api_key=effective_key)
self.model = model or os.getenv("LOCAL_EMBEDDING_MODEL") or LOCAL_DEFAULT_MODEL
self.dimension = _coerce_dimension(
dimension if dimension is not None else os.getenv("LOCAL_EMBEDDING_DIMENSION"),
"LOCAL_EMBEDDING_DIMENSION",
LOCAL_DEFAULT_DIMENSION,
)
# Default to e5 prefix for local — the recommended Turkish setup
# (multilingual-e5-large). Override via EMBEDDING_PROMPT_STYLE when
# using a different model family (e.g. nomic, bge).
self.prompt_style = _resolve_prompt_style(prompt_style, "e5")
logger.info(
f"Local Embedder initialized: model={self.model} "
f"base_url={self.base_url} dimension={self.dimension} "
f"prompt_style={self.prompt_style}"
)
def get_embedder():
"""
Factory that picks the embedder based on EMBEDDING_PROVIDER.
- ``EMBEDDING_PROVIDER=local`` -> ``LocalEmbedder``
- otherwise -> ``OpenRouterEmbedder`` (requires OPENROUTER_API_KEY)
Raises:
ValueError: If no provider is configured (neither local nor OpenRouter).
"""
if is_local_embedding_configured():
return LocalEmbedder()
if is_openrouter_available():
return OpenRouterEmbedder()
raise ValueError(
"No embedding provider configured. Set OPENROUTER_API_KEY for hosted "
"embeddings, or EMBEDDING_PROVIDER=local (with LOCAL_EMBEDDING_* "
"env vars) for a local OpenAI-compatible server like Ollama."
)
+305
View File
@@ -0,0 +1,305 @@
# semantic_search/processor.py
import logging
import re
from typing import List, Dict, Any, Optional
from dataclasses import dataclass
import hashlib
logger = logging.getLogger(__name__)
@dataclass
class DocumentChunk:
"""Represents a chunk of a document."""
chunk_id: str
document_id: str
text: str
metadata: Dict[str, Any]
chunk_index: int
total_chunks: int
class DocumentProcessor:
"""
Processes legal documents for semantic search.
Handles chunking, cleaning, and metadata extraction.
"""
def __init__(self,
chunk_size: int = 1000,
chunk_overlap: int = 200,
min_chunk_size: int = 100):
"""
Initialize document processor.
Args:
chunk_size: Target size for each chunk in characters
chunk_overlap: Number of overlapping characters between chunks
min_chunk_size: Minimum chunk size to keep
"""
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.min_chunk_size = min_chunk_size
logger.info(f"Initialized DocumentProcessor (chunk_size={chunk_size}, overlap={chunk_overlap})")
def process_document(self,
document_id: str,
text: str,
metadata: Optional[Dict[str, Any]] = None) -> List[DocumentChunk]:
"""
Process a single document into chunks.
Args:
document_id: Unique document identifier
text: Document text content
metadata: Optional document metadata
Returns:
List of document chunks
"""
if not text or len(text.strip()) < self.min_chunk_size:
logger.warning(f"Document {document_id} too short to process")
return []
# Clean text
cleaned_text = self._clean_text(text)
# Extract metadata from text if not provided
if metadata is None:
metadata = {}
# Add extracted metadata
extracted_metadata = self._extract_metadata(cleaned_text)
metadata.update(extracted_metadata)
# Create chunks
chunks = self._create_chunks(cleaned_text)
# Create DocumentChunk objects
document_chunks = []
for i, chunk_text in enumerate(chunks):
chunk_id = self._generate_chunk_id(document_id, i)
chunk = DocumentChunk(
chunk_id=chunk_id,
document_id=document_id,
text=chunk_text,
metadata={
**metadata,
'chunk_index': i,
'total_chunks': len(chunks)
},
chunk_index=i,
total_chunks=len(chunks)
)
document_chunks.append(chunk)
logger.info(f"Processed document {document_id} into {len(chunks)} chunks")
return document_chunks
def _clean_text(self, text: str) -> str:
"""
Clean and normalize text for processing.
Args:
text: Raw text
Returns:
Cleaned text
"""
# Remove excessive whitespace
text = re.sub(r'\s+', ' ', text)
# Remove special characters but keep Turkish characters
# Keep: letters, numbers, spaces, and common punctuation
text = re.sub(r'[^\w\s\.\,\;\:\!\?\-\(\)\"\'ÇĞIİÖŞÜçğıiöşü]', ' ', text)
# Remove multiple spaces
text = re.sub(r' +', ' ', text)
# Trim
text = text.strip()
return text
def _extract_metadata(self, text: str) -> Dict[str, Any]:
"""
Extract metadata from legal document text.
Args:
text: Document text
Returns:
Extracted metadata
"""
metadata = {}
# Extract case numbers (Esas/Karar)
esas_pattern = r'E(?:sas)?[\s\.\:]*(\d{4})[\/\-](\d+)'
karar_pattern = r'K(?:arar)?[\s\.\:]*(\d{4})[\/\-](\d+)'
esas_match = re.search(esas_pattern, text[:500]) # Look in first 500 chars
if esas_match:
metadata['esas_no'] = f"E.{esas_match.group(1)}/{esas_match.group(2)}"
karar_match = re.search(karar_pattern, text[:500])
if karar_match:
metadata['karar_no'] = f"K.{karar_match.group(1)}/{karar_match.group(2)}"
# Extract dates (DD.MM.YYYY or DD/MM/YYYY format)
date_pattern = r'(\d{1,2})[\.\/](\d{1,2})[\.\/](\d{4})'
dates = re.findall(date_pattern, text[:1000]) # Look in first 1000 chars
if dates:
# Take the first date as decision date
day, month, year = dates[0]
metadata['karar_tarihi'] = f"{year}-{month.zfill(2)}-{day.zfill(2)}"
# Extract court/chamber name
chamber_patterns = [
r'(\d+)\.\s*Hukuk\s+Dairesi',
r'(\d+)\.\s*Ceza\s+Dairesi',
r'Hukuk\s+Genel\s+Kurulu',
r'Ceza\s+Genel\s+Kurulu',
r'(\d+)\.\s*Daire'
]
for pattern in chamber_patterns:
match = re.search(pattern, text[:500], re.IGNORECASE)
if match:
metadata['chamber'] = match.group(0)
break
return metadata
def _create_chunks(self, text: str) -> List[str]:
"""
Create overlapping chunks from text.
Args:
text: Cleaned document text
Returns:
List of text chunks
"""
chunks = []
# Split by sentences for better semantic coherence
sentences = self._split_sentences(text)
current_chunk = []
current_size = 0
for sentence in sentences:
sentence_size = len(sentence)
# If adding this sentence exceeds chunk size
if current_size + sentence_size > self.chunk_size and current_chunk:
# Save current chunk
chunk_text = ' '.join(current_chunk)
chunks.append(chunk_text)
# Create overlap for next chunk
overlap_size = 0
overlap_sentences = []
# Add sentences from the end until we reach overlap size
for sent in reversed(current_chunk):
overlap_size += len(sent)
overlap_sentences.insert(0, sent)
if overlap_size >= self.chunk_overlap:
break
# Start new chunk with overlap
current_chunk = overlap_sentences
current_size = sum(len(s) for s in current_chunk)
# Add sentence to current chunk
current_chunk.append(sentence)
current_size += sentence_size
# Add final chunk if not empty
if current_chunk:
chunk_text = ' '.join(current_chunk)
if len(chunk_text) >= self.min_chunk_size:
chunks.append(chunk_text)
return chunks
def _split_sentences(self, text: str) -> List[str]:
"""
Split text into sentences.
Args:
text: Text to split
Returns:
List of sentences
"""
# Simple sentence splitting for Turkish text
# Split on period, question mark, exclamation, but not on abbreviations
# Common Turkish abbreviations to preserve
abbreviations = ['Dr', 'Prof', 'Av', 'Md', 'Yrd', 'Doç', 'No', 'S', 'vs', 'vb', 'bkz']
# Replace abbreviations temporarily
temp_text = text
replacements = {}
for i, abbr in enumerate(abbreviations):
placeholder = f"__ABBR{i}__"
temp_text = temp_text.replace(f"{abbr}.", placeholder)
replacements[placeholder] = f"{abbr}."
# Split sentences
sentence_endings = re.compile(r'[.!?]+')
sentences = sentence_endings.split(temp_text)
# Restore abbreviations and clean
cleaned_sentences = []
for sentence in sentences:
# Restore abbreviations
for placeholder, original in replacements.items():
sentence = sentence.replace(placeholder, original)
# Clean and add if not empty
sentence = sentence.strip()
if sentence and len(sentence) > 10: # Minimum sentence length
cleaned_sentences.append(sentence)
return cleaned_sentences
def _generate_chunk_id(self, document_id: str, chunk_index: int) -> str:
"""
Generate unique chunk ID.
Args:
document_id: Parent document ID
chunk_index: Index of chunk in document
Returns:
Unique chunk ID
"""
chunk_string = f"{document_id}_chunk_{chunk_index}"
chunk_hash = hashlib.md5(chunk_string.encode()).hexdigest()[:8]
return f"{document_id}_c{chunk_index}_{chunk_hash}"
def combine_chunks(self, chunks: List[DocumentChunk]) -> str:
"""
Combine chunks back into full document text.
Args:
chunks: List of document chunks
Returns:
Combined text
"""
if not chunks:
return ""
# Sort by chunk index
sorted_chunks = sorted(chunks, key=lambda x: x.chunk_index)
# For overlapping chunks, we need to be careful about duplication
# Simple approach: just concatenate with space
combined = " ".join([chunk.text for chunk in sorted_chunks])
return combined
+235
View File
@@ -0,0 +1,235 @@
# semantic_search/vector_store.py
import logging
import numpy as np
from typing import List, Dict, Any, Tuple, Optional
from dataclasses import dataclass
import json
logger = logging.getLogger(__name__)
@dataclass
class Document:
"""Represents a document with its embedding and metadata."""
id: str
text: str
embedding: np.ndarray
metadata: Dict[str, Any]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary (excluding embedding for serialization)."""
return {
'id': self.id,
'text': self.text,
'metadata': self.metadata
}
class VectorStore:
"""
In-memory vector storage with similarity search capabilities.
Future versions can use Faiss, ChromaDB, or other vector databases.
"""
def __init__(self, dimension: int = 768):
"""
Initialize vector store.
Args:
dimension: Embedding dimension size
"""
self.dimension = dimension
self.documents: List[Document] = []
self.embeddings: Optional[np.ndarray] = None
self.index_built = False
logger.info(f"Initialized VectorStore with dimension: {dimension}")
def add_documents(self,
ids: List[str],
texts: List[str],
embeddings: np.ndarray,
metadata: Optional[List[Dict[str, Any]]] = None) -> int:
"""
Add documents to the vector store.
Args:
ids: Document IDs
texts: Document texts
embeddings: Document embeddings (N x dimension)
metadata: Optional metadata for each document
Returns:
Number of documents added
"""
if len(ids) != len(texts) or len(ids) != embeddings.shape[0]:
raise ValueError("Mismatched lengths for ids, texts, and embeddings")
if metadata and len(metadata) != len(ids):
raise ValueError("Metadata length doesn't match document count")
# Add documents
for i in range(len(ids)):
doc = Document(
id=ids[i],
text=texts[i],
embedding=embeddings[i],
metadata=metadata[i] if metadata else {}
)
self.documents.append(doc)
# Rebuild index
self._build_index()
logger.info(f"Added {len(ids)} documents to vector store. Total: {len(self.documents)}")
return len(ids)
def _build_index(self):
"""Build or rebuild the embedding index."""
if not self.documents:
self.embeddings = None
self.index_built = False
return
# Stack all embeddings into a single array
self.embeddings = np.vstack([doc.embedding for doc in self.documents])
self.index_built = True
logger.debug(f"Built index with shape: {self.embeddings.shape}")
def search(self,
query_embedding: np.ndarray,
top_k: int = 10,
threshold: Optional[float] = None) -> List[Tuple[Document, float]]:
"""
Search for similar documents using cosine similarity.
Args:
query_embedding: Query embedding vector
top_k: Number of results to return
threshold: Optional similarity threshold (0-1)
Returns:
List of (Document, similarity_score) tuples
"""
if not self.index_built or self.embeddings is None:
logger.warning("No documents in vector store")
return []
# Ensure query is 2D
if len(query_embedding.shape) == 1:
query_embedding = query_embedding.reshape(1, -1)
# Compute cosine similarities (assuming normalized embeddings)
similarities = np.dot(self.embeddings, query_embedding.T).squeeze()
# Apply threshold if specified
if threshold is not None:
valid_indices = np.where(similarities >= threshold)[0]
if len(valid_indices) == 0:
logger.info(f"No documents above threshold {threshold}")
return []
similarities = similarities[valid_indices]
valid_docs = [self.documents[i] for i in valid_indices]
else:
valid_docs = self.documents
# Get top-k indices
top_k = min(top_k, len(valid_docs))
if top_k == 0:
return []
# Use argpartition for efficiency with large arrays
if len(similarities) > top_k:
top_indices = np.argpartition(similarities, -top_k)[-top_k:]
top_indices = top_indices[np.argsort(similarities[top_indices])[::-1]]
else:
top_indices = np.argsort(similarities)[::-1]
# Create results
results = []
for idx in top_indices:
doc = valid_docs[idx] if threshold else self.documents[idx]
score = float(similarities[idx])
results.append((doc, score))
logger.info(f"Search returned {len(results)} results (top_k={top_k})")
return results
def hybrid_search(self,
query_embedding: np.ndarray,
keyword_scores: Dict[str, float],
top_k: int = 10,
alpha: float = 0.5) -> List[Tuple[Document, float]]:
"""
Hybrid search combining vector similarity and keyword scores.
Args:
query_embedding: Query embedding vector
keyword_scores: Document ID to keyword relevance score mapping
top_k: Number of results to return
alpha: Weight for vector similarity (1-alpha for keyword score)
Returns:
List of (Document, combined_score) tuples
"""
if not self.index_built:
logger.warning("No documents in vector store")
return []
# Get vector similarities
vector_results = self.search(query_embedding, top_k=len(self.documents))
# Combine scores
combined_scores = []
for doc, vector_score in vector_results:
keyword_score = keyword_scores.get(doc.id, 0.0)
# Normalize keyword score to 0-1 range if needed
if keyword_score > 1.0:
keyword_score = keyword_score / max(keyword_scores.values())
combined_score = alpha * vector_score + (1 - alpha) * keyword_score
combined_scores.append((doc, combined_score))
# Sort by combined score and return top-k
combined_scores.sort(key=lambda x: x[1], reverse=True)
results = combined_scores[:top_k]
logger.info(f"Hybrid search returned {len(results)} results")
return results
def clear(self):
"""Clear all documents from the store."""
self.documents = []
self.embeddings = None
self.index_built = False
logger.info("Cleared vector store")
def size(self) -> int:
"""Get number of documents in store."""
return len(self.documents)
def get_by_id(self, doc_id: str) -> Optional[Document]:
"""Get document by ID."""
for doc in self.documents:
if doc.id == doc_id:
return doc
return None
def get_stats(self) -> Dict[str, Any]:
"""Get statistics about the vector store."""
stats = {
'num_documents': len(self.documents),
'dimension': self.dimension,
'index_built': self.index_built,
'memory_usage_mb': 0
}
if self.embeddings is not None:
# Estimate memory usage
memory_bytes = self.embeddings.nbytes
for doc in self.documents:
memory_bytes += len(doc.text.encode('utf-8'))
memory_bytes += len(json.dumps(doc.metadata).encode('utf-8'))
stats['memory_usage_mb'] = memory_bytes / (1024 * 1024)
return stats
+21
View File
@@ -0,0 +1,21 @@
# sigorta_tahkim_mcp_module/__init__.py
from .client import SigortaTahkimApiClient
from .models import (
SigortaTahkimSearchRequest,
SigortaTahkimDecisionSummary,
SigortaTahkimSearchResult,
SigortaTahkimDocumentMarkdown,
SigortaTahkimSearchWithinMatch,
SigortaTahkimSearchWithinResult
)
__all__ = [
"SigortaTahkimApiClient",
"SigortaTahkimSearchRequest",
"SigortaTahkimDecisionSummary",
"SigortaTahkimSearchResult",
"SigortaTahkimDocumentMarkdown",
"SigortaTahkimSearchWithinMatch",
"SigortaTahkimSearchWithinResult"
]
+345
View File
@@ -0,0 +1,345 @@
# sigorta_tahkim_mcp_module/client.py
import asyncio
import httpx
from typing import Optional
import logging
import os
import re
import io
import math
from markitdown import MarkItDown
from .models import (
SigortaTahkimSearchRequest,
SigortaTahkimDecisionSummary,
SigortaTahkimSearchResult,
SigortaTahkimDocumentMarkdown,
SigortaTahkimSearchWithinMatch,
SigortaTahkimSearchWithinResult
)
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Turkish-specific lowercase: İ→i, I→ı (Python's str.lower() doesn't handle these)
_TR_UPPER = str.maketrans("İIÇĞÖŞÜ", "iıçğöşü")
def _turkish_lower(text: str) -> str:
"""Lowercase with Turkish İ/I handling."""
return text.translate(_TR_UPPER).lower()
class SigortaTahkimApiClient:
"""
API client for searching and retrieving Sigorta Tahkim Komisyonu
(Insurance Arbitration Commission) decisions using Tavily Search API
for discovery and direct PDF download for content retrieval.
The commission publishes quarterly PDF journals ("Hakem Karar Dergisi")
containing arbitration decisions. There are 64 issues spanning 2010-2025.
"""
TAVILY_API_URL = "https://api.tavily.com/search"
BASE_URL = "https://www.sigortatahkim.org"
PDF_BASE_URL = "https://www.sigortatahkim.org/content/CmsFiles/"
DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000
def __init__(self, request_timeout: float = 60.0):
"""Initialize the Sigorta Tahkim API client."""
self.tavily_api_key = os.getenv("TAVILY_API_KEY")
if not self.tavily_api_key:
self.tavily_api_key = "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"
logger.info("Using fallback Tavily API token (development token)")
else:
logger.info("Using Tavily API key from environment variable")
self.http_client = httpx.AsyncClient(
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
},
timeout=httpx.Timeout(request_timeout)
)
self.markitdown = MarkItDown()
async def close_client_session(self):
"""Close the HTTP client session."""
await self.http_client.aclose()
logger.info("SigortaTahkimApiClient: HTTP client session closed.")
def _get_pdf_filename(self, issue_number: int) -> str:
"""Get the PDF filename for a given journal issue number."""
if issue_number == 4:
return "karardergisisayi4.pdf"
elif 57 <= issue_number <= 61:
return f"revizekd{issue_number}.pdf"
else:
return f"karardrgs{issue_number}.pdf"
def _extract_issue_number(self, url: str) -> Optional[str]:
"""Extract journal issue number from a sigortatahkim.org URL."""
# Pattern: karardrgs{N}.pdf
match = re.search(r'karardrgs(\d+)\.pdf', url, re.IGNORECASE)
if match:
return match.group(1)
# Pattern: revizekd{N}.pdf
match = re.search(r'revizekd(\d+)\.pdf', url, re.IGNORECASE)
if match:
return match.group(1)
# Pattern: karardergisisayi{N}.pdf
match = re.search(r'karardergisisayi(\d+)\.pdf', url, re.IGNORECASE)
if match:
return match.group(1)
# Pattern: sayı or sayi in URL path with number
match = re.search(r'say[ıi]\s*[-:]?\s*(\d+)', url, re.IGNORECASE)
if match:
return match.group(1)
return None
async def search_decisions(
self,
request: SigortaTahkimSearchRequest
) -> SigortaTahkimSearchResult:
"""
Search for Sigorta Tahkim Komisyonu decisions using Tavily API.
Args:
request: Search request parameters
Returns:
SigortaTahkimSearchResult with matching decisions
"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.tavily_api_key}"
}
payload = {
"query": request.keywords,
"country": "turkey",
"include_domains": ["sigortatahkim.org"],
"max_results": request.pageSize,
"search_depth": "advanced"
}
if request.page > 1:
logger.warning(f"Tavily API doesn't support pagination. Page {request.page} requested.")
response = await self.http_client.post(
self.TAVILY_API_URL,
json=payload,
headers=headers
)
response.raise_for_status()
data = response.json()
logger.info(f"Tavily returned {len(data.get('results', []))} results for Sigorta Tahkim")
decisions = []
for result in data.get("results", []):
url = result.get("url", "")
title = result.get("title", "").strip()
content = result.get("content", "")[:500]
issue_num = self._extract_issue_number(url)
doc_id = issue_num if issue_num else url
decision = SigortaTahkimDecisionSummary(
title=title,
document_id=doc_id,
content=content,
url=url
)
decisions.append(decision)
return SigortaTahkimSearchResult(
decisions=decisions,
total_results=len(data.get("results", [])),
page=request.page,
pageSize=request.pageSize
)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error searching Sigorta Tahkim decisions: {e}")
if e.response.status_code == 401:
raise Exception("Tavily API authentication failed. Check API key.")
raise Exception(f"Failed to search Sigorta Tahkim decisions: {str(e)}")
except Exception as e:
logger.error(f"Error searching Sigorta Tahkim decisions: {e}")
raise Exception(f"Failed to search Sigorta Tahkim decisions: {str(e)}")
# Regex pattern to split decisions within a journal issue
DECISION_HEADER_PATTERN = re.compile(
r'(\d{2}\.\d{2}\.\d{4}\s+Tarih\s+ve\s+K-\d{4}/\d+\s+Sayılı\s+Hakem\s+Kararı)'
)
# Minimum body length to distinguish real decisions from TOC entries
MIN_DECISION_BODY_LENGTH = 1000
async def _download_and_convert_pdf(self, issue_number: str) -> tuple[str, str]:
"""
Download a journal issue PDF and convert to markdown.
Returns:
Tuple of (markdown_content, pdf_url)
"""
issue_num = int(issue_number)
filename = self._get_pdf_filename(issue_num)
pdf_url = f"{self.PDF_BASE_URL}{filename}"
logger.info(f"Downloading Sigorta Tahkim PDF: {pdf_url}")
response = await self.http_client.get(pdf_url, follow_redirects=True)
response.raise_for_status()
pdf_stream = io.BytesIO(response.content)
# markitdown is sync; offload to thread so PDF parsing doesn't block
# the event-loop / other in-flight MCP requests.
result = await asyncio.to_thread(
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
)
return result.text_content.strip(), pdf_url
def _split_into_decisions(self, markdown_content: str) -> list[tuple[str, str]]:
"""
Split markdown content into individual decisions.
Returns:
List of (header, body) tuples for decisions with substantial content.
"""
parts = self.DECISION_HEADER_PATTERN.split(markdown_content)
decisions = []
for i in range(1, len(parts) - 1, 2):
header = parts[i].strip()
body = parts[i + 1].strip() if i + 1 < len(parts) else ""
if len(body) >= self.MIN_DECISION_BODY_LENGTH:
decisions.append((header, body))
return decisions
async def get_document_markdown(
self,
issue_number: str,
page_number: int = 1
) -> SigortaTahkimDocumentMarkdown:
"""
Retrieve a Sigorta Tahkim journal issue PDF and convert to Markdown.
Args:
issue_number: Journal issue number (e.g., '64')
page_number: Page number for paginated content (1-indexed)
Returns:
SigortaTahkimDocumentMarkdown with paginated content
"""
try:
markdown_content, pdf_url = await self._download_and_convert_pdf(issue_number)
total_length = len(markdown_content)
total_pages = max(1, math.ceil(total_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE))
start_idx = (page_number - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
end_idx = start_idx + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
page_content = markdown_content[start_idx:end_idx]
return SigortaTahkimDocumentMarkdown(
document_id=issue_number,
markdown_content=page_content,
page_number=page_number,
total_pages=total_pages,
source_url=pdf_url
)
except ValueError:
raise Exception(f"Invalid issue number: {issue_number}. Must be a number (e.g., '64').")
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching Sigorta Tahkim issue {issue_number}: {e}")
raise Exception(f"Failed to fetch journal issue {issue_number}: {str(e)}")
except Exception as e:
logger.error(f"Error processing Sigorta Tahkim issue {issue_number}: {e}")
raise Exception(f"Failed to process journal issue {issue_number}: {str(e)}")
async def search_within_issue(
self,
issue_number: str,
keyword: str,
max_results: int = 10
) -> SigortaTahkimSearchWithinResult:
"""
Search for a keyword within a specific journal issue's decisions.
Downloads the PDF, splits into individual decisions, and returns
matching decisions sorted by relevance (match count).
Args:
issue_number: Journal issue number (e.g., '64')
keyword: Search keyword or phrase in Turkish
max_results: Maximum matching decisions to return
Returns:
SigortaTahkimSearchWithinResult with matching decisions
"""
try:
markdown_content, _ = await self._download_and_convert_pdf(issue_number)
decisions = self._split_into_decisions(markdown_content)
logger.info(
f"Searching '{keyword}' within issue {issue_number}: "
f"{len(decisions)} decisions found"
)
keyword_lower = _turkish_lower(keyword)
matches = []
for header, body in decisions:
body_lower = _turkish_lower(body)
count = body_lower.count(keyword_lower)
if count == 0:
continue
# Extract excerpt around the first match
first_pos = body_lower.find(keyword_lower)
excerpt_start = max(0, first_pos - 200)
excerpt_end = min(len(body), first_pos + len(keyword) + 200)
excerpt = body[excerpt_start:excerpt_end].strip()
if excerpt_start > 0:
excerpt = "..." + excerpt
if excerpt_end < len(body):
excerpt = excerpt + "..."
matches.append(SigortaTahkimSearchWithinMatch(
decision_header=header,
relevance_score=count,
excerpt=excerpt,
body_length=len(body)
))
# Sort by relevance (highest match count first)
matches.sort(key=lambda m: m.relevance_score, reverse=True)
matches = matches[:max_results]
return SigortaTahkimSearchWithinResult(
issue_number=issue_number,
keyword=keyword,
total_decisions=len(decisions),
matching_decisions=len(matches),
matches=matches
)
except ValueError:
raise Exception(f"Invalid issue number: {issue_number}. Must be a number (e.g., '64').")
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error in search_within issue {issue_number}: {e}")
raise Exception(f"Failed to fetch journal issue {issue_number}: {str(e)}")
except Exception as e:
logger.error(f"Error in search_within issue {issue_number}: {e}")
raise Exception(f"Failed to search within issue {issue_number}: {str(e)}")
+59
View File
@@ -0,0 +1,59 @@
# sigorta_tahkim_mcp_module/models.py
from pydantic import BaseModel, Field
from typing import List
class SigortaTahkimSearchRequest(BaseModel):
"""Request model for searching Sigorta Tahkim Komisyonu decisions via Tavily API."""
keywords: str = Field(..., description="Search keywords in Turkish")
page: int = Field(1, ge=1, description="Page number (1-indexed)")
pageSize: int = Field(10, ge=1, le=50, description="Results per page (1-50)")
class SigortaTahkimDecisionSummary(BaseModel):
"""Summary of a Sigorta Tahkim decision from search results."""
title: str = Field(..., description="Decision title or journal issue info")
document_id: str = Field(..., description="Journal issue number (e.g., '64')")
content: str = Field(..., description="Decision summary/excerpt")
url: str = Field("", description="Source URL")
class SigortaTahkimSearchResult(BaseModel):
"""Response model for Sigorta Tahkim decision search results."""
decisions: List[SigortaTahkimDecisionSummary] = Field(
default_factory=list,
description="List of matching decisions"
)
total_results: int = Field(0, description="Total number of results")
page: int = Field(1, description="Current page number")
pageSize: int = Field(10, description="Results per page")
class SigortaTahkimDocumentMarkdown(BaseModel):
"""Sigorta Tahkim journal issue converted to Markdown format."""
document_id: str = Field(..., description="Journal issue number")
markdown_content: str = Field("", description="Document content in Markdown")
page_number: int = Field(1, description="Current page number")
total_pages: int = Field(1, description="Total number of pages")
source_url: str = Field("", description="PDF source URL")
class SigortaTahkimSearchWithinMatch(BaseModel):
"""A single matching decision from search within a journal issue."""
decision_header: str = Field(..., description="Decision header (date and K-number)")
relevance_score: int = Field(0, description="Number of keyword matches")
excerpt: str = Field("", description="Matching excerpt with context")
body_length: int = Field(0, description="Full decision body length in chars")
class SigortaTahkimSearchWithinResult(BaseModel):
"""Response model for search within a journal issue."""
issue_number: str = Field(..., description="Journal issue number searched")
keyword: str = Field("", description="Search keyword used")
total_decisions: int = Field(0, description="Total decisions in issue")
matching_decisions: int = Field(0, description="Number of matching decisions")
matches: List[SigortaTahkimSearchWithinMatch] = Field(
default_factory=list,
description="List of matching decisions sorted by relevance"
)
Generated
+2408
View File
File diff suppressed because it is too large Load Diff
+52 -43
View File
@@ -1,16 +1,15 @@
# uyusmazlik_mcp_module/client.py
import asyncio
import httpx
import aiohttp
from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Union, Tuple
import logging
import html
import re
import tempfile
import os
import io
from markitdown import MarkItDown
from urllib.parse import urljoin, urlencode # urlencode for aiohttp form data
from urllib.parse import urljoin
from .models import (
UyusmazlikSearchRequest,
@@ -31,13 +30,15 @@ BOLUM_ENUM_TO_ID_MAP = {
UyusmazlikBolumEnum.CEZA_BOLUMU: "f6b74320-f2d7-4209-ad6e-c6df180d4e7c",
UyusmazlikBolumEnum.GENEL_KURUL_KARARLARI: "e4ca658d-a75a-4719-b866-b2d2f1c3b1d9",
UyusmazlikBolumEnum.HUKUK_BOLUMU: "96b26fc4-ef8e-4a4f-a9cc-a3de89952aa1",
UyusmazlikBolumEnum.TUMU: "" # Represents "...Seçiniz..." or all
UyusmazlikBolumEnum.TUMU: "", # Represents "...Seçiniz..." or all - empty string for API
"ALL": "" # Also map the new "ALL" literal to empty string for backward compatibility
}
UYUSMAZLIK_TURU_ENUM_TO_ID_MAP = {
UyusmazlikTuruEnum.GOREV_UYUSMAZLIGI: "7b1e2cd3-8f09-418a-921c-bbe501e1740c",
UyusmazlikTuruEnum.HUKUM_UYUSMAZLIGI: "19b88402-172b-4c1d-8339-595c942a89f5",
UyusmazlikTuruEnum.TUMU: "" # Represents "...Seçiniz..." or all
UyusmazlikTuruEnum.TUMU: "", # Represents "...Seçiniz..." or all - empty string for API
"ALL": "" # Also map the new "ALL" literal to empty string for backward compatibility
}
KARAR_SONUCU_ENUM_TO_ID_MAP = {
@@ -55,17 +56,21 @@ class UyusmazlikApiClient:
# Individual documents are fetched by their full URLs obtained from search results.
def __init__(self, request_timeout: float = 30.0):
self.request_timeout = request_timeout # Store timeout for aiohttp and httpx
# Headers for aiohttp search. httpx for docs will create its own.
self.default_aiohttp_search_headers = {
"Accept": "*/*", # Mimicking browser headers provided by user
self.request_timeout = request_timeout
# Create shared httpx client for all requests
self.http_client = httpx.AsyncClient(
base_url=self.BASE_URL,
headers={
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
"X-Requested-With": "XMLHttpRequest",
"Origin": self.BASE_URL,
"Referer": self.BASE_URL + "/",
}
},
timeout=request_timeout,
verify=False
)
async def search_decisions(
@@ -106,32 +111,36 @@ class UyusmazlikApiClient:
add_to_form_data("Hepsi", params.hepsi)
add_to_form_data("Herhangibirisi", params.herhangi_birisi)
add_to_form_data("NotHepsi", params.not_hepsi)
# X-Requested-With is handled by default_aiohttp_search_headers
search_url = urljoin(self.BASE_URL, self.SEARCH_ENDPOINT)
# For aiohttp, data for application/x-www-form-urlencoded should be a dict or str.
# Using urlencode for list of tuples.
encoded_form_payload = urlencode(form_data_list, encoding='UTF-8')
# Convert form data to dict for httpx
form_data_dict = {}
for key, value in form_data_list:
if key in form_data_dict:
# Handle multiple values (like KararSonucuList)
if not isinstance(form_data_dict[key], list):
form_data_dict[key] = [form_data_dict[key]]
form_data_dict[key].append(value)
else:
form_data_dict[key] = value
logger.info(f"UyusmazlikApiClient (aiohttp): Performing search to {search_url} with form_data: {encoded_form_payload}")
html_content = ""
aiohttp_headers = self.default_aiohttp_search_headers.copy()
aiohttp_headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
logger.info(f"UyusmazlikApiClient (httpx): Performing search to {self.SEARCH_ENDPOINT} with form_data: {form_data_dict}")
try:
# Create a new session for each call for simplicity with aiohttp here
async with aiohttp.ClientSession(headers=aiohttp_headers) as session:
async with session.post(search_url, data=encoded_form_payload, timeout=self.request_timeout) as response:
response.raise_for_status() # Raises ClientResponseError for 400-599
html_content = await response.text(encoding='utf-8') # Ensure correct encoding
logger.debug("UyusmazlikApiClient (aiohttp): Received HTML response for search.")
# Use shared httpx client
response = await self.http_client.post(
self.SEARCH_ENDPOINT,
data=form_data_dict,
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
)
response.raise_for_status()
html_content = response.text
logger.debug("UyusmazlikApiClient (httpx): Received HTML response for search.")
except aiohttp.ClientError as e:
logger.error(f"UyusmazlikApiClient (aiohttp): HTTP client error during search: {e}")
except httpx.HTTPError as e:
logger.error(f"UyusmazlikApiClient (httpx): HTTP client error during search: {e}")
raise # Re-raise to be handled by the MCP tool
except Exception as e:
logger.error(f"UyusmazlikApiClient (aiohttp): Error processing search request: {e}")
logger.error(f"UyusmazlikApiClient (httpx): Error processing search request: {e}")
raise
# --- HTML Parsing (remains the same as previous version) ---
@@ -194,21 +203,18 @@ class UyusmazlikApiClient:
html_input_for_markdown = processed_html
markdown_text = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_file:
tmp_file.write(html_input_for_markdown)
temp_file_path = tmp_file.name
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_input_for_markdown.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
conversion_result = md_converter.convert(temp_file_path)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
conversion_result = md_converter.convert(html_stream)
markdown_text = conversion_result.text_content
logger.info("UyusmazlikApiClient: HTML to Markdown conversion successful.")
except Exception as e:
logger.error(f"UyusmazlikApiClient: Error during MarkItDown HTML to Markdown conversion: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
return markdown_text
async def get_decision_document_as_markdown(self, document_url: str) -> UyusmazlikDocumentMarkdown:
@@ -219,7 +225,6 @@ class UyusmazlikApiClient:
try:
# Using a new httpx.AsyncClient instance for this GET request for simplicity
async with httpx.AsyncClient(verify=False, timeout=self.request_timeout) as doc_fetch_client:
get_response = await doc_fetch_client.get(document_url, headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"})
get_response.raise_for_status()
html_content_from_api = get_response.text
@@ -228,7 +233,7 @@ class UyusmazlikApiClient:
logger.warning(f"UyusmazlikApiClient: Received empty or non-string HTML from URL {document_url}.")
return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=None)
markdown_content = self._convert_html_to_markdown_uyusmazlik(html_content_from_api)
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_uyusmazlik, html_content_from_api)
return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=markdown_content)
except httpx.RequestError as e:
logger.error(f"UyusmazlikApiClient (httpx for docs): HTTP error fetching Uyuşmazlık document from {document_url}: {e}")
@@ -238,5 +243,9 @@ class UyusmazlikApiClient:
raise
async def close_client_session(self):
"""Close the shared httpx client session."""
if hasattr(self, 'http_client') and self.http_client:
await self.http_client.aclose()
logger.info("UyusmazlikApiClient: HTTP client session closed.")
else:
logger.info("UyusmazlikApiClient: No persistent client session from __init__ to close.")
+22 -22
View File
@@ -7,14 +7,14 @@ from enum import Enum
# Enum definitions for user-friendly input based on the provided HTML form
class UyusmazlikBolumEnum(str, Enum):
"""User-friendly names for 'BolumId'."""
TUMU = "" # Represents "...Seçiniz..." or all
TUMU = "ALL" # Represents "...Seçiniz..." or all
CEZA_BOLUMU = "Ceza Bölümü"
GENEL_KURUL_KARARLARI = "Genel Kurul Kararları"
HUKUK_BOLUMU = "Hukuk Bölümü"
class UyusmazlikTuruEnum(str, Enum):
"""User-friendly names for 'UyusmazlikId'."""
TUMU = "" # Represents "...Seçiniz..." or all
TUMU = "ALL" # Represents "...Seçiniz..." or all
GOREV_UYUSMAZLIGI = "Görev Uyuşmazlığı"
HUKUM_UYUSMAZLIGI = "Hüküm Uyuşmazlığı"
@@ -28,41 +28,41 @@ class UyusmazlikKararSonucuEnum(str, Enum): # Based on checkbox text in the form
class UyusmazlikSearchRequest(BaseModel): # This is the model the MCP tool will accept
"""Model for Uyuşmazlık Mahkemesi search request using user-friendly terms."""
icerik: Optional[str] = Field("", description="Keyword or content for main text search (Icerik).")
icerik: Optional[str] = Field("", description="Search text")
bolum: Optional[UyusmazlikBolumEnum] = Field(
UyusmazlikBolumEnum.TUMU,
description="Select the department (Bölüm)."
description="Department"
)
uyusmazlik_turu: Optional[UyusmazlikTuruEnum] = Field(
UyusmazlikTuruEnum.TUMU,
description="Select the type of dispute (Uyuşmazlık)."
description="Dispute type"
)
# User provides a list of user-friendly names for Karar Sonucu
karar_sonuclari: Optional[List[UyusmazlikKararSonucuEnum]] = Field( # Changed to list of Enums
default_factory=list,
description="List of desired 'Karar Sonucu' types."
description="Decision types"
)
esas_yil: Optional[str] = Field("", description="Case year ('Esas Yılı').")
esas_sayisi: Optional[str] = Field("", description="Case number ('Esas Sayısı').")
karar_yil: Optional[str] = Field("", description="Decision year ('Karar Yılı').")
karar_sayisi: Optional[str] = Field("", description="Decision number ('Karar Sayısı').")
kanun_no: Optional[str] = Field("", description="Relevant Law Number ('KanunNo').")
esas_yil: Optional[str] = Field("", description="Case year")
esas_sayisi: Optional[str] = Field("", description="Case no")
karar_yil: Optional[str] = Field("", description="Decision year")
karar_sayisi: Optional[str] = Field("", description="Decision no")
kanun_no: Optional[str] = Field("", description="Law no")
karar_date_begin: Optional[str] = Field("", description="Decision start date (DD.MM.YYYY) ('KararDateBegin').")
karar_date_end: Optional[str] = Field("", description="Decision end date (DD.MM.YYYY) ('KararDateEnd').")
karar_date_begin: Optional[str] = Field("", description="Start date (DD.MM.YYYY)")
karar_date_end: Optional[str] = Field("", description="End date (DD.MM.YYYY)")
resmi_gazete_sayi: Optional[str] = Field("", description="Official Gazette number ('ResmiGazeteSayi').")
resmi_gazete_date: Optional[str] = Field("", description="Official Gazette date (DD.MM.YYYY) ('ResmiGazeteDate').")
resmi_gazete_sayi: Optional[str] = Field("", description="Gazette no")
resmi_gazete_date: Optional[str] = Field("", description="Gazette date (DD.MM.YYYY)")
# Detailed text search fields from the "icerikDetail" section of the form
tumce: Optional[str] = Field("", description="Exact phrase search ('Tumce').")
wild_card: Optional[str] = Field("", description="Search for phrase and its inflections ('WildCard').") # Changed from WildCard for Pythonic name
hepsi: Optional[str] = Field("", description="Search for texts containing all specified words ('Hepsi').")
herhangi_birisi: Optional[str] = Field("", description="Search for texts containing any of the specified words ('Herhangibirisi').")
not_hepsi: Optional[str] = Field("", description="Exclude texts containing these specified words ('NotHepsi').")
tumce: Optional[str] = Field("", description="Exact phrase")
wild_card: Optional[str] = Field("", description="Wildcard search")
hepsi: Optional[str] = Field("", description="All words")
herhangi_birisi: Optional[str] = Field("", description="Any word")
not_hepsi: Optional[str] = Field("", description="Exclude words")
class UyusmazlikApiDecisionEntry(BaseModel):
"""Model for an individual decision entry parsed from Uyuşmazlık API's HTML search response."""
@@ -71,9 +71,9 @@ class UyusmazlikApiDecisionEntry(BaseModel):
bolum: Optional[str] = Field(None)
uyusmazlik_konusu: Optional[str] = Field(None)
karar_sonucu: Optional[str] = Field(None)
popover_content: Optional[str] = Field(None, description="Summary/description from popover.")
popover_content: Optional[str] = Field(None, description="Summary")
document_url: HttpUrl # Full URL to the decision document HTML page
pdf_url: Optional[HttpUrl] = Field(None, description="Direct URL to PDF if available.")
pdf_url: Optional[HttpUrl] = Field(None, description="PDF URL")
class UyusmazlikSearchResponse(BaseModel): # This is what the MCP tool will return
"""Response model for Uyuşmazlık Mahkemesi search results for the MCP tool."""
+30 -22
View File
@@ -1,13 +1,13 @@
# yargitay_mcp_module/client.py
import asyncio
import httpx
from bs4 import BeautifulSoup # Still needed for pre-processing HTML before markitdown
from typing import Dict, Any, List, Optional
import logging
import html
import re
import tempfile
import os
import io
from markitdown import MarkItDown
from .models import (
@@ -66,6 +66,19 @@ class YargitayOfficialApiClient:
response.raise_for_status() # Raise an exception for HTTP 4xx or 5xx status codes
response_json_data = response.json()
logger.debug(f"YargitayOfficialApiClient: Raw API response: {response_json_data}")
# Handle None or empty data response from API
if response_json_data is None:
logger.warning("YargitayOfficialApiClient: API returned None response")
response_json_data = {"data": {"data": [], "recordsTotal": 0, "recordsFiltered": 0}}
elif not isinstance(response_json_data, dict):
logger.warning(f"YargitayOfficialApiClient: API returned unexpected response type: {type(response_json_data)}")
response_json_data = {"data": {"data": [], "recordsTotal": 0, "recordsFiltered": 0}}
elif response_json_data.get("data") is None:
logger.warning("YargitayOfficialApiClient: API response data field is None")
response_json_data["data"] = {"data": [], "recordsTotal": 0, "recordsFiltered": 0}
# Validate and parse the response using Pydantic models
api_response = YargitayApiSearchResponse(**response_json_data)
@@ -108,37 +121,32 @@ class YargitayOfficialApiClient:
html_to_convert = processed_html
markdown_output = None
temp_file_path = None
try:
md_converter = MarkItDown(enable_plugins=False) # Plugins disabled as per basic usage
# Convert HTML string to bytes and create BytesIO stream
html_bytes = html_to_convert.encode('utf-8')
html_stream = io.BytesIO(html_bytes)
# Write the HTML to a temporary file for MarkItDown to process
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as tmp_html_file:
tmp_html_file.write(html_to_convert)
temp_file_path = tmp_html_file.name
conversion_result = md_converter.convert(temp_file_path)
# Pass BytesIO stream to MarkItDown to avoid temp file creation
md_converter = MarkItDown()
conversion_result = md_converter.convert(html_stream)
markdown_output = conversion_result.text_content
logger.info("Successfully converted HTML to Markdown.")
except Exception as e:
logger.error(f"Error during MarkItDown HTML to Markdown conversion: {e}")
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path) # Clean up the temporary file
return markdown_output
async def get_decision_document_as_markdown(self, document_id: str) -> YargitayDocumentMarkdown:
async def get_decision_document_as_markdown(self, id: str) -> YargitayDocumentMarkdown:
"""
Retrieves a specific Yargitay decision by its ID and returns its content
as Markdown.
Based on user-provided /getDokuman response structure.
"""
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={document_id}"
document_api_url = f"{self.DOCUMENT_ENDPOINT}?id={id}"
source_url = f"{self.BASE_URL}{document_api_url}" # The original URL of the document
logger.info(f"YargitayOfficialApiClient: Fetching document for Markdown conversion (ID: {document_id})")
logger.info(f"YargitayOfficialApiClient: Fetching document for Markdown conversion (ID: {id})")
try:
response = await self.http_client.get(document_api_url)
@@ -149,24 +157,24 @@ class YargitayOfficialApiClient:
html_content_from_api = response_json.get("data")
if not isinstance(html_content_from_api, str):
logger.error(f"YargitayOfficialApiClient: 'data' field in API response is not a string or not found (ID: {document_id}).")
logger.error(f"YargitayOfficialApiClient: 'data' field in API response is not a string or not found (ID: {id}).")
raise ValueError("Expected HTML content not found in API response's 'data' field.")
markdown_content = self._convert_html_to_markdown(html_content_from_api)
markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, html_content_from_api)
return YargitayDocumentMarkdown(
document_id=document_id,
id=id,
markdown_content=markdown_content,
source_url=source_url
)
except httpx.RequestError as e:
logger.error(f"YargitayOfficialApiClient: HTTP error fetching document for Markdown (ID: {document_id}): {e}")
logger.error(f"YargitayOfficialApiClient: HTTP error fetching document for Markdown (ID: {id}): {e}")
raise
except ValueError as e: # For JSON parsing errors or missing 'data' field
logger.error(f"YargitayOfficialApiClient: Error processing document response for Markdown (ID: {document_id}): {e}")
logger.error(f"YargitayOfficialApiClient: Error processing document response for Markdown (ID: {id}): {e}")
raise
except Exception as e: # For other unexpected errors
logger.error(f"YargitayOfficialApiClient: General error fetching/processing document for Markdown (ID: {document_id}): {e}")
logger.error(f"YargitayOfficialApiClient: General error fetching/processing document for Markdown (ID: {id}): {e}")
raise
async def close_client_session(self):
+63 -36
View File
@@ -1,7 +1,32 @@
# yargitay_mcp_module/models.py
from pydantic import BaseModel, Field, HttpUrl
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
from typing import List, Optional, Dict, Any, Literal
# Yargıtay Chamber/Board Options
YargitayBirimEnum = Literal[
"ALL", # "ALL" for all chambers
# Hukuk (Civil) Chambers
"Hukuk Genel Kurulu",
"1. Hukuk Dairesi", "2. Hukuk Dairesi", "3. Hukuk Dairesi", "4. Hukuk Dairesi",
"5. Hukuk Dairesi", "6. Hukuk Dairesi", "7. Hukuk Dairesi", "8. Hukuk Dairesi",
"9. Hukuk Dairesi", "10. Hukuk Dairesi", "11. Hukuk Dairesi", "12. Hukuk Dairesi",
"13. Hukuk Dairesi", "14. Hukuk Dairesi", "15. Hukuk Dairesi", "16. Hukuk Dairesi",
"17. Hukuk Dairesi", "18. Hukuk Dairesi", "19. Hukuk Dairesi", "20. Hukuk Dairesi",
"21. Hukuk Dairesi", "22. Hukuk Dairesi", "23. Hukuk Dairesi",
"Hukuk Daireleri Başkanlar Kurulu",
# Ceza (Criminal) Chambers
"Ceza Genel Kurulu",
"1. Ceza Dairesi", "2. Ceza Dairesi", "3. Ceza Dairesi", "4. Ceza Dairesi",
"5. Ceza Dairesi", "6. Ceza Dairesi", "7. Ceza Dairesi", "8. Ceza Dairesi",
"9. Ceza Dairesi", "10. Ceza Dairesi", "11. Ceza Dairesi", "12. Ceza Dairesi",
"13. Ceza Dairesi", "14. Ceza Dairesi", "15. Ceza Dairesi", "16. Ceza Dairesi",
"17. Ceza Dairesi", "18. Ceza Dairesi", "19. Ceza Dairesi", "20. Ceza Dairesi",
"21. Ceza Dairesi", "22. Ceza Dairesi", "23. Ceza Dairesi",
"Ceza Daireleri Başkanlar Kurulu",
# General Assembly
"Büyük Genel Kurulu"
]
class YargitayDetailedSearchRequest(BaseModel):
"""
@@ -9,68 +34,70 @@ class YargitayDetailedSearchRequest(BaseModel):
to Yargitay's detailed search endpoint (e.g., /aramadetaylist).
Based on the payload provided by the user.
"""
arananKelime: Optional[str] = Field("", description="Keyword to search for.")
# Department/Board selection. Based on user provided payload.
# birimYrg* fields seem to be the ones used for filtering.
birimYrgKurulDaire: Optional[str] = Field("", description="Yargitay Board Unit (e.g., 'Hukuk Genel Kurulu').")
birimYrgHukukDaire: Optional[str] = Field("", description="Yargitay Civil Chamber (e.g., '1. Hukuk Dairesi').")
birimYrgCezaDaire: Optional[str] = Field("", description="Yargitay Criminal Chamber.")
arananKelime: Optional[str] = Field("", description="Turkish keywords (supports +word -word \"phrase\" operators)")
# Department/Board selection - Complete Court of Cassation chamber hierarchy
birimYrgKurulDaire: Optional[str] = Field("ALL", description="Chamber (ALL or specific chamber name)")
esasYil: Optional[str] = Field("", description="Case year for 'Esas No'.")
esasIlkSiraNo: Optional[str] = Field("", description="Starting sequence number for 'Esas No'.")
esasSonSiraNo: Optional[str] = Field("", description="Ending sequence number for 'Esas No'.")
esasYil: Optional[str] = Field("", description="Case year (YYYY)")
esasIlkSiraNo: Optional[str] = Field("", description="Start case no")
esasSonSiraNo: Optional[str] = Field("", description="End case no")
kararYil: Optional[str] = Field("", description="Decision year for 'Karar No'.")
kararIlkSiraNo: Optional[str] = Field("", description="Starting sequence number for 'Karar No'.")
kararSonSiraNo: Optional[str] = Field("", description="Ending sequence number for 'Karar No'.")
kararYil: Optional[str] = Field("", description="Decision year (YYYY)")
kararIlkSiraNo: Optional[str] = Field("", description="Start decision no")
kararSonSiraNo: Optional[str] = Field("", description="End decision no")
baslangicTarihi: Optional[str] = Field("", description="Start date for decision search (DD.MM.YYYY).")
bitisTarihi: Optional[str] = Field("", description="End date for decision search (DD.MM.YYYY).")
baslangicTarihi: Optional[str] = Field("", description="Start date (DD.MM.YYYY)")
bitisTarihi: Optional[str] = Field("", description="End date (DD.MM.YYYY)")
siralama: Optional[str] = Field("3", description="Sorting criteria (1: Esas No, 2: Karar No, 3: Karar Tarihi).") # Default to 'Karar Tarihine Göre'
siralamaDirection: Optional[str] = Field("desc", description="Sorting direction ('asc' or 'desc').") # Default to 'Büyükten Küçüğe'
pageSize: int = Field(10, ge=1, le=100, description="Number of results per page.")
pageNumber: int = Field(1, ge=1, description="Page number to retrieve.")
pageSize: int = Field(10, ge=1, le=10, description="Results per page (1-100)")
pageNumber: int = Field(1, ge=1, description="Page number (1-indexed)")
class YargitayApiDecisionEntry(BaseModel):
"""Model for an individual decision entry from the Yargitay API search response."""
id: str # Unique system ID of the decision
daire: Optional[str] = Field(None, description="The chamber that made the decision.")
esasNo: Optional[str] = Field(None, alias="esasNo", description="Case registry number ('Esas No').")
kararNo: Optional[str] = Field(None, alias="kararNo", description="Decision number ('Karar No').")
kararTarihi: Optional[str] = Field(None, alias="kararTarihi", description="Date of the decision.")
arananKelime: Optional[str] = Field(None, alias="arananKelime", description="Matched keyword in the search result item.")
daire: Optional[str] = Field(None, description="Chamber")
esasNo: Optional[str] = Field(None, alias="esasNo", description="Case no")
kararNo: Optional[str] = Field(None, alias="kararNo", description="Decision no")
kararTarihi: Optional[str] = Field(None, alias="kararTarihi", description="Date")
# 'index' and 'siraNo' from API response are not critical for MCP tool, so omitted for brevity
# This field will be populated by the client after fetching the search list
document_url: Optional[HttpUrl] = Field(None, description="Direct URL to the decision document.")
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
class Config:
populate_by_name = True # To allow populating by alias from API response
model_config = ConfigDict(populate_by_name=True) # To allow populating by alias from API response
class YargitayApiResponseInnerData(BaseModel):
"""Model for the inner 'data' object in the Yargitay API search response."""
data: List[YargitayApiDecisionEntry]
data: List[YargitayApiDecisionEntry] = Field(default_factory=list)
# draw: Optional[int] = None # Typically used by DataTables, not essential for MCP
recordsTotal: int # Total number of records matching the query
recordsFiltered: int # Total number of records after filtering (usually same as recordsTotal)
recordsTotal: int = Field(default=0) # Total number of records matching the query
recordsFiltered: int = Field(default=0) # Total number of records after filtering (usually same as recordsTotal)
class YargitayApiSearchResponse(BaseModel):
"""Model for the complete search response from the Yargitay API."""
data: YargitayApiResponseInnerData
data: Optional[YargitayApiResponseInnerData] = Field(default_factory=lambda: YargitayApiResponseInnerData())
# metadata: Optional[Dict[str, Any]] = None # Optional metadata from API
class YargitayDocumentMarkdown(BaseModel):
"""Model for a Yargitay decision document, containing only Markdown content."""
document_id: str = Field(..., description="The unique ID of the document.")
markdown_content: Optional[str] = Field(None, description="The decision content converted to Markdown.")
source_url: HttpUrl = Field(..., description="The source URL of the original document.")
id: str = Field(..., description="Document ID")
markdown_content: Optional[str] = Field(None, description="Content")
source_url: HttpUrl = Field(..., description="Source URL")
class CleanYargitayDecisionEntry(BaseModel):
"""Clean decision entry without arananKelime field to reduce token usage."""
id: str
daire: Optional[str] = Field(None, description="Chamber")
esasNo: Optional[str] = Field(None, description="Case no")
kararNo: Optional[str] = Field(None, description="Decision no")
kararTarihi: Optional[str] = Field(None, description="Date")
document_url: Optional[HttpUrl] = Field(None, description="Document URL")
class CompactYargitaySearchResult(BaseModel):
"""A more compact search result model for the MCP tool to return."""
decisions: List[YargitayApiDecisionEntry]
decisions: List[CleanYargitayDecisionEntry]
total_records: int
requested_page: int
page_size: int