This is page 6 of 11. Use http://codebase.md/saidsurucu/yargi-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── __main__.py
├── .dockerignore
├── .env.example
├── .gitattributes
├── .github
│ └── workflows
│ └── publish.yml
├── .gitignore
├── .serena
│ ├── .gitignore
│ └── project.yml
├── 5ire-settings.png
├── analyze_kik_hash_generation.py
├── anayasa_mcp_module
│ ├── __init__.py
│ ├── bireysel_client.py
│ ├── client.py
│ ├── models.py
│ └── unified_client.py
├── asgi_app.py
├── bddk_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
├── bedesten_mcp_module
│ ├── __init__.py
│ ├── client.py
│ ├── enums.py
│ └── models.py
├── check_response_format.py
├── CLAUDE.md
├── danistay_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
├── docker-compose.yml
├── Dockerfile
├── docs
│ └── DEPLOYMENT.md
├── emsal_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
├── example_fastapi_app.py
├── fly-no-auth.toml
├── fly.toml
├── kik_mcp_module
│ ├── __init__.py
│ ├── client_v2.py
│ ├── client.py
│ ├── models_v2.py
│ └── models.py
├── kvkk_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
├── LICENSE
├── mcp_auth
│ ├── __init__.py
│ ├── clerk_config.py
│ ├── middleware.py
│ ├── oauth.py
│ ├── policy.py
│ └── storage.py
├── mcp_auth_factory.py
├── mcp_auth_http_adapter.py
├── mcp_auth_http_simple.py
├── mcp_server_main.py
├── nginx.conf
├── ornek.png
├── Procfile
├── pyproject.toml
├── railway.json
├── README.md
├── redis_session_store.py
├── rekabet_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
├── requirements.txt
├── run_asgi.py
├── saidsurucu-yargi-mcp-f5fa007
│ ├── __main__.py
│ ├── .dockerignore
│ ├── .env.example
│ ├── .gitattributes
│ ├── .github
│ │ └── workflows
│ │ └── publish.yml
│ ├── .gitignore
│ ├── 5ire-settings.png
│ ├── anayasa_mcp_module
│ │ ├── __init__.py
│ │ ├── bireysel_client.py
│ │ ├── client.py
│ │ ├── models.py
│ │ └── unified_client.py
│ ├── asgi_app.py
│ ├── bddk_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ ├── bedesten_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ ├── enums.py
│ │ └── models.py
│ ├── check_response_format.py
│ ├── danistay_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── docs
│ │ └── DEPLOYMENT.md
│ ├── emsal_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ ├── example_fastapi_app.py
│ ├── kik_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ ├── kvkk_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ ├── LICENSE
│ ├── mcp_auth
│ │ ├── __init__.py
│ │ ├── clerk_config.py
│ │ ├── middleware.py
│ │ ├── oauth.py
│ │ ├── policy.py
│ │ └── storage.py
│ ├── mcp_auth_factory.py
│ ├── mcp_auth_http_adapter.py
│ ├── mcp_auth_http_simple.py
│ ├── mcp_server_main.py
│ ├── nginx.conf
│ ├── ornek.png
│ ├── Procfile
│ ├── pyproject.toml
│ ├── railway.json
│ ├── README.md
│ ├── redis_session_store.py
│ ├── rekabet_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ ├── run_asgi.py
│ ├── sayistay_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ ├── enums.py
│ │ ├── models.py
│ │ └── unified_client.py
│ ├── starlette_app.py
│ ├── stripe_webhook.py
│ ├── uyusmazlik_mcp_module
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models.py
│ └── yargitay_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
├── sayistay_mcp_module
│ ├── __init__.py
│ ├── client.py
│ ├── enums.py
│ ├── models.py
│ └── unified_client.py
├── starlette_app.py
├── stripe_webhook.py
├── uv.lock
├── uyusmazlik_mcp_module
│ ├── __init__.py
│ ├── client.py
│ └── models.py
└── yargitay_mcp_module
├── __init__.py
├── client.py
└── models.py
```
# Files
--------------------------------------------------------------------------------
/redis_session_store.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Redis Session Store for OAuth Authorization Codes and User Sessions
3 |
4 | This module provides Redis-based storage for OAuth authorization codes and user sessions,
5 | enabling multi-machine deployment support by replacing in-memory storage.
6 |
7 | Uses Upstash Redis via REST API for serverless-friendly operation.
8 | """
9 |
10 | import os
11 | import json
12 | import time
13 | import logging
14 | from typing import Optional, Dict, Any, Union
15 | from datetime import datetime, timedelta
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 | try:
20 | from upstash_redis import Redis
21 | UPSTASH_AVAILABLE = True
22 | except ImportError:
23 | UPSTASH_AVAILABLE = False
24 | Redis = None
25 |
26 | # Use standard Python exceptions for Redis connection errors
27 | import socket
28 | from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout as RequestsTimeout
29 |
30 | class RedisSessionStore:
31 | """
32 | Redis-based session store for OAuth flows and user sessions.
33 |
34 | Uses Upstash Redis REST API for connection-free operation suitable for
35 | multi-instance deployments on platforms like Fly.io.
36 | """
37 |
38 | def __init__(self):
39 | """Initialize Redis connection using environment variables."""
40 | if not UPSTASH_AVAILABLE:
41 | raise ImportError("upstash-redis package is required. Install with: pip install upstash-redis")
42 |
43 | # Initialize Upstash Redis client from environment with optimized connection settings
44 | try:
45 | # Get Upstash Redis configuration
46 | redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
47 | redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
48 |
49 | if not redis_url or not redis_token:
50 | raise ValueError("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be set")
51 |
52 | logger.info(f"Connecting to Upstash Redis at {redis_url[:30]}...")
53 |
54 | # Initialize with explicit configuration for better SSL handling
55 | self.redis = Redis(
56 | url=redis_url,
57 | token=redis_token
58 | )
59 |
60 | logger.info("Upstash Redis client created")
61 |
62 | # Skip connection test during initialization to prevent server hang
63 | # Connection will be tested during first actual operation
64 | logger.info("Redis client initialized - connection will be tested on first use")
65 |
66 | except Exception as e:
67 | logger.error(f"Failed to initialize Upstash Redis: {e}")
68 | raise
69 |
70 | # TTL values (in seconds)
71 | self.oauth_code_ttl = int(os.getenv("OAUTH_CODE_TTL", "600")) # 10 minutes
72 | self.session_ttl = int(os.getenv("SESSION_TTL", "3600")) # 1 hour
73 |
74 | def _serialize_data(self, data: Dict[str, Any]) -> Dict[str, str]:
75 | """Convert data to Redis-compatible string format."""
76 | serialized = {}
77 | for key, value in data.items():
78 | if isinstance(value, (dict, list)):
79 | serialized[key] = json.dumps(value)
80 | elif isinstance(value, (int, float)):
81 | serialized[key] = str(value)
82 | elif isinstance(value, bool):
83 | serialized[key] = "true" if value else "false"
84 | else:
85 | serialized[key] = str(value)
86 | return serialized
87 |
88 | def _deserialize_data(self, data: Dict[str, str]) -> Dict[str, Any]:
89 | """Convert Redis string data back to original types."""
90 | if not data:
91 | return {}
92 |
93 | deserialized = {}
94 | for key, value in data.items():
95 | if not isinstance(value, str):
96 | deserialized[key] = value
97 | continue
98 |
99 | # Try to deserialize JSON
100 | if value.startswith(('[', '{')):
101 | try:
102 | deserialized[key] = json.loads(value)
103 | continue
104 | except json.JSONDecodeError:
105 | pass
106 |
107 | # Try to convert numbers
108 | if value.isdigit():
109 | deserialized[key] = int(value)
110 | continue
111 |
112 | if value.replace('.', '').isdigit():
113 | try:
114 | deserialized[key] = float(value)
115 | continue
116 | except ValueError:
117 | pass
118 |
119 | # Handle booleans
120 | if value in ("true", "false"):
121 | deserialized[key] = value == "true"
122 | continue
123 |
124 | # Keep as string
125 | deserialized[key] = value
126 |
127 | return deserialized
128 |
129 | # OAuth Authorization Code Methods
130 |
131 | def set_oauth_code(self, code: str, data: Dict[str, Any]) -> bool:
132 | """
133 | Store OAuth authorization code with automatic expiration.
134 |
135 | Args:
136 | code: Authorization code string
137 | data: Code data including user_id, client_id, etc.
138 |
139 | Returns:
140 | True if stored successfully, False otherwise
141 | """
142 | try:
143 | key = f"oauth:code:{code}"
144 |
145 | # Add timestamp for debugging
146 | data_with_timestamp = data.copy()
147 | data_with_timestamp.update({
148 | "created_at": time.time(),
149 | "expires_at": time.time() + self.oauth_code_ttl
150 | })
151 |
152 | # Serialize and store - Upstash Redis doesn't support mapping parameter
153 | serialized_data = self._serialize_data(data_with_timestamp)
154 |
155 | # Use individual hset calls for each field with retry logic
156 | max_retries = 3
157 | for attempt in range(max_retries):
158 | try:
159 | # Clear any existing data first
160 | self.redis.delete(key)
161 |
162 | # Set all fields in a pipeline-like manner
163 | for field, value in serialized_data.items():
164 | self.redis.hset(key, field, value)
165 |
166 | # Set expiration
167 | self.redis.expire(key, self.oauth_code_ttl)
168 |
169 | logger.info(f"Stored OAuth code {code[:10]}... with TTL {self.oauth_code_ttl}s (attempt {attempt + 1})")
170 | return True
171 |
172 | except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
173 | logger.warning(f"Redis connection error on attempt {attempt + 1}: {e}")
174 | if attempt == max_retries - 1:
175 | raise # Re-raise on final attempt
176 | time.sleep(0.5 * (attempt + 1)) # Exponential backoff
177 |
178 | except Exception as e:
179 | logger.error(f"Failed to store OAuth code {code[:10]}... after {max_retries} attempts: {e}")
180 | return False
181 |
182 | def get_oauth_code(self, code: str, delete_after_use: bool = True) -> Optional[Dict[str, Any]]:
183 | """
184 | Retrieve OAuth authorization code data.
185 |
186 | Args:
187 | code: Authorization code string
188 | delete_after_use: If True, delete the code after retrieval (one-time use)
189 |
190 | Returns:
191 | Code data dictionary or None if not found/expired
192 | """
193 | max_retries = 3
194 | for attempt in range(max_retries):
195 | try:
196 | key = f"oauth:code:{code}"
197 |
198 | # Get all hash fields with retry
199 | data = self.redis.hgetall(key)
200 |
201 | if not data:
202 | logger.warning(f"OAuth code {code[:10]}... not found or expired (attempt {attempt + 1})")
203 | return None
204 |
205 | # Deserialize data
206 | deserialized_data = self._deserialize_data(data)
207 |
208 | # Check manual expiration (in case Redis TTL failed)
209 | expires_at = deserialized_data.get("expires_at", 0)
210 | if expires_at and time.time() > expires_at:
211 | logger.warning(f"OAuth code {code[:10]}... manually expired")
212 | try:
213 | self.redis.delete(key)
214 | except Exception as del_error:
215 | logger.warning(f"Failed to delete expired code: {del_error}")
216 | return None
217 |
218 | # Delete after use for security (one-time use)
219 | if delete_after_use:
220 | try:
221 | self.redis.delete(key)
222 | logger.info(f"Retrieved and deleted OAuth code {code[:10]}... (attempt {attempt + 1})")
223 | except Exception as del_error:
224 | logger.warning(f"Failed to delete code after use: {del_error}")
225 | # Continue anyway since we got the data
226 | else:
227 | logger.info(f"Retrieved OAuth code {code[:10]}... (not deleted, attempt {attempt + 1})")
228 |
229 | return deserialized_data
230 |
231 | except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
232 | logger.warning(f"Redis connection error on retrieval attempt {attempt + 1}: {e}")
233 | if attempt == max_retries - 1:
234 | logger.error(f"Failed to retrieve OAuth code {code[:10]}... after {max_retries} attempts: {e}")
235 | return None
236 | time.sleep(0.5 * (attempt + 1)) # Exponential backoff
237 |
238 | except Exception as e:
239 | logger.error(f"Failed to retrieve OAuth code {code[:10]}... on attempt {attempt + 1}: {e}")
240 | if attempt == max_retries - 1:
241 | return None
242 | time.sleep(0.5 * (attempt + 1))
243 |
244 | return None
245 |
246 | # User Session Methods
247 |
248 | def set_session(self, session_id: str, user_data: Dict[str, Any]) -> bool:
249 | """
250 | Store user session data with sliding expiration.
251 |
252 | Args:
253 | session_id: Unique session identifier
254 | user_data: User session data (user_id, email, scopes, etc.)
255 |
256 | Returns:
257 | True if stored successfully, False otherwise
258 | """
259 | try:
260 | key = f"session:{session_id}"
261 |
262 | # Add session metadata
263 | session_data = user_data.copy()
264 | session_data.update({
265 | "session_id": session_id,
266 | "created_at": time.time(),
267 | "last_accessed": time.time()
268 | })
269 |
270 | # Serialize and store - Upstash Redis doesn't support mapping parameter
271 | serialized_data = self._serialize_data(session_data)
272 |
273 | # Use individual hset calls for each field (Upstash compatibility)
274 | for field, value in serialized_data.items():
275 | self.redis.hset(key, field, value)
276 | self.redis.expire(key, self.session_ttl)
277 |
278 | logger.info(f"Stored session {session_id[:10]}... with TTL {self.session_ttl}s")
279 | return True
280 |
281 | except Exception as e:
282 | logger.error(f"Failed to store session {session_id[:10]}...: {e}")
283 | return False
284 |
285 | def get_session(self, session_id: str, refresh_ttl: bool = True) -> Optional[Dict[str, Any]]:
286 | """
287 | Retrieve user session data.
288 |
289 | Args:
290 | session_id: Session identifier
291 | refresh_ttl: If True, extend session TTL on access
292 |
293 | Returns:
294 | Session data dictionary or None if not found/expired
295 | """
296 | try:
297 | key = f"session:{session_id}"
298 |
299 | # Get session data
300 | data = self.redis.hgetall(key)
301 |
302 | if not data:
303 | logger.warning(f"Session {session_id[:10]}... not found or expired")
304 | return None
305 |
306 | # Deserialize data
307 | session_data = self._deserialize_data(data)
308 |
309 | # Update last accessed time and refresh TTL
310 | if refresh_ttl:
311 | session_data["last_accessed"] = time.time()
312 | self.redis.hset(key, "last_accessed", str(time.time()))
313 | self.redis.expire(key, self.session_ttl)
314 | logger.debug(f"Refreshed session {session_id[:10]}... TTL")
315 |
316 | return session_data
317 |
318 | except Exception as e:
319 | logger.error(f"Failed to retrieve session {session_id[:10]}...: {e}")
320 | return None
321 |
322 | def delete_session(self, session_id: str) -> bool:
323 | """
324 | Delete user session (logout).
325 |
326 | Args:
327 | session_id: Session identifier
328 |
329 | Returns:
330 | True if deleted successfully, False otherwise
331 | """
332 | try:
333 | key = f"session:{session_id}"
334 | result = self.redis.delete(key)
335 |
336 | if result:
337 | logger.info(f"Deleted session {session_id[:10]}...")
338 | return True
339 | else:
340 | logger.warning(f"Session {session_id[:10]}... not found for deletion")
341 | return False
342 |
343 | except Exception as e:
344 | logger.error(f"Failed to delete session {session_id[:10]}...: {e}")
345 | return False
346 |
347 | # Health Check Methods
348 |
349 | def health_check(self) -> Dict[str, Any]:
350 | """
351 | Perform Redis health check.
352 |
353 | Returns:
354 | Health status dictionary
355 | """
356 | try:
357 | # Test basic operations
358 | test_key = f"health:check:{int(time.time())}"
359 | test_value = {"timestamp": time.time(), "test": True}
360 |
361 | # Test set - Use individual hset calls for Upstash compatibility
362 | serialized_test = self._serialize_data(test_value)
363 | for field, value in serialized_test.items():
364 | self.redis.hset(test_key, field, value)
365 |
366 | # Test get
367 | retrieved = self.redis.hgetall(test_key)
368 |
369 | # Test delete
370 | self.redis.delete(test_key)
371 |
372 | return {
373 | "status": "healthy",
374 | "redis_connected": True,
375 | "operations_working": bool(retrieved),
376 | "timestamp": datetime.utcnow().isoformat()
377 | }
378 |
379 | except Exception as e:
380 | logger.error(f"Redis health check failed: {e}")
381 | return {
382 | "status": "unhealthy",
383 | "redis_connected": False,
384 | "error": str(e),
385 | "timestamp": datetime.utcnow().isoformat()
386 | }
387 |
388 | def get_stats(self) -> Dict[str, Any]:
389 | """
390 | Get Redis usage statistics.
391 |
392 | Returns:
393 | Statistics dictionary
394 | """
395 | try:
396 | # Get basic info (not all Upstash plans support INFO command)
397 | stats = {
398 | "oauth_codes_pattern": "oauth:code:*",
399 | "sessions_pattern": "session:*",
400 | "timestamp": datetime.utcnow().isoformat()
401 | }
402 |
403 | try:
404 | # Try to get counts (may fail on some Upstash plans)
405 | oauth_keys = self.redis.keys("oauth:code:*")
406 | session_keys = self.redis.keys("session:*")
407 |
408 | stats.update({
409 | "active_oauth_codes": len(oauth_keys) if oauth_keys else 0,
410 | "active_sessions": len(session_keys) if session_keys else 0
411 | })
412 | except Exception as e:
413 | logger.warning(f"Could not get detailed stats: {e}")
414 | stats["warning"] = "Detailed stats not available on this Redis plan"
415 |
416 | return stats
417 |
418 | except Exception as e:
419 | logger.error(f"Failed to get Redis stats: {e}")
420 | return {"error": str(e), "timestamp": datetime.utcnow().isoformat()}
421 |
422 | # Global instance for easy importing
423 | redis_store = None
424 |
425 | def get_redis_store() -> Optional[RedisSessionStore]:
426 | """
427 | Get global Redis store instance (singleton pattern).
428 |
429 | Returns:
430 | RedisSessionStore instance or None if initialization fails
431 | """
432 | global redis_store
433 |
434 | if redis_store is None:
435 | try:
436 | logger.info("Initializing Redis store...")
437 | redis_store = RedisSessionStore()
438 | logger.info("Redis store initialized successfully")
439 | except Exception as e:
440 | logger.error(f"Failed to initialize Redis store: {e}")
441 | redis_store = None
442 |
443 | return redis_store
444 |
445 | def init_redis_store() -> RedisSessionStore:
446 | """
447 | Initialize Redis store and perform health check.
448 |
449 | Returns:
450 | RedisSessionStore instance
451 |
452 | Raises:
453 | Exception if Redis is not available or unhealthy
454 | """
455 | store = get_redis_store()
456 |
457 | # Perform health check
458 | health = store.health_check()
459 |
460 | if health["status"] != "healthy":
461 | raise Exception(f"Redis health check failed: {health}")
462 |
463 | logger.info("Redis session store initialized and healthy")
464 | return store
```
--------------------------------------------------------------------------------
/saidsurucu-yargi-mcp-f5fa007/redis_session_store.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Redis Session Store for OAuth Authorization Codes and User Sessions
3 |
4 | This module provides Redis-based storage for OAuth authorization codes and user sessions,
5 | enabling multi-machine deployment support by replacing in-memory storage.
6 |
7 | Uses Upstash Redis via REST API for serverless-friendly operation.
8 | """
9 |
10 | import os
11 | import json
12 | import time
13 | import logging
14 | from typing import Optional, Dict, Any, Union
15 | from datetime import datetime, timedelta
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 | try:
20 | from upstash_redis import Redis
21 | UPSTASH_AVAILABLE = True
22 | except ImportError:
23 | UPSTASH_AVAILABLE = False
24 | Redis = None
25 |
26 | # Use standard Python exceptions for Redis connection errors
27 | import socket
28 | from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout as RequestsTimeout
29 |
30 | class RedisSessionStore:
31 | """
32 | Redis-based session store for OAuth flows and user sessions.
33 |
34 | Uses Upstash Redis REST API for connection-free operation suitable for
35 | multi-instance deployments on platforms like Fly.io.
36 | """
37 |
38 | def __init__(self):
39 | """Initialize Redis connection using environment variables."""
40 | if not UPSTASH_AVAILABLE:
41 | raise ImportError("upstash-redis package is required. Install with: pip install upstash-redis")
42 |
43 | # Initialize Upstash Redis client from environment with optimized connection settings
44 | try:
45 | # Get Upstash Redis configuration
46 | redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
47 | redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
48 |
49 | if not redis_url or not redis_token:
50 | raise ValueError("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be set")
51 |
52 | logger.info(f"Connecting to Upstash Redis at {redis_url[:30]}...")
53 |
54 | # Initialize with explicit configuration for better SSL handling
55 | self.redis = Redis(
56 | url=redis_url,
57 | token=redis_token
58 | )
59 |
60 | logger.info("Upstash Redis client created")
61 |
62 | # Skip connection test during initialization to prevent server hang
63 | # Connection will be tested during first actual operation
64 | logger.info("Redis client initialized - connection will be tested on first use")
65 |
66 | except Exception as e:
67 | logger.error(f"Failed to initialize Upstash Redis: {e}")
68 | raise
69 |
70 | # TTL values (in seconds)
71 | self.oauth_code_ttl = int(os.getenv("OAUTH_CODE_TTL", "600")) # 10 minutes
72 | self.session_ttl = int(os.getenv("SESSION_TTL", "3600")) # 1 hour
73 |
74 | def _serialize_data(self, data: Dict[str, Any]) -> Dict[str, str]:
75 | """Convert data to Redis-compatible string format."""
76 | serialized = {}
77 | for key, value in data.items():
78 | if isinstance(value, (dict, list)):
79 | serialized[key] = json.dumps(value)
80 | elif isinstance(value, (int, float)):
81 | serialized[key] = str(value)
82 | elif isinstance(value, bool):
83 | serialized[key] = "true" if value else "false"
84 | else:
85 | serialized[key] = str(value)
86 | return serialized
87 |
88 | def _deserialize_data(self, data: Dict[str, str]) -> Dict[str, Any]:
89 | """Convert Redis string data back to original types."""
90 | if not data:
91 | return {}
92 |
93 | deserialized = {}
94 | for key, value in data.items():
95 | if not isinstance(value, str):
96 | deserialized[key] = value
97 | continue
98 |
99 | # Try to deserialize JSON
100 | if value.startswith(('[', '{')):
101 | try:
102 | deserialized[key] = json.loads(value)
103 | continue
104 | except json.JSONDecodeError:
105 | pass
106 |
107 | # Try to convert numbers
108 | if value.isdigit():
109 | deserialized[key] = int(value)
110 | continue
111 |
112 | if value.replace('.', '').isdigit():
113 | try:
114 | deserialized[key] = float(value)
115 | continue
116 | except ValueError:
117 | pass
118 |
119 | # Handle booleans
120 | if value in ("true", "false"):
121 | deserialized[key] = value == "true"
122 | continue
123 |
124 | # Keep as string
125 | deserialized[key] = value
126 |
127 | return deserialized
128 |
129 | # OAuth Authorization Code Methods
130 |
131 | def set_oauth_code(self, code: str, data: Dict[str, Any]) -> bool:
132 | """
133 | Store OAuth authorization code with automatic expiration.
134 |
135 | Args:
136 | code: Authorization code string
137 | data: Code data including user_id, client_id, etc.
138 |
139 | Returns:
140 | True if stored successfully, False otherwise
141 | """
142 | try:
143 | key = f"oauth:code:{code}"
144 |
145 | # Add timestamp for debugging
146 | data_with_timestamp = data.copy()
147 | data_with_timestamp.update({
148 | "created_at": time.time(),
149 | "expires_at": time.time() + self.oauth_code_ttl
150 | })
151 |
152 | # Serialize and store - Upstash Redis doesn't support mapping parameter
153 | serialized_data = self._serialize_data(data_with_timestamp)
154 |
155 | # Use individual hset calls for each field with retry logic
156 | max_retries = 3
157 | for attempt in range(max_retries):
158 | try:
159 | # Clear any existing data first
160 | self.redis.delete(key)
161 |
162 | # Set all fields in a pipeline-like manner
163 | for field, value in serialized_data.items():
164 | self.redis.hset(key, field, value)
165 |
166 | # Set expiration
167 | self.redis.expire(key, self.oauth_code_ttl)
168 |
169 | logger.info(f"Stored OAuth code {code[:10]}... with TTL {self.oauth_code_ttl}s (attempt {attempt + 1})")
170 | return True
171 |
172 | except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
173 | logger.warning(f"Redis connection error on attempt {attempt + 1}: {e}")
174 | if attempt == max_retries - 1:
175 | raise # Re-raise on final attempt
176 | time.sleep(0.5 * (attempt + 1)) # Exponential backoff
177 |
178 | except Exception as e:
179 | logger.error(f"Failed to store OAuth code {code[:10]}... after {max_retries} attempts: {e}")
180 | return False
181 |
182 | def get_oauth_code(self, code: str, delete_after_use: bool = True) -> Optional[Dict[str, Any]]:
183 | """
184 | Retrieve OAuth authorization code data.
185 |
186 | Args:
187 | code: Authorization code string
188 | delete_after_use: If True, delete the code after retrieval (one-time use)
189 |
190 | Returns:
191 | Code data dictionary or None if not found/expired
192 | """
193 | max_retries = 3
194 | for attempt in range(max_retries):
195 | try:
196 | key = f"oauth:code:{code}"
197 |
198 | # Get all hash fields with retry
199 | data = self.redis.hgetall(key)
200 |
201 | if not data:
202 | logger.warning(f"OAuth code {code[:10]}... not found or expired (attempt {attempt + 1})")
203 | return None
204 |
205 | # Deserialize data
206 | deserialized_data = self._deserialize_data(data)
207 |
208 | # Check manual expiration (in case Redis TTL failed)
209 | expires_at = deserialized_data.get("expires_at", 0)
210 | if expires_at and time.time() > expires_at:
211 | logger.warning(f"OAuth code {code[:10]}... manually expired")
212 | try:
213 | self.redis.delete(key)
214 | except Exception as del_error:
215 | logger.warning(f"Failed to delete expired code: {del_error}")
216 | return None
217 |
218 | # Delete after use for security (one-time use)
219 | if delete_after_use:
220 | try:
221 | self.redis.delete(key)
222 | logger.info(f"Retrieved and deleted OAuth code {code[:10]}... (attempt {attempt + 1})")
223 | except Exception as del_error:
224 | logger.warning(f"Failed to delete code after use: {del_error}")
225 | # Continue anyway since we got the data
226 | else:
227 | logger.info(f"Retrieved OAuth code {code[:10]}... (not deleted, attempt {attempt + 1})")
228 |
229 | return deserialized_data
230 |
231 | except (RequestsConnectionError, RequestsTimeout, OSError, socket.error) as e:
232 | logger.warning(f"Redis connection error on retrieval attempt {attempt + 1}: {e}")
233 | if attempt == max_retries - 1:
234 | logger.error(f"Failed to retrieve OAuth code {code[:10]}... after {max_retries} attempts: {e}")
235 | return None
236 | time.sleep(0.5 * (attempt + 1)) # Exponential backoff
237 |
238 | except Exception as e:
239 | logger.error(f"Failed to retrieve OAuth code {code[:10]}... on attempt {attempt + 1}: {e}")
240 | if attempt == max_retries - 1:
241 | return None
242 | time.sleep(0.5 * (attempt + 1))
243 |
244 | return None
245 |
246 | # User Session Methods
247 |
248 | def set_session(self, session_id: str, user_data: Dict[str, Any]) -> bool:
249 | """
250 | Store user session data with sliding expiration.
251 |
252 | Args:
253 | session_id: Unique session identifier
254 | user_data: User session data (user_id, email, scopes, etc.)
255 |
256 | Returns:
257 | True if stored successfully, False otherwise
258 | """
259 | try:
260 | key = f"session:{session_id}"
261 |
262 | # Add session metadata
263 | session_data = user_data.copy()
264 | session_data.update({
265 | "session_id": session_id,
266 | "created_at": time.time(),
267 | "last_accessed": time.time()
268 | })
269 |
270 | # Serialize and store - Upstash Redis doesn't support mapping parameter
271 | serialized_data = self._serialize_data(session_data)
272 |
273 | # Use individual hset calls for each field (Upstash compatibility)
274 | for field, value in serialized_data.items():
275 | self.redis.hset(key, field, value)
276 | self.redis.expire(key, self.session_ttl)
277 |
278 | logger.info(f"Stored session {session_id[:10]}... with TTL {self.session_ttl}s")
279 | return True
280 |
281 | except Exception as e:
282 | logger.error(f"Failed to store session {session_id[:10]}...: {e}")
283 | return False
284 |
285 | def get_session(self, session_id: str, refresh_ttl: bool = True) -> Optional[Dict[str, Any]]:
286 | """
287 | Retrieve user session data.
288 |
289 | Args:
290 | session_id: Session identifier
291 | refresh_ttl: If True, extend session TTL on access
292 |
293 | Returns:
294 | Session data dictionary or None if not found/expired
295 | """
296 | try:
297 | key = f"session:{session_id}"
298 |
299 | # Get session data
300 | data = self.redis.hgetall(key)
301 |
302 | if not data:
303 | logger.warning(f"Session {session_id[:10]}... not found or expired")
304 | return None
305 |
306 | # Deserialize data
307 | session_data = self._deserialize_data(data)
308 |
309 | # Update last accessed time and refresh TTL
310 | if refresh_ttl:
311 | session_data["last_accessed"] = time.time()
312 | self.redis.hset(key, "last_accessed", str(time.time()))
313 | self.redis.expire(key, self.session_ttl)
314 | logger.debug(f"Refreshed session {session_id[:10]}... TTL")
315 |
316 | return session_data
317 |
318 | except Exception as e:
319 | logger.error(f"Failed to retrieve session {session_id[:10]}...: {e}")
320 | return None
321 |
322 | def delete_session(self, session_id: str) -> bool:
323 | """
324 | Delete user session (logout).
325 |
326 | Args:
327 | session_id: Session identifier
328 |
329 | Returns:
330 | True if deleted successfully, False otherwise
331 | """
332 | try:
333 | key = f"session:{session_id}"
334 | result = self.redis.delete(key)
335 |
336 | if result:
337 | logger.info(f"Deleted session {session_id[:10]}...")
338 | return True
339 | else:
340 | logger.warning(f"Session {session_id[:10]}... not found for deletion")
341 | return False
342 |
343 | except Exception as e:
344 | logger.error(f"Failed to delete session {session_id[:10]}...: {e}")
345 | return False
346 |
347 | # Health Check Methods
348 |
349 | def health_check(self) -> Dict[str, Any]:
350 | """
351 | Perform Redis health check.
352 |
353 | Returns:
354 | Health status dictionary
355 | """
356 | try:
357 | # Test basic operations
358 | test_key = f"health:check:{int(time.time())}"
359 | test_value = {"timestamp": time.time(), "test": True}
360 |
361 | # Test set - Use individual hset calls for Upstash compatibility
362 | serialized_test = self._serialize_data(test_value)
363 | for field, value in serialized_test.items():
364 | self.redis.hset(test_key, field, value)
365 |
366 | # Test get
367 | retrieved = self.redis.hgetall(test_key)
368 |
369 | # Test delete
370 | self.redis.delete(test_key)
371 |
372 | return {
373 | "status": "healthy",
374 | "redis_connected": True,
375 | "operations_working": bool(retrieved),
376 | "timestamp": datetime.utcnow().isoformat()
377 | }
378 |
379 | except Exception as e:
380 | logger.error(f"Redis health check failed: {e}")
381 | return {
382 | "status": "unhealthy",
383 | "redis_connected": False,
384 | "error": str(e),
385 | "timestamp": datetime.utcnow().isoformat()
386 | }
387 |
388 | def get_stats(self) -> Dict[str, Any]:
389 | """
390 | Get Redis usage statistics.
391 |
392 | Returns:
393 | Statistics dictionary
394 | """
395 | try:
396 | # Get basic info (not all Upstash plans support INFO command)
397 | stats = {
398 | "oauth_codes_pattern": "oauth:code:*",
399 | "sessions_pattern": "session:*",
400 | "timestamp": datetime.utcnow().isoformat()
401 | }
402 |
403 | try:
404 | # Try to get counts (may fail on some Upstash plans)
405 | oauth_keys = self.redis.keys("oauth:code:*")
406 | session_keys = self.redis.keys("session:*")
407 |
408 | stats.update({
409 | "active_oauth_codes": len(oauth_keys) if oauth_keys else 0,
410 | "active_sessions": len(session_keys) if session_keys else 0
411 | })
412 | except Exception as e:
413 | logger.warning(f"Could not get detailed stats: {e}")
414 | stats["warning"] = "Detailed stats not available on this Redis plan"
415 |
416 | return stats
417 |
418 | except Exception as e:
419 | logger.error(f"Failed to get Redis stats: {e}")
420 | return {"error": str(e), "timestamp": datetime.utcnow().isoformat()}
421 |
422 | # Global instance for easy importing
423 | redis_store = None
424 |
425 | def get_redis_store() -> Optional[RedisSessionStore]:
426 | """
427 | Get global Redis store instance (singleton pattern).
428 |
429 | Returns:
430 | RedisSessionStore instance or None if initialization fails
431 | """
432 | global redis_store
433 |
434 | if redis_store is None:
435 | try:
436 | logger.info("Initializing Redis store...")
437 | redis_store = RedisSessionStore()
438 | logger.info("Redis store initialized successfully")
439 | except Exception as e:
440 | logger.error(f"Failed to initialize Redis store: {e}")
441 | redis_store = None
442 |
443 | return redis_store
444 |
445 | def init_redis_store() -> RedisSessionStore:
446 | """
447 | Initialize Redis store and perform health check.
448 |
449 | Returns:
450 | RedisSessionStore instance
451 |
452 | Raises:
453 | Exception if Redis is not available or unhealthy
454 | """
455 | store = get_redis_store()
456 |
457 | # Perform health check
458 | health = store.health_check()
459 |
460 | if health["status"] != "healthy":
461 | raise Exception(f"Redis health check failed: {health}")
462 |
463 | logger.info("Redis session store initialized and healthy")
464 | return store
```
--------------------------------------------------------------------------------
/anayasa_mcp_module/bireysel_client.py:
--------------------------------------------------------------------------------
```python
1 | # anayasa_mcp_module/bireysel_client.py
2 | # This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
3 |
4 | import httpx
5 | from bs4 import BeautifulSoup, Tag
6 | from typing import Dict, Any, List, Optional, Tuple
7 | import logging
8 | import html
9 | import re
10 | import io
11 | from urllib.parse import urlencode, urljoin, quote
12 | from markitdown import MarkItDown
13 | import math # For math.ceil for pagination
14 |
15 | from .models import (
16 | AnayasaBireyselReportSearchRequest,
17 | AnayasaBireyselReportDecisionDetail,
18 | AnayasaBireyselReportDecisionSummary,
19 | AnayasaBireyselReportSearchResult,
20 | AnayasaBireyselBasvuruDocumentMarkdown, # Model for Bireysel Başvuru document
21 | )
22 |
23 | logger = logging.getLogger(__name__)
24 | if not logger.hasHandlers():
25 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
26 |
27 |
28 | class AnayasaBireyselBasvuruApiClient:
29 | BASE_URL = "https://kararlarbilgibankasi.anayasa.gov.tr"
30 | SEARCH_PATH = "/Ara"
31 | DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
32 |
33 | def __init__(self, request_timeout: float = 60.0):
34 | self.http_client = httpx.AsyncClient(
35 | base_url=self.BASE_URL,
36 | headers={
37 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
38 | "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
39 | "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"
40 | },
41 | timeout=request_timeout,
42 | verify=True,
43 | follow_redirects=True
44 | )
45 |
46 | def _build_query_params_for_bireysel_report(self, params: AnayasaBireyselReportSearchRequest) -> List[Tuple[str, str]]:
47 | query_params: List[Tuple[str, str]] = []
48 | query_params.append(("KararBulteni", "1")) # Specific to this report type
49 |
50 | if params.keywords:
51 | for kw in params.keywords:
52 | query_params.append(("KelimeAra[]", kw))
53 |
54 | if params.page_to_fetch and params.page_to_fetch > 1:
55 | query_params.append(("page", str(params.page_to_fetch)))
56 |
57 | return query_params
58 |
59 | async def search_bireysel_basvuru_report(
60 | self,
61 | params: AnayasaBireyselReportSearchRequest
62 | ) -> AnayasaBireyselReportSearchResult:
63 | final_query_params = self._build_query_params_for_bireysel_report(params)
64 | request_url = self.SEARCH_PATH
65 |
66 | logger.info(f"AnayasaBireyselBasvuruApiClient: Performing Bireysel Başvuru Report search. Path: {request_url}, Params: {final_query_params}")
67 |
68 | try:
69 | response = await self.http_client.get(request_url, params=final_query_params)
70 | response.raise_for_status()
71 | html_content = response.text
72 | except httpx.RequestError as e:
73 | logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP request error during Bireysel Başvuru Report search: {e}")
74 | raise
75 | except Exception as e:
76 | logger.error(f"AnayasaBireyselBasvuruApiClient: Error processing Bireysel Başvuru Report search request: {e}")
77 | raise
78 |
79 | soup = BeautifulSoup(html_content, 'html.parser')
80 |
81 | total_records = None
82 | bulunan_karar_div = soup.find("div", class_="bulunankararsayisi")
83 | if bulunan_karar_div:
84 | match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True))
85 | if match_records:
86 | total_records = int(match_records.group(1))
87 |
88 | processed_decisions: List[AnayasaBireyselReportDecisionSummary] = []
89 |
90 | report_content_area = soup.find("div", class_="HaberBulteni")
91 | if not report_content_area:
92 | logger.warning("HaberBulteni div not found, attempting to parse decision divs from the whole page.")
93 | report_content_area = soup
94 |
95 | decision_divs = report_content_area.find_all("div", class_="KararBulteniBirKarar")
96 | if not decision_divs:
97 | logger.warning("No KararBulteniBirKarar divs found.")
98 |
99 |
100 | for decision_div in decision_divs:
101 | title_tag = decision_div.find("h4")
102 | 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 "")
103 |
104 |
105 | alti_cizili_div = decision_div.find("div", class_="AltiCizili")
106 | ref_no, dec_type, body, app_date, dec_date, url_path = "", "", "", "", "", ""
107 | if alti_cizili_div:
108 | link_tag = alti_cizili_div.find("a", href=True)
109 | if link_tag:
110 | ref_no = link_tag.get_text(strip=True)
111 | url_path = link_tag['href']
112 |
113 | parts_text = alti_cizili_div.get_text(separator="|", strip=True)
114 | parts = [part.strip() for part in parts_text.split("|")]
115 |
116 | # Clean ref_no from the first part if it was extracted from link
117 | if ref_no and parts and parts[0].strip().startswith(ref_no):
118 | parts[0] = parts[0].replace(ref_no, "").strip()
119 | if not parts[0]: parts.pop(0) # Remove empty string if ref_no was the only content
120 |
121 | # Assign parts based on typical order, adjusting for missing ref_no at start
122 | current_idx = 0
123 | if not ref_no and len(parts) > current_idx and re.match(r"\d+/\d+", parts[current_idx]): # Check if first part is ref_no
124 | ref_no = parts[current_idx]
125 | current_idx += 1
126 |
127 | dec_type = parts[current_idx] if len(parts) > current_idx else ""
128 | current_idx += 1
129 | body = parts[current_idx] if len(parts) > current_idx else ""
130 | current_idx += 1
131 |
132 | app_date_raw = parts[current_idx] if len(parts) > current_idx else ""
133 | current_idx += 1
134 | dec_date_raw = parts[current_idx] if len(parts) > current_idx else ""
135 |
136 | if app_date_raw and "Başvuru Tarihi :" in app_date_raw:
137 | app_date = app_date_raw.replace("Başvuru Tarihi :", "").strip()
138 | elif app_date_raw: # If label is missing but format matches
139 | app_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', app_date_raw)
140 | if app_date_match: app_date = app_date_match.group(1)
141 |
142 |
143 | if dec_date_raw and "Karar Tarihi :" in dec_date_raw:
144 | dec_date = dec_date_raw.replace("Karar Tarihi :", "").strip()
145 | elif dec_date_raw: # If label is missing but format matches
146 | dec_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', dec_date_raw)
147 | if dec_date_match: dec_date = dec_date_match.group(1)
148 |
149 |
150 | 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 :"))
151 | subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else ""
152 |
153 | details_list: List[AnayasaBireyselReportDecisionDetail] = []
154 | karar_detaylari_div = decision_div.find_next_sibling("div", id="KararDetaylari") # Corrected: was KararDetaylari
155 | if karar_detaylari_div:
156 | table = karar_detaylari_div.find("table", class_="table")
157 | if table and table.find("tbody"):
158 | for row in table.find("tbody").find_all("tr"):
159 | cells = row.find_all("td")
160 | if len(cells) == 4: # Hak, Müdahale İddiası, Sonuç, Giderim
161 | details_list.append(AnayasaBireyselReportDecisionDetail(
162 | hak=cells[0].get_text(strip=True) or "",
163 | mudahale_iddiasi=cells[1].get_text(strip=True) or "",
164 | sonuc=cells[2].get_text(strip=True) or "",
165 | giderim=cells[3].get_text(strip=True) or "",
166 | ))
167 |
168 | full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else ""
169 |
170 | processed_decisions.append(AnayasaBireyselReportDecisionSummary(
171 | title=title_text,
172 | decision_reference_no=ref_no,
173 | decision_page_url=full_decision_page_url,
174 | decision_type_summary=dec_type,
175 | decision_making_body=body,
176 | application_date_summary=app_date,
177 | decision_date_summary=dec_date,
178 | application_subject_summary=subject_text,
179 | details=details_list
180 | ))
181 |
182 | return AnayasaBireyselReportSearchResult(
183 | decisions=processed_decisions,
184 | total_records_found=total_records,
185 | retrieved_page_number=params.page_to_fetch
186 | )
187 |
188 | def _convert_html_to_markdown_bireysel(self, full_decision_html_content: str) -> Optional[str]:
189 | if not full_decision_html_content:
190 | return None
191 |
192 | processed_html = html.unescape(full_decision_html_content)
193 | soup = BeautifulSoup(processed_html, "html.parser")
194 | html_input_for_markdown = ""
195 |
196 | karar_tab_content = soup.find("div", id="Karar")
197 | if karar_tab_content:
198 | karar_html_span = karar_tab_content.find("span", class_="kararHtml")
199 | if karar_html_span:
200 | word_section = karar_html_span.find("div", class_="WordSection1")
201 | if word_section:
202 | for s in word_section.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
203 | s.decompose()
204 | html_input_for_markdown = str(word_section)
205 | else:
206 | logger.warning("AnayasaBireyselBasvuruApiClient: WordSection1 not found in span.kararHtml. Using span.kararHtml content.")
207 | for s in karar_html_span.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
208 | s.decompose()
209 | html_input_for_markdown = str(karar_html_span)
210 | else:
211 | logger.warning("AnayasaBireyselBasvuruApiClient: span.kararHtml not found in div#Karar. Using div#Karar content.")
212 | for s in karar_tab_content.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
213 | s.decompose()
214 | html_input_for_markdown = str(karar_tab_content)
215 | else:
216 | logger.warning("AnayasaBireyselBasvuruApiClient: div#Karar (KARAR tab) not found. Trying WordSection1 fallback.")
217 | word_section_fallback = soup.find("div", class_="WordSection1")
218 | if word_section_fallback:
219 | for s in word_section_fallback.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
220 | s.decompose()
221 | html_input_for_markdown = str(word_section_fallback)
222 | else:
223 | body_tag = soup.find("body")
224 | if body_tag:
225 | for s in body_tag.select('script, style, .item.col-xs-12.col-sm-12, center:has(b), .banner, .footer, .yazdirmaalani, .filtreler, .menu, .altmenu, .geri, .arabuton, .temizlebutonu, form#KararGetir, .TabBaslik, #KararDetaylari, .share-button-container'):
226 | s.decompose()
227 | html_input_for_markdown = str(body_tag)
228 | else:
229 | html_input_for_markdown = processed_html
230 |
231 | markdown_text = None
232 | try:
233 | # Ensure the content is wrapped in basic HTML structure if it's not already
234 | if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
235 | html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
236 | else:
237 | html_content = html_input_for_markdown
238 |
239 | # Convert HTML string to bytes and create BytesIO stream
240 | html_bytes = html_content.encode('utf-8')
241 | html_stream = io.BytesIO(html_bytes)
242 |
243 | # Pass BytesIO stream to MarkItDown to avoid temp file creation
244 | md_converter = MarkItDown()
245 | conversion_result = md_converter.convert(html_stream)
246 | markdown_text = conversion_result.text_content
247 | except Exception as e:
248 | logger.error(f"AnayasaBireyselBasvuruApiClient: MarkItDown conversion error: {e}")
249 | return markdown_text
250 |
251 | async def get_decision_document_as_markdown(
252 | self,
253 | document_url_path: str, # e.g. /BB/2021/20295
254 | page_number: int = 1
255 | ) -> AnayasaBireyselBasvuruDocumentMarkdown:
256 | full_url = urljoin(self.BASE_URL, document_url_path)
257 | logger.info(f"AnayasaBireyselBasvuruApiClient: Fetching Bireysel Başvuru document for Markdown (page {page_number}) from URL: {full_url}")
258 |
259 | basvuru_no_from_page = None
260 | karar_tarihi_from_page = None
261 | basvuru_tarihi_from_page = None
262 | karari_veren_birim_from_page = None
263 | karar_turu_from_page = None
264 | resmi_gazete_info_from_page = None
265 |
266 | try:
267 | response = await self.http_client.get(full_url)
268 | response.raise_for_status()
269 | html_content_from_api = response.text
270 |
271 | if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
272 | logger.warning(f"AnayasaBireyselBasvuruApiClient: Received empty HTML from {full_url}.")
273 | return AnayasaBireyselBasvuruDocumentMarkdown(
274 | source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
275 | )
276 |
277 | soup = BeautifulSoup(html_content_from_api, 'html.parser')
278 |
279 | meta_desc_tag = soup.find("meta", attrs={"name": "description"})
280 | if meta_desc_tag and meta_desc_tag.get("content"):
281 | content = meta_desc_tag["content"]
282 | bn_match = re.search(r"B\.\s*No:\s*([\d\/]+)", content)
283 | if bn_match: basvuru_no_from_page = bn_match.group(1).strip()
284 |
285 | date_match = re.search(r"(\d{1,2}\/\d{1,2}\/\d{4}),\s*§", content)
286 | if date_match: karar_tarihi_from_page = date_match.group(1).strip()
287 |
288 | karar_detaylari_tab = soup.find("div", id="KararDetaylari")
289 | if karar_detaylari_tab:
290 | table = karar_detaylari_tab.find("table", class_="table")
291 | if table:
292 | rows = table.find_all("tr")
293 | for row in rows:
294 | cells = row.find_all("td")
295 | if len(cells) == 2:
296 | key = cells[0].get_text(strip=True)
297 | value = cells[1].get_text(strip=True)
298 | if "Kararı Veren Birim" in key: karari_veren_birim_from_page = value
299 | elif "Karar Türü (Başvuru Sonucu)" in key: karar_turu_from_page = value
300 | elif "Başvuru No" in key and not basvuru_no_from_page: basvuru_no_from_page = value
301 | elif "Başvuru Tarihi" in key: basvuru_tarihi_from_page = value
302 | elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value
303 | elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value
304 |
305 | full_markdown_content = self._convert_html_to_markdown_bireysel(html_content_from_api)
306 |
307 | if not full_markdown_content:
308 | return AnayasaBireyselBasvuruDocumentMarkdown(
309 | source_url=full_url,
310 | basvuru_no_from_page=basvuru_no_from_page,
311 | karar_tarihi_from_page=karar_tarihi_from_page,
312 | basvuru_tarihi_from_page=basvuru_tarihi_from_page,
313 | karari_veren_birim_from_page=karari_veren_birim_from_page,
314 | karar_turu_from_page=karar_turu_from_page,
315 | resmi_gazete_info_from_page=resmi_gazete_info_from_page,
316 | markdown_chunk=None,
317 | current_page=page_number,
318 | total_pages=0,
319 | is_paginated=False
320 | )
321 |
322 | content_length = len(full_markdown_content)
323 | total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
324 | if total_pages == 0: total_pages = 1
325 |
326 | current_page_clamped = max(1, min(page_number, total_pages))
327 | start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
328 | end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
329 | markdown_chunk = full_markdown_content[start_index:end_index]
330 |
331 | return AnayasaBireyselBasvuruDocumentMarkdown(
332 | source_url=full_url,
333 | basvuru_no_from_page=basvuru_no_from_page,
334 | karar_tarihi_from_page=karar_tarihi_from_page,
335 | basvuru_tarihi_from_page=basvuru_tarihi_from_page,
336 | karari_veren_birim_from_page=karari_veren_birim_from_page,
337 | karar_turu_from_page=karar_turu_from_page,
338 | resmi_gazete_info_from_page=resmi_gazete_info_from_page,
339 | markdown_chunk=markdown_chunk,
340 | current_page=current_page_clamped,
341 | total_pages=total_pages,
342 | is_paginated=(total_pages > 1)
343 | )
344 |
345 | except httpx.RequestError as e:
346 | logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP error fetching Bireysel Başvuru document from {full_url}: {e}")
347 | raise
348 | except Exception as e:
349 | logger.error(f"AnayasaBireyselBasvuruApiClient: General error processing Bireysel Başvuru document from {full_url}: {e}")
350 | raise
351 |
352 | async def close_client_session(self):
353 | if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
354 | await self.http_client.aclose()
355 | logger.info("AnayasaBireyselBasvuruApiClient: HTTP client session closed.")
```
--------------------------------------------------------------------------------
/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/bireysel_client.py:
--------------------------------------------------------------------------------
```python
1 | # anayasa_mcp_module/bireysel_client.py
2 | # This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
3 |
4 | import httpx
5 | from bs4 import BeautifulSoup, Tag
6 | from typing import Dict, Any, List, Optional, Tuple
7 | import logging
8 | import html
9 | import re
10 | import io
11 | from urllib.parse import urlencode, urljoin, quote
12 | from markitdown import MarkItDown
13 | import math # For math.ceil for pagination
14 |
15 | from .models import (
16 | AnayasaBireyselReportSearchRequest,
17 | AnayasaBireyselReportDecisionDetail,
18 | AnayasaBireyselReportDecisionSummary,
19 | AnayasaBireyselReportSearchResult,
20 | AnayasaBireyselBasvuruDocumentMarkdown, # Model for Bireysel Başvuru document
21 | )
22 |
23 | logger = logging.getLogger(__name__)
24 | if not logger.hasHandlers():
25 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
26 |
27 |
28 | class AnayasaBireyselBasvuruApiClient:
29 | BASE_URL = "https://kararlarbilgibankasi.anayasa.gov.tr"
30 | SEARCH_PATH = "/Ara"
31 | DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
32 |
33 | def __init__(self, request_timeout: float = 60.0):
34 | self.http_client = httpx.AsyncClient(
35 | base_url=self.BASE_URL,
36 | headers={
37 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
38 | "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
39 | "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"
40 | },
41 | timeout=request_timeout,
42 | verify=True,
43 | follow_redirects=True
44 | )
45 |
46 | def _build_query_params_for_bireysel_report(self, params: AnayasaBireyselReportSearchRequest) -> List[Tuple[str, str]]:
47 | query_params: List[Tuple[str, str]] = []
48 | query_params.append(("KararBulteni", "1")) # Specific to this report type
49 |
50 | if params.keywords:
51 | for kw in params.keywords:
52 | query_params.append(("KelimeAra[]", kw))
53 |
54 | if params.page_to_fetch and params.page_to_fetch > 1:
55 | query_params.append(("page", str(params.page_to_fetch)))
56 |
57 | return query_params
58 |
59 | async def search_bireysel_basvuru_report(
60 | self,
61 | params: AnayasaBireyselReportSearchRequest
62 | ) -> AnayasaBireyselReportSearchResult:
63 | final_query_params = self._build_query_params_for_bireysel_report(params)
64 | request_url = self.SEARCH_PATH
65 |
66 | logger.info(f"AnayasaBireyselBasvuruApiClient: Performing Bireysel Başvuru Report search. Path: {request_url}, Params: {final_query_params}")
67 |
68 | try:
69 | response = await self.http_client.get(request_url, params=final_query_params)
70 | response.raise_for_status()
71 | html_content = response.text
72 | except httpx.RequestError as e:
73 | logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP request error during Bireysel Başvuru Report search: {e}")
74 | raise
75 | except Exception as e:
76 | logger.error(f"AnayasaBireyselBasvuruApiClient: Error processing Bireysel Başvuru Report search request: {e}")
77 | raise
78 |
79 | soup = BeautifulSoup(html_content, 'html.parser')
80 |
81 | total_records = None
82 | bulunan_karar_div = soup.find("div", class_="bulunankararsayisi")
83 | if bulunan_karar_div:
84 | match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True))
85 | if match_records:
86 | total_records = int(match_records.group(1))
87 |
88 | processed_decisions: List[AnayasaBireyselReportDecisionSummary] = []
89 |
90 | report_content_area = soup.find("div", class_="HaberBulteni")
91 | if not report_content_area:
92 | logger.warning("HaberBulteni div not found, attempting to parse decision divs from the whole page.")
93 | report_content_area = soup
94 |
95 | decision_divs = report_content_area.find_all("div", class_="KararBulteniBirKarar")
96 | if not decision_divs:
97 | logger.warning("No KararBulteniBirKarar divs found.")
98 |
99 |
100 | for decision_div in decision_divs:
101 | title_tag = decision_div.find("h4")
102 | 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 "")
103 |
104 |
105 | alti_cizili_div = decision_div.find("div", class_="AltiCizili")
106 | ref_no, dec_type, body, app_date, dec_date, url_path = "", "", "", "", "", ""
107 | if alti_cizili_div:
108 | link_tag = alti_cizili_div.find("a", href=True)
109 | if link_tag:
110 | ref_no = link_tag.get_text(strip=True)
111 | url_path = link_tag['href']
112 |
113 | parts_text = alti_cizili_div.get_text(separator="|", strip=True)
114 | parts = [part.strip() for part in parts_text.split("|")]
115 |
116 | # Clean ref_no from the first part if it was extracted from link
117 | if ref_no and parts and parts[0].strip().startswith(ref_no):
118 | parts[0] = parts[0].replace(ref_no, "").strip()
119 | if not parts[0]: parts.pop(0) # Remove empty string if ref_no was the only content
120 |
121 | # Assign parts based on typical order, adjusting for missing ref_no at start
122 | current_idx = 0
123 | if not ref_no and len(parts) > current_idx and re.match(r"\d+/\d+", parts[current_idx]): # Check if first part is ref_no
124 | ref_no = parts[current_idx]
125 | current_idx += 1
126 |
127 | dec_type = parts[current_idx] if len(parts) > current_idx else ""
128 | current_idx += 1
129 | body = parts[current_idx] if len(parts) > current_idx else ""
130 | current_idx += 1
131 |
132 | app_date_raw = parts[current_idx] if len(parts) > current_idx else ""
133 | current_idx += 1
134 | dec_date_raw = parts[current_idx] if len(parts) > current_idx else ""
135 |
136 | if app_date_raw and "Başvuru Tarihi :" in app_date_raw:
137 | app_date = app_date_raw.replace("Başvuru Tarihi :", "").strip()
138 | elif app_date_raw: # If label is missing but format matches
139 | app_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', app_date_raw)
140 | if app_date_match: app_date = app_date_match.group(1)
141 |
142 |
143 | if dec_date_raw and "Karar Tarihi :" in dec_date_raw:
144 | dec_date = dec_date_raw.replace("Karar Tarihi :", "").strip()
145 | elif dec_date_raw: # If label is missing but format matches
146 | dec_date_match = re.search(r'(\d{1,2}/\d{1,2}/\d{4})', dec_date_raw)
147 | if dec_date_match: dec_date = dec_date_match.group(1)
148 |
149 |
150 | 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 :"))
151 | subject_text = subject_div.get_text(strip=True).replace("BAŞVURU KONUSU :", "").strip() if subject_div else ""
152 |
153 | details_list: List[AnayasaBireyselReportDecisionDetail] = []
154 | karar_detaylari_div = decision_div.find_next_sibling("div", id="KararDetaylari") # Corrected: was KararDetaylari
155 | if karar_detaylari_div:
156 | table = karar_detaylari_div.find("table", class_="table")
157 | if table and table.find("tbody"):
158 | for row in table.find("tbody").find_all("tr"):
159 | cells = row.find_all("td")
160 | if len(cells) == 4: # Hak, Müdahale İddiası, Sonuç, Giderim
161 | details_list.append(AnayasaBireyselReportDecisionDetail(
162 | hak=cells[0].get_text(strip=True) or "",
163 | mudahale_iddiasi=cells[1].get_text(strip=True) or "",
164 | sonuc=cells[2].get_text(strip=True) or "",
165 | giderim=cells[3].get_text(strip=True) or "",
166 | ))
167 |
168 | full_decision_page_url = urljoin(self.BASE_URL, url_path) if url_path else ""
169 |
170 | processed_decisions.append(AnayasaBireyselReportDecisionSummary(
171 | title=title_text,
172 | decision_reference_no=ref_no,
173 | decision_page_url=full_decision_page_url,
174 | decision_type_summary=dec_type,
175 | decision_making_body=body,
176 | application_date_summary=app_date,
177 | decision_date_summary=dec_date,
178 | application_subject_summary=subject_text,
179 | details=details_list
180 | ))
181 |
182 | return AnayasaBireyselReportSearchResult(
183 | decisions=processed_decisions,
184 | total_records_found=total_records,
185 | retrieved_page_number=params.page_to_fetch
186 | )
187 |
188 | def _convert_html_to_markdown_bireysel(self, full_decision_html_content: str) -> Optional[str]:
189 | if not full_decision_html_content:
190 | return None
191 |
192 | processed_html = html.unescape(full_decision_html_content)
193 | soup = BeautifulSoup(processed_html, "html.parser")
194 | html_input_for_markdown = ""
195 |
196 | karar_tab_content = soup.find("div", id="Karar")
197 | if karar_tab_content:
198 | karar_html_span = karar_tab_content.find("span", class_="kararHtml")
199 | if karar_html_span:
200 | word_section = karar_html_span.find("div", class_="WordSection1")
201 | if word_section:
202 | for s in word_section.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
203 | s.decompose()
204 | html_input_for_markdown = str(word_section)
205 | else:
206 | logger.warning("AnayasaBireyselBasvuruApiClient: WordSection1 not found in span.kararHtml. Using span.kararHtml content.")
207 | for s in karar_html_span.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
208 | s.decompose()
209 | html_input_for_markdown = str(karar_html_span)
210 | else:
211 | logger.warning("AnayasaBireyselBasvuruApiClient: span.kararHtml not found in div#Karar. Using div#Karar content.")
212 | for s in karar_tab_content.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
213 | s.decompose()
214 | html_input_for_markdown = str(karar_tab_content)
215 | else:
216 | logger.warning("AnayasaBireyselBasvuruApiClient: div#Karar (KARAR tab) not found. Trying WordSection1 fallback.")
217 | word_section_fallback = soup.find("div", class_="WordSection1")
218 | if word_section_fallback:
219 | for s in word_section_fallback.select('script, style, .item.col-xs-12.col-sm-12, center:has(b)'):
220 | s.decompose()
221 | html_input_for_markdown = str(word_section_fallback)
222 | else:
223 | body_tag = soup.find("body")
224 | if body_tag:
225 | for s in body_tag.select('script, style, .item.col-xs-12.col-sm-12, center:has(b), .banner, .footer, .yazdirmaalani, .filtreler, .menu, .altmenu, .geri, .arabuton, .temizlebutonu, form#KararGetir, .TabBaslik, #KararDetaylari, .share-button-container'):
226 | s.decompose()
227 | html_input_for_markdown = str(body_tag)
228 | else:
229 | html_input_for_markdown = processed_html
230 |
231 | markdown_text = None
232 | try:
233 | # Ensure the content is wrapped in basic HTML structure if it's not already
234 | if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
235 | html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
236 | else:
237 | html_content = html_input_for_markdown
238 |
239 | # Convert HTML string to bytes and create BytesIO stream
240 | html_bytes = html_content.encode('utf-8')
241 | html_stream = io.BytesIO(html_bytes)
242 |
243 | # Pass BytesIO stream to MarkItDown to avoid temp file creation
244 | md_converter = MarkItDown()
245 | conversion_result = md_converter.convert(html_stream)
246 | markdown_text = conversion_result.text_content
247 | except Exception as e:
248 | logger.error(f"AnayasaBireyselBasvuruApiClient: MarkItDown conversion error: {e}")
249 | return markdown_text
250 |
251 | async def get_decision_document_as_markdown(
252 | self,
253 | document_url_path: str, # e.g. /BB/2021/20295
254 | page_number: int = 1
255 | ) -> AnayasaBireyselBasvuruDocumentMarkdown:
256 | full_url = urljoin(self.BASE_URL, document_url_path)
257 | logger.info(f"AnayasaBireyselBasvuruApiClient: Fetching Bireysel Başvuru document for Markdown (page {page_number}) from URL: {full_url}")
258 |
259 | basvuru_no_from_page = None
260 | karar_tarihi_from_page = None
261 | basvuru_tarihi_from_page = None
262 | karari_veren_birim_from_page = None
263 | karar_turu_from_page = None
264 | resmi_gazete_info_from_page = None
265 |
266 | try:
267 | response = await self.http_client.get(full_url)
268 | response.raise_for_status()
269 | html_content_from_api = response.text
270 |
271 | if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
272 | logger.warning(f"AnayasaBireyselBasvuruApiClient: Received empty HTML from {full_url}.")
273 | return AnayasaBireyselBasvuruDocumentMarkdown(
274 | source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
275 | )
276 |
277 | soup = BeautifulSoup(html_content_from_api, 'html.parser')
278 |
279 | meta_desc_tag = soup.find("meta", attrs={"name": "description"})
280 | if meta_desc_tag and meta_desc_tag.get("content"):
281 | content = meta_desc_tag["content"]
282 | bn_match = re.search(r"B\.\s*No:\s*([\d\/]+)", content)
283 | if bn_match: basvuru_no_from_page = bn_match.group(1).strip()
284 |
285 | date_match = re.search(r"(\d{1,2}\/\d{1,2}\/\d{4}),\s*§", content)
286 | if date_match: karar_tarihi_from_page = date_match.group(1).strip()
287 |
288 | karar_detaylari_tab = soup.find("div", id="KararDetaylari")
289 | if karar_detaylari_tab:
290 | table = karar_detaylari_tab.find("table", class_="table")
291 | if table:
292 | rows = table.find_all("tr")
293 | for row in rows:
294 | cells = row.find_all("td")
295 | if len(cells) == 2:
296 | key = cells[0].get_text(strip=True)
297 | value = cells[1].get_text(strip=True)
298 | if "Kararı Veren Birim" in key: karari_veren_birim_from_page = value
299 | elif "Karar Türü (Başvuru Sonucu)" in key: karar_turu_from_page = value
300 | elif "Başvuru No" in key and not basvuru_no_from_page: basvuru_no_from_page = value
301 | elif "Başvuru Tarihi" in key: basvuru_tarihi_from_page = value
302 | elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value
303 | elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value
304 |
305 | full_markdown_content = self._convert_html_to_markdown_bireysel(html_content_from_api)
306 |
307 | if not full_markdown_content:
308 | return AnayasaBireyselBasvuruDocumentMarkdown(
309 | source_url=full_url,
310 | basvuru_no_from_page=basvuru_no_from_page,
311 | karar_tarihi_from_page=karar_tarihi_from_page,
312 | basvuru_tarihi_from_page=basvuru_tarihi_from_page,
313 | karari_veren_birim_from_page=karari_veren_birim_from_page,
314 | karar_turu_from_page=karar_turu_from_page,
315 | resmi_gazete_info_from_page=resmi_gazete_info_from_page,
316 | markdown_chunk=None,
317 | current_page=page_number,
318 | total_pages=0,
319 | is_paginated=False
320 | )
321 |
322 | content_length = len(full_markdown_content)
323 | total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
324 | if total_pages == 0: total_pages = 1
325 |
326 | current_page_clamped = max(1, min(page_number, total_pages))
327 | start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
328 | end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
329 | markdown_chunk = full_markdown_content[start_index:end_index]
330 |
331 | return AnayasaBireyselBasvuruDocumentMarkdown(
332 | source_url=full_url,
333 | basvuru_no_from_page=basvuru_no_from_page,
334 | karar_tarihi_from_page=karar_tarihi_from_page,
335 | basvuru_tarihi_from_page=basvuru_tarihi_from_page,
336 | karari_veren_birim_from_page=karari_veren_birim_from_page,
337 | karar_turu_from_page=karar_turu_from_page,
338 | resmi_gazete_info_from_page=resmi_gazete_info_from_page,
339 | markdown_chunk=markdown_chunk,
340 | current_page=current_page_clamped,
341 | total_pages=total_pages,
342 | is_paginated=(total_pages > 1)
343 | )
344 |
345 | except httpx.RequestError as e:
346 | logger.error(f"AnayasaBireyselBasvuruApiClient: HTTP error fetching Bireysel Başvuru document from {full_url}: {e}")
347 | raise
348 | except Exception as e:
349 | logger.error(f"AnayasaBireyselBasvuruApiClient: General error processing Bireysel Başvuru document from {full_url}: {e}")
350 | raise
351 |
352 | async def close_client_session(self):
353 | if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
354 | await self.http_client.aclose()
355 | logger.info("AnayasaBireyselBasvuruApiClient: HTTP client session closed.")
```
--------------------------------------------------------------------------------
/kik_mcp_module/client_v2.py:
--------------------------------------------------------------------------------
```python
1 | # kik_mcp_module/client_v2.py
2 |
3 | import httpx
4 | import requests
5 | import logging
6 | import uuid
7 | import base64
8 | import ssl
9 | from typing import Optional
10 | from datetime import datetime
11 |
12 | from .models_v2 import (
13 | KikV2DecisionType, KikV2SearchPayload, KikV2SearchPayloadDk, KikV2SearchPayloadMk,
14 | KikV2RequestData, KikV2QueryRequest, KikV2KeyValuePair,
15 | KikV2SearchResponse, KikV2SearchResponseDk, KikV2SearchResponseMk,
16 | KikV2SearchResult, KikV2CompactDecision, KikV2DocumentMarkdown
17 | )
18 |
19 | logger = logging.getLogger(__name__)
20 |
21 | class KikV2ApiClient:
22 | """
23 | New KIK v2 API Client for https://ekapv2.kik.gov.tr
24 |
25 | This client uses the modern JSON-based API endpoint that provides
26 | better structured data compared to the legacy form-based API.
27 | """
28 |
29 | BASE_URL = "https://ekapv2.kik.gov.tr"
30 |
31 | # Endpoint mappings for different decision types
32 | ENDPOINTS = {
33 | KikV2DecisionType.UYUSMAZLIK: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari",
34 | KikV2DecisionType.DUZENLEYICI: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk",
35 | KikV2DecisionType.MAHKEME: "/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk"
36 | }
37 |
38 | def __init__(self, request_timeout: float = 60.0):
39 | # Create SSL context with legacy server support
40 | ssl_context = ssl.create_default_context()
41 | ssl_context.check_hostname = False
42 | ssl_context.verify_mode = ssl.CERT_NONE
43 |
44 | # Enable legacy server connect option for older SSL implementations (Python 3.12+)
45 | if hasattr(ssl, 'OP_LEGACY_SERVER_CONNECT'):
46 | ssl_context.options |= ssl.OP_LEGACY_SERVER_CONNECT
47 |
48 | # Set broader cipher suite support including legacy ciphers
49 | ssl_context.set_ciphers('ALL:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
50 |
51 | self.http_client = httpx.AsyncClient(
52 | base_url=self.BASE_URL,
53 | verify=ssl_context,
54 | headers={
55 | "Accept": "application/json",
56 | "Accept-Language": "tr",
57 | "Content-Type": "application/json",
58 | "Origin": self.BASE_URL,
59 | "Referer": f"{self.BASE_URL}/sorgulamalar/kurul-kararlari",
60 | "Sec-Fetch-Dest": "empty",
61 | "Sec-Fetch-Mode": "cors",
62 | "Sec-Fetch-Site": "same-origin",
63 | "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",
64 | "api-version": "v1",
65 | "sec-ch-ua": '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
66 | "sec-ch-ua-mobile": "?0",
67 | "sec-ch-ua-platform": '"macOS"'
68 | },
69 | timeout=request_timeout
70 | )
71 |
72 | # Generate security headers (these might need to be updated based on API requirements)
73 | self.security_headers = self._generate_security_headers()
74 |
75 | def _generate_security_headers(self) -> dict:
76 | """
77 | Generate the custom security headers required by KIK v2 API.
78 | These headers appear to be for request validation/encryption.
79 | """
80 | # Generate a random GUID for each session
81 | request_guid = str(uuid.uuid4())
82 |
83 | # These are example values - in a real implementation, these might need
84 | # to be calculated based on the request content or session
85 | return {
86 | "X-Custom-Request-Guid": request_guid,
87 | "X-Custom-Request-R8id": "hwnOjsN8qdgtDw70x3sKkxab0rj2bQ8Uph4+C+oU+9AMmQqRN3eMOEEeet748DOf",
88 | "X-Custom-Request-Siv": "p2IQRTitF8z7I39nBjdAqA==",
89 | "X-Custom-Request-Ts": "1vB3Wwrt8YQ5U6t3XAzZ+Q=="
90 | }
91 |
92 | def _build_search_payload(self,
93 | decision_type: KikV2DecisionType,
94 | karar_metni: str = "",
95 | karar_no: str = "",
96 | basvuran: str = "",
97 | idare_adi: str = "",
98 | baslangic_tarihi: str = "",
99 | bitis_tarihi: str = ""):
100 | """Build the search payload for KIK v2 API."""
101 |
102 | key_value_pairs = []
103 |
104 | # Add non-empty search criteria
105 | if karar_metni:
106 | key_value_pairs.append(KikV2KeyValuePair(key="KararMetni", value=karar_metni))
107 |
108 | if karar_no:
109 | key_value_pairs.append(KikV2KeyValuePair(key="KararNo", value=karar_no))
110 |
111 | if basvuran:
112 | key_value_pairs.append(KikV2KeyValuePair(key="BasvuranAdi", value=basvuran))
113 |
114 | if idare_adi:
115 | key_value_pairs.append(KikV2KeyValuePair(key="IdareAdi", value=idare_adi))
116 |
117 | if baslangic_tarihi:
118 | key_value_pairs.append(KikV2KeyValuePair(key="BaslangicTarihi", value=baslangic_tarihi))
119 |
120 | if bitis_tarihi:
121 | key_value_pairs.append(KikV2KeyValuePair(key="BitisTarihi", value=bitis_tarihi))
122 |
123 | # If no search criteria provided, use a generic search
124 | if not key_value_pairs:
125 | key_value_pairs.append(KikV2KeyValuePair(key="KararMetni", value=""))
126 |
127 | query_request = KikV2QueryRequest(keyValueOfstringanyType=key_value_pairs)
128 | request_data = KikV2RequestData(keyValuePairs=query_request)
129 |
130 | # Return appropriate payload based on decision type
131 | if decision_type == KikV2DecisionType.UYUSMAZLIK:
132 | return KikV2SearchPayload(sorgulaKurulKararlari=request_data)
133 | elif decision_type == KikV2DecisionType.DUZENLEYICI:
134 | return KikV2SearchPayloadDk(sorgulaKurulKararlariDk=request_data)
135 | elif decision_type == KikV2DecisionType.MAHKEME:
136 | return KikV2SearchPayloadMk(sorgulaKurulKararlariMk=request_data)
137 | else:
138 | raise ValueError(f"Unsupported decision type: {decision_type}")
139 |
140 | async def search_decisions(self,
141 | decision_type: KikV2DecisionType = KikV2DecisionType.UYUSMAZLIK,
142 | karar_metni: str = "",
143 | karar_no: str = "",
144 | basvuran: str = "",
145 | idare_adi: str = "",
146 | baslangic_tarihi: str = "",
147 | bitis_tarihi: str = "") -> KikV2SearchResult:
148 | """
149 | Search KIK decisions using the v2 API.
150 |
151 | Args:
152 | decision_type: Type of decision to search (uyusmazlik/duzenleyici/mahkeme)
153 | karar_metni: Decision text search
154 | karar_no: Decision number (e.g., "2025/UH.II-1801")
155 | basvuran: Applicant name
156 | idare_adi: Administration name
157 | baslangic_tarihi: Start date (YYYY-MM-DD format)
158 | bitis_tarihi: End date (YYYY-MM-DD format)
159 |
160 | Returns:
161 | KikV2SearchResult with compact decision list
162 | """
163 |
164 | logger.info(f"KikV2ApiClient: Searching {decision_type.value} decisions with criteria - karar_metni: '{karar_metni}', karar_no: '{karar_no}', basvuran: '{basvuran}'")
165 |
166 | try:
167 | # Build request payload
168 | payload = self._build_search_payload(
169 | decision_type=decision_type,
170 | karar_metni=karar_metni,
171 | karar_no=karar_no,
172 | basvuran=basvuran,
173 | idare_adi=idare_adi,
174 | baslangic_tarihi=baslangic_tarihi,
175 | bitis_tarihi=bitis_tarihi
176 | )
177 |
178 | # Update security headers for this request
179 | headers = {**self.http_client.headers, **self._generate_security_headers()}
180 |
181 | # Get the appropriate endpoint for this decision type
182 | endpoint = self.ENDPOINTS[decision_type]
183 |
184 | # Make API request
185 | response = await self.http_client.post(
186 | endpoint,
187 | json=payload.model_dump(),
188 | headers=headers
189 | )
190 |
191 | response.raise_for_status()
192 | response_data = response.json()
193 |
194 | logger.debug(f"KikV2ApiClient: Raw API response structure: {type(response_data)}")
195 |
196 | # Parse the API response based on decision type
197 | if decision_type == KikV2DecisionType.UYUSMAZLIK:
198 | api_response = KikV2SearchResponse(**response_data)
199 | result_data = api_response.SorgulaKurulKararlariResponse.SorgulaKurulKararlariResult
200 | elif decision_type == KikV2DecisionType.DUZENLEYICI:
201 | api_response = KikV2SearchResponseDk(**response_data)
202 | result_data = api_response.SorgulaKurulKararlariDkResponse.SorgulaKurulKararlariDkResult
203 | elif decision_type == KikV2DecisionType.MAHKEME:
204 | api_response = KikV2SearchResponseMk(**response_data)
205 | result_data = api_response.SorgulaKurulKararlariMkResponse.SorgulaKurulKararlariMkResult
206 | else:
207 | raise ValueError(f"Unsupported decision type: {decision_type}")
208 |
209 | # Check for API errors
210 | if result_data.hataKodu and result_data.hataKodu != "0":
211 | logger.warning(f"KikV2ApiClient: API returned error - Code: {result_data.hataKodu}, Message: {result_data.hataMesaji}")
212 | return KikV2SearchResult(
213 | decisions=[],
214 | total_records=0,
215 | page=1,
216 | error_code=result_data.hataKodu,
217 | error_message=result_data.hataMesaji
218 | )
219 |
220 | # Convert to compact format
221 | compact_decisions = []
222 | total_count = 0
223 |
224 | for decision_group in result_data.KurulKararTutanakDetayListesi:
225 | for decision_detail in decision_group.KurulKararTutanakDetayi:
226 | compact_decision = KikV2CompactDecision(
227 | kararNo=decision_detail.kararNo,
228 | kararTarihi=decision_detail.kararTarihi,
229 | basvuran=decision_detail.basvuran,
230 | idareAdi=decision_detail.idareAdi,
231 | basvuruKonusu=decision_detail.basvuruKonusu,
232 | gundemMaddesiId=decision_detail.gundemMaddesiId,
233 | decision_type=decision_type.value
234 | )
235 | compact_decisions.append(compact_decision)
236 | total_count += 1
237 |
238 | logger.info(f"KikV2ApiClient: Found {total_count} decisions")
239 |
240 | return KikV2SearchResult(
241 | decisions=compact_decisions,
242 | total_records=total_count,
243 | page=1,
244 | error_code="0",
245 | error_message=""
246 | )
247 |
248 | except httpx.HTTPStatusError as e:
249 | logger.error(f"KikV2ApiClient: HTTP error during search: {e.response.status_code} - {e.response.text}")
250 | return KikV2SearchResult(
251 | decisions=[],
252 | total_records=0,
253 | page=1,
254 | error_code="HTTP_ERROR",
255 | error_message=f"HTTP {e.response.status_code}: {e.response.text}"
256 | )
257 | except Exception as e:
258 | logger.error(f"KikV2ApiClient: Unexpected error during search: {str(e)}")
259 | return KikV2SearchResult(
260 | decisions=[],
261 | total_records=0,
262 | page=1,
263 | error_code="UNEXPECTED_ERROR",
264 | error_message=str(e)
265 | )
266 |
267 | async def get_document_markdown(self, document_id: str) -> KikV2DocumentMarkdown:
268 | """
269 | Get KİK decision document content in Markdown format.
270 |
271 | This method uses a two-step process:
272 | 1. Call GetSorgulamaUrl endpoint to get the actual document URL
273 | 2. Use Playwright to navigate to that URL and extract content
274 |
275 | Args:
276 | document_id: The gundemMaddesiId from search results
277 |
278 | Returns:
279 | KikV2DocumentMarkdown with document content converted to Markdown
280 | """
281 |
282 | logger.info(f"KikV2ApiClient: Getting document for ID: {document_id}")
283 |
284 | if not document_id or not document_id.strip():
285 | return KikV2DocumentMarkdown(
286 | document_id=document_id,
287 | kararNo="",
288 | markdown_content="",
289 | source_url="",
290 | error_message="Document ID is required"
291 | )
292 |
293 | try:
294 | # Step 1: Get the actual document URL using GetSorgulamaUrl endpoint
295 | logger.info(f"KikV2ApiClient: Step 1 - Getting document URL for ID: {document_id}")
296 |
297 | # Update security headers for this request
298 | headers = {**self.http_client.headers, **self._generate_security_headers()}
299 |
300 | # Call GetSorgulamaUrl to get the real document URL
301 | url_payload = {"sorguSayfaTipi": 2} # As shown in curl example
302 |
303 | url_response = await self.http_client.post(
304 | "/b_ihalearaclari/api/KurulKararlari/GetSorgulamaUrl",
305 | json=url_payload,
306 | headers=headers
307 | )
308 |
309 | url_response.raise_for_status()
310 | url_data = url_response.json()
311 |
312 | # Get the base document URL from API response
313 | base_document_url = url_data.get("sorgulamaUrl", "")
314 | if not base_document_url:
315 | return KikV2DocumentMarkdown(
316 | document_id=document_id,
317 | kararNo="",
318 | markdown_content="",
319 | source_url="",
320 | error_message="Could not get document URL from GetSorgulamaUrl API"
321 | )
322 |
323 | # Construct full document URL with the actual document ID
324 | document_url = f"{base_document_url}?KararId={document_id}"
325 | logger.info(f"KikV2ApiClient: Step 2 - Retrieved document URL: {document_url}")
326 |
327 | except Exception as e:
328 | logger.error(f"KikV2ApiClient: Error getting document URL for ID {document_id}: {str(e)}")
329 | # Fallback to old method if GetSorgulamaUrl fails
330 | document_url = f"https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId={document_id}"
331 | logger.info(f"KikV2ApiClient: Falling back to direct URL: {document_url}")
332 |
333 | try:
334 | # Step 2: Use Playwright to get the actual document content
335 | logger.info(f"KikV2ApiClient: Step 2 - Using Playwright to retrieve document from: {document_url}")
336 |
337 | try:
338 | from playwright.async_api import async_playwright
339 |
340 | async with async_playwright() as p:
341 | # Launch browser
342 | browser = await p.chromium.launch(
343 | headless=True,
344 | args=['--no-sandbox', '--disable-dev-shm-usage']
345 | )
346 |
347 | page = await browser.new_page(
348 | 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"
349 | )
350 |
351 | # Navigate to document page with longer timeout for JS loading
352 | await page.goto(document_url, wait_until="networkidle", timeout=15000)
353 |
354 | # Wait for the document content to load (KİK pages might need more time for JS execution)
355 | await page.wait_for_timeout(3000)
356 |
357 | # Wait for Angular/Zone.js to finish loading and document to be ready
358 | try:
359 | # Wait for Angular zone to be available (this JavaScript code you showed)
360 | await page.wait_for_function(
361 | "typeof Zone !== 'undefined' && Zone.current",
362 | timeout=10000
363 | )
364 |
365 | # Wait for network to be idle after Angular bootstrap
366 | await page.wait_for_load_state("networkidle", timeout=10000)
367 |
368 | # Wait for specific document content to appear
369 | await page.wait_for_function(
370 | """
371 | document.body.textContent.length > 5000 &&
372 | (document.body.textContent.includes('Karar') ||
373 | document.body.textContent.includes('KURUL') ||
374 | document.body.textContent.includes('Gündem') ||
375 | document.body.textContent.includes('Toplantı'))
376 | """,
377 | timeout=15000
378 | )
379 |
380 | logger.info("KikV2ApiClient: Angular document content loaded successfully")
381 |
382 | except Exception as e:
383 | logger.warning(f"KikV2ApiClient: Angular content loading timed out, proceeding anyway: {str(e)}")
384 | # Give a bit more time for any remaining content to load
385 | await page.wait_for_timeout(5000)
386 |
387 | # Get page content
388 | html_content = await page.content()
389 |
390 | await browser.close()
391 |
392 | logger.info(f"KikV2ApiClient: Retrieved content via Playwright, length: {len(html_content)}")
393 |
394 | except ImportError:
395 | logger.info("KikV2ApiClient: Playwright not available, falling back to httpx")
396 | # Fallback to httpx
397 | response = await self.http_client.get(
398 | document_url,
399 | headers={
400 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
401 | "Accept-Language": "tr,en-US;q=0.5",
402 | "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",
403 | "Referer": "https://ekap.kik.gov.tr/",
404 | "Cache-Control": "no-cache"
405 | }
406 | )
407 | response.raise_for_status()
408 | html_content = response.text
409 |
410 | # Convert HTML to Markdown using MarkItDown with BytesIO
411 | try:
412 | from markitdown import MarkItDown
413 | from io import BytesIO
414 |
415 | md = MarkItDown()
416 | html_bytes = html_content.encode('utf-8')
417 | html_stream = BytesIO(html_bytes)
418 |
419 | result = md.convert_stream(html_stream, file_extension=".html")
420 | markdown_content = result.text_content
421 |
422 | return KikV2DocumentMarkdown(
423 | document_id=document_id,
424 | kararNo="",
425 | markdown_content=markdown_content,
426 | source_url=document_url,
427 | error_message=""
428 | )
429 |
430 | except ImportError:
431 | return KikV2DocumentMarkdown(
432 | document_id=document_id,
433 | kararNo="",
434 | markdown_content="MarkItDown library not available",
435 | source_url=document_url,
436 | error_message="MarkItDown library not installed"
437 | )
438 |
439 | except Exception as e:
440 | logger.error(f"KikV2ApiClient: Error retrieving document {document_id}: {str(e)}")
441 | return KikV2DocumentMarkdown(
442 | document_id=document_id,
443 | kararNo="",
444 | markdown_content="",
445 | source_url=document_url,
446 | error_message=str(e)
447 | )
448 |
449 | async def close_client_session(self):
450 | """Close HTTP client session."""
451 | await self.http_client.aclose()
452 | logger.info("KikV2ApiClient: HTTP client session closed.")
```
--------------------------------------------------------------------------------
/anayasa_mcp_module/client.py:
--------------------------------------------------------------------------------
```python
1 | # anayasa_mcp_module/client.py
2 | # This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
3 |
4 | import httpx
5 | from bs4 import BeautifulSoup
6 | from typing import Dict, Any, List, Optional, Tuple
7 | import logging
8 | import html
9 | import re
10 | import io
11 | from urllib.parse import urlencode, urljoin, quote
12 | from markitdown import MarkItDown
13 | import math # For math.ceil for pagination
14 |
15 | from .models import (
16 | AnayasaNormDenetimiSearchRequest,
17 | AnayasaDecisionSummary,
18 | AnayasaReviewedNormInfo,
19 | AnayasaSearchResult,
20 | AnayasaDocumentMarkdown, # Model for Norm Denetimi document
21 | )
22 |
23 | logger = logging.getLogger(__name__)
24 | if not logger.hasHandlers():
25 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
26 |
27 | class AnayasaMahkemesiApiClient:
28 | BASE_URL = "https://normkararlarbilgibankasi.anayasa.gov.tr"
29 | SEARCH_PATH_SEGMENT = "Ara"
30 | DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
31 |
32 | def __init__(self, request_timeout: float = 60.0):
33 | self.http_client = httpx.AsyncClient(
34 | base_url=self.BASE_URL,
35 | headers={
36 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
37 | "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
38 | "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"
39 | },
40 | timeout=request_timeout,
41 | verify=True,
42 | follow_redirects=True
43 | )
44 |
45 | def _build_search_query_params_for_aym(self, params: AnayasaNormDenetimiSearchRequest) -> List[Tuple[str, str]]:
46 | query_params: List[Tuple[str, str]] = []
47 | if params.keywords_all:
48 | for kw in params.keywords_all: query_params.append(("KelimeAra[]", kw))
49 | if params.keywords_any:
50 | for kw in params.keywords_any: query_params.append(("HerhangiBirKelimeAra[]", kw))
51 | if params.keywords_exclude:
52 | for kw in params.keywords_exclude: query_params.append(("BulunmayanKelimeAra[]", kw))
53 | if params.period and params.period and params.period != "ALL": query_params.append(("Donemler_id", params.period))
54 | if params.case_number_esas: query_params.append(("EsasNo", params.case_number_esas))
55 | if params.decision_number_karar: query_params.append(("KararNo", params.decision_number_karar))
56 | if params.first_review_date_start: query_params.append(("IlkIncelemeTarihiIlk", params.first_review_date_start))
57 | if params.first_review_date_end: query_params.append(("IlkIncelemeTarihiSon", params.first_review_date_end))
58 | if params.decision_date_start: query_params.append(("KararTarihiIlk", params.decision_date_start))
59 | if params.decision_date_end: query_params.append(("KararTarihiSon", params.decision_date_end))
60 | if params.application_type and params.application_type and params.application_type != "ALL": query_params.append(("BasvuruTurler_id", params.application_type))
61 | if params.applicant_general_name: query_params.append(("BasvuranGeneller_id", params.applicant_general_name))
62 | if params.applicant_specific_name: query_params.append(("BasvuranOzeller_id", params.applicant_specific_name))
63 | if params.attending_members_names:
64 | for name in params.attending_members_names: query_params.append(("Uyeler_id[]", name))
65 | if params.rapporteur_name: query_params.append(("Raportorler_id", params.rapporteur_name))
66 | if params.norm_type and params.norm_type and params.norm_type != "ALL": query_params.append(("NormunTurler_id", params.norm_type))
67 | if params.norm_id_or_name: query_params.append(("NormunNumarasiAdlar_id", params.norm_id_or_name))
68 | if params.norm_article: query_params.append(("NormunMaddeNumarasi", params.norm_article))
69 | if params.review_outcomes:
70 | for outcome_val in params.review_outcomes:
71 | if outcome_val and outcome_val != "ALL": query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_val))
72 | if params.reason_for_final_outcome and params.reason_for_final_outcome and params.reason_for_final_outcome != "ALL":
73 | query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome))
74 | if params.basis_constitution_article_numbers:
75 | for article_no in params.basis_constitution_article_numbers: query_params.append(("DayanakHukmu[]", article_no))
76 | if params.official_gazette_date_start: query_params.append(("ResmiGazeteTarihiIlk", params.official_gazette_date_start))
77 | if params.official_gazette_date_end: query_params.append(("ResmiGazeteTarihiSon", params.official_gazette_date_end))
78 | if params.official_gazette_number_start: query_params.append(("ResmiGazeteSayisiIlk", params.official_gazette_number_start))
79 | if params.official_gazette_number_end: query_params.append(("ResmiGazeteSayisiSon", params.official_gazette_number_end))
80 | if params.has_press_release and params.has_press_release and params.has_press_release != "ALL": query_params.append(("BasinDuyurusu", params.has_press_release))
81 | if params.has_dissenting_opinion and params.has_dissenting_opinion and params.has_dissenting_opinion != "ALL": query_params.append(("KarsiOy", params.has_dissenting_opinion))
82 | if params.has_different_reasoning and params.has_different_reasoning and params.has_different_reasoning != "ALL": query_params.append(("FarkliGerekce", params.has_different_reasoning))
83 |
84 | # Add pagination and sorting parameters as query params instead of URL path
85 | if params.results_per_page and params.results_per_page != 10:
86 | query_params.append(("SatirSayisi", str(params.results_per_page)))
87 |
88 | if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi":
89 | query_params.append(("Siralama", params.sort_by_criteria))
90 |
91 | if params.page_to_fetch and params.page_to_fetch > 1:
92 | query_params.append(("page", str(params.page_to_fetch)))
93 | return query_params
94 |
95 | async def search_norm_denetimi_decisions(
96 | self,
97 | params: AnayasaNormDenetimiSearchRequest
98 | ) -> AnayasaSearchResult:
99 | # Use simple /Ara endpoint - the complex path structure seems to cause 404s
100 | request_path = f"/{self.SEARCH_PATH_SEGMENT}"
101 |
102 | final_query_params = self._build_search_query_params_for_aym(params)
103 | logger.info(f"AnayasaMahkemesiApiClient: Performing Norm Denetimi search. Path: {request_path}, Params: {final_query_params}")
104 |
105 | try:
106 | response = await self.http_client.get(request_path, params=final_query_params)
107 | response.raise_for_status()
108 | html_content = response.text
109 | except httpx.RequestError as e:
110 | logger.error(f"AnayasaMahkemesiApiClient: HTTP request error during Norm Denetimi search: {e}")
111 | raise
112 | except Exception as e:
113 | logger.error(f"AnayasaMahkemesiApiClient: Error processing Norm Denetimi search request: {e}")
114 | raise
115 |
116 | soup = BeautifulSoup(html_content, 'html.parser')
117 |
118 | total_records = None
119 | bulunan_karar_div = soup.find("div", class_="bulunankararsayisi")
120 | if not bulunan_karar_div: # Fallback for mobile view
121 | bulunan_karar_div = soup.find("div", class_="bulunankararsayisiMobil")
122 |
123 | if bulunan_karar_div:
124 | match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True))
125 | if match_records:
126 | total_records = int(match_records.group(1))
127 |
128 | processed_decisions: List[AnayasaDecisionSummary] = []
129 | decision_divs = soup.find_all("div", class_="birkarar")
130 |
131 | for decision_div in decision_divs:
132 | link_tag = decision_div.find("a", href=True)
133 | doc_url_path = link_tag['href'] if link_tag else None
134 | decision_page_url_str = urljoin(self.BASE_URL, doc_url_path) if doc_url_path else None
135 |
136 | title_div = decision_div.find("div", class_="bkararbaslik")
137 | ek_no_text_raw = title_div.get_text(strip=True, separator=" ").replace('\xa0', ' ') if title_div else ""
138 | ek_no_match = re.search(r"(E\.\s*\d+/\d+\s*,\s*K\.\s*\d+/\d+)", ek_no_text_raw)
139 | ek_no_text = ek_no_match.group(1) if ek_no_match else ek_no_text_raw.split("Sayılı Karar")[0].strip()
140 |
141 | keyword_count_div = title_div.find("div", class_="BulunanKelimeSayisi") if title_div else None
142 | keyword_count_text = keyword_count_div.get_text(strip=True).replace("Bulunan Kelime Sayısı", "").strip() if keyword_count_div else None
143 | keyword_count = int(keyword_count_text) if keyword_count_text and keyword_count_text.isdigit() else None
144 |
145 | info_div = decision_div.find("div", class_="kararbilgileri")
146 | info_parts = [part.strip() for part in info_div.get_text(separator="|").split("|")] if info_div else []
147 |
148 | app_type_summary = info_parts[0] if len(info_parts) > 0 else None
149 | applicant_summary = info_parts[1] if len(info_parts) > 1 else None
150 | outcome_summary = info_parts[2] if len(info_parts) > 2 else None
151 | dec_date_raw = info_parts[3] if len(info_parts) > 3 else None
152 | decision_date_summary = dec_date_raw.replace("Karar Tarihi:", "").strip() if dec_date_raw else None
153 |
154 | reviewed_norms_list: List[AnayasaReviewedNormInfo] = []
155 | details_table_container = decision_div.find_next_sibling("div", class_=re.compile(r"col-sm-12")) # The details table is in a sibling div
156 | if details_table_container:
157 | details_table = details_table_container.find("table", class_="table")
158 | if details_table and details_table.find("tbody"):
159 | for row in details_table.find("tbody").find_all("tr"):
160 | cells = row.find_all("td")
161 | if len(cells) == 6:
162 | reviewed_norms_list.append(AnayasaReviewedNormInfo(
163 | norm_name_or_number=cells[0].get_text(strip=True) or None,
164 | article_number=cells[1].get_text(strip=True) or None,
165 | review_type_and_outcome=cells[2].get_text(strip=True) or None,
166 | outcome_reason=cells[3].get_text(strip=True) or None,
167 | basis_constitution_articles_cited=[a.strip() for a in cells[4].get_text(strip=True).split(',') if a.strip()] if cells[4].get_text(strip=True) else [],
168 | postponement_period=cells[5].get_text(strip=True) or None
169 | ))
170 |
171 | processed_decisions.append(AnayasaDecisionSummary(
172 | decision_reference_no=ek_no_text,
173 | decision_page_url=decision_page_url_str,
174 | keywords_found_count=keyword_count,
175 | application_type_summary=app_type_summary,
176 | applicant_summary=applicant_summary,
177 | decision_outcome_summary=outcome_summary,
178 | decision_date_summary=decision_date_summary,
179 | reviewed_norms=reviewed_norms_list
180 | ))
181 |
182 | return AnayasaSearchResult(
183 | decisions=processed_decisions,
184 | total_records_found=total_records,
185 | retrieved_page_number=params.page_to_fetch
186 | )
187 |
188 | def _convert_html_to_markdown_norm_denetimi(self, full_decision_html_content: str) -> Optional[str]:
189 | """Converts direct HTML content from an Anayasa Mahkemesi Norm Denetimi decision page to Markdown."""
190 | if not full_decision_html_content:
191 | return None
192 |
193 | processed_html = html.unescape(full_decision_html_content)
194 | soup = BeautifulSoup(processed_html, "html.parser")
195 | html_input_for_markdown = ""
196 |
197 | karar_tab_content = soup.find("div", id="Karar") # "KARAR" tab content
198 | if karar_tab_content:
199 | karar_metni_div = karar_tab_content.find("div", class_="KararMetni")
200 | if karar_metni_div:
201 | # Remove scripts and styles
202 | for script_tag in karar_metni_div.find_all("script"): script_tag.decompose()
203 | for style_tag in karar_metni_div.find_all("style"): style_tag.decompose()
204 | # Remove "Künye Kopyala" button and other non-content divs
205 | for item_div in karar_metni_div.find_all("div", class_="item col-sm-12"): item_div.decompose()
206 | for modal_div in karar_metni_div.find_all("div", class_="modal fade"): modal_div.decompose() # If any modals
207 |
208 | word_section = karar_metni_div.find("div", class_="WordSection1")
209 | html_input_for_markdown = str(word_section) if word_section else str(karar_metni_div)
210 | else:
211 | html_input_for_markdown = str(karar_tab_content)
212 | else:
213 | # Fallback if specific structure is not found
214 | word_section_fallback = soup.find("div", class_="WordSection1")
215 | if word_section_fallback:
216 | html_input_for_markdown = str(word_section_fallback)
217 | else:
218 | # Last resort: use the whole body or the raw HTML
219 | body_tag = soup.find("body")
220 | html_input_for_markdown = str(body_tag) if body_tag else processed_html
221 |
222 | markdown_text = None
223 | try:
224 | # Ensure the content is wrapped in basic HTML structure if it's not already
225 | if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
226 | html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
227 | else:
228 | html_content = html_input_for_markdown
229 |
230 | # Convert HTML string to bytes and create BytesIO stream
231 | html_bytes = html_content.encode('utf-8')
232 | html_stream = io.BytesIO(html_bytes)
233 |
234 | # Pass BytesIO stream to MarkItDown to avoid temp file creation
235 | md_converter = MarkItDown()
236 | conversion_result = md_converter.convert(html_stream)
237 | markdown_text = conversion_result.text_content
238 | except Exception as e:
239 | logger.error(f"AnayasaMahkemesiApiClient: MarkItDown conversion error: {e}")
240 | return markdown_text
241 |
242 | async def get_decision_document_as_markdown(
243 | self,
244 | document_url: str,
245 | page_number: int = 1
246 | ) -> AnayasaDocumentMarkdown:
247 | """
248 | Retrieves a specific Anayasa Mahkemesi (Norm Denetimi) decision,
249 | converts its content to Markdown, and returns the requested page/chunk.
250 | """
251 | full_url = urljoin(self.BASE_URL, document_url) if not document_url.startswith("http") else document_url
252 | logger.info(f"AnayasaMahkemesiApiClient: Fetching Norm Denetimi document for Markdown (page {page_number}) from URL: {full_url}")
253 |
254 | decision_ek_no_from_page = None
255 | decision_date_from_page = None
256 | official_gazette_from_page = None
257 |
258 | try:
259 | # Use a new client instance for document fetching if headers/timeout needs to be different,
260 | # or reuse self.http_client if settings are compatible. For now, self.http_client.
261 | get_response = await self.http_client.get(full_url, headers={"Accept": "text/html"})
262 | get_response.raise_for_status()
263 | html_content_from_api = get_response.text
264 |
265 | if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
266 | logger.warning(f"AnayasaMahkemesiApiClient: Received empty or non-string HTML from URL {full_url}.")
267 | return AnayasaDocumentMarkdown(
268 | source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
269 | )
270 |
271 | # Extract metadata from the page content (E.K. No, Date, RG)
272 | soup = BeautifulSoup(html_content_from_api, "html.parser")
273 | karar_metni_div = soup.find("div", class_="KararMetni") # Usually within div#Karar
274 | if not karar_metni_div: # Fallback if not in KararMetni
275 | karar_metni_div = soup.find("div", class_="WordSection1")
276 |
277 | # Initialize with empty string defaults
278 | decision_ek_no_from_page = ""
279 | decision_date_from_page = ""
280 | official_gazette_from_page = ""
281 |
282 | if karar_metni_div:
283 | # Attempt to find E.K. No (Esas No, Karar No)
284 | # Norm Denetimi pages often have this in bold <p> tags directly or in the WordSection1
285 | # Look for patterns like "Esas No.: YYYY/NN" and "Karar No.: YYYY/NN"
286 |
287 | esas_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Esas No.:" in tag.find("b").get_text())
288 | karar_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar No.:" in tag.find("b").get_text())
289 | karar_tarihi_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar tarihi:" in tag.find("b").get_text()) # Less common on Norm pages
290 | resmi_gazete_tag = karar_metni_div.find(lambda tag: tag.name == "p" and ("Resmî Gazete tarih ve sayısı:" in tag.get_text() or "Resmi Gazete tarih/sayı:" in tag.get_text()))
291 |
292 |
293 | if esas_no_tag and esas_no_tag.find("b") and karar_no_tag and karar_no_tag.find("b"):
294 | esas_str = esas_no_tag.find("b").get_text(strip=True).replace('Esas No.:', '').strip()
295 | karar_str = karar_no_tag.find("b").get_text(strip=True).replace('Karar No.:', '').strip()
296 | decision_ek_no_from_page = f"E.{esas_str}, K.{karar_str}"
297 |
298 | if karar_tarihi_tag and karar_tarihi_tag.find("b"):
299 | decision_date_from_page = karar_tarihi_tag.find("b").get_text(strip=True).replace("Karar tarihi:", "").strip()
300 | elif karar_metni_div: # Fallback for Karar Tarihi if not in specific tag
301 | date_match = re.search(r"Karar Tarihi\s*:\s*([\d\.]+)", karar_metni_div.get_text()) # Norm pages often use DD.MM.YYYY
302 | if date_match: decision_date_from_page = date_match.group(1).strip()
303 |
304 |
305 | if resmi_gazete_tag:
306 | # Try to get the bold part first if it exists
307 | bold_rg_tag = resmi_gazete_tag.find("b")
308 | rg_text_content = bold_rg_tag.get_text(strip=True) if bold_rg_tag else resmi_gazete_tag.get_text(strip=True)
309 | official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip()
310 |
311 |
312 | full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api)
313 |
314 | if not full_markdown_content:
315 | return AnayasaDocumentMarkdown(
316 | source_url=full_url,
317 | decision_reference_no_from_page=decision_ek_no_from_page,
318 | decision_date_from_page=decision_date_from_page,
319 | official_gazette_info_from_page=official_gazette_from_page,
320 | markdown_chunk=None,
321 | current_page=page_number,
322 | total_pages=0,
323 | is_paginated=False
324 | )
325 |
326 | content_length = len(full_markdown_content)
327 | total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
328 | if total_pages == 0: total_pages = 1
329 |
330 | current_page_clamped = max(1, min(page_number, total_pages))
331 | start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
332 | end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
333 | markdown_chunk = full_markdown_content[start_index:end_index]
334 |
335 | return AnayasaDocumentMarkdown(
336 | source_url=full_url,
337 | decision_reference_no_from_page=decision_ek_no_from_page,
338 | decision_date_from_page=decision_date_from_page,
339 | official_gazette_info_from_page=official_gazette_from_page,
340 | markdown_chunk=markdown_chunk,
341 | current_page=current_page_clamped,
342 | total_pages=total_pages,
343 | is_paginated=(total_pages > 1)
344 | )
345 |
346 | except httpx.RequestError as e:
347 | logger.error(f"AnayasaMahkemesiApiClient: HTTP error fetching Norm Denetimi document from {full_url}: {e}")
348 | raise
349 | except Exception as e:
350 | logger.error(f"AnayasaMahkemesiApiClient: General error processing Norm Denetimi document from {full_url}: {e}")
351 | raise
352 |
353 | async def close_client_session(self):
354 | if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
355 | await self.http_client.aclose()
356 | logger.info("AnayasaMahkemesiApiClient (Norm Denetimi): HTTP client session closed.")
```
--------------------------------------------------------------------------------
/saidsurucu-yargi-mcp-f5fa007/anayasa_mcp_module/client.py:
--------------------------------------------------------------------------------
```python
1 | # anayasa_mcp_module/client.py
2 | # This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
3 |
4 | import httpx
5 | from bs4 import BeautifulSoup
6 | from typing import Dict, Any, List, Optional, Tuple
7 | import logging
8 | import html
9 | import re
10 | import io
11 | from urllib.parse import urlencode, urljoin, quote
12 | from markitdown import MarkItDown
13 | import math # For math.ceil for pagination
14 |
15 | from .models import (
16 | AnayasaNormDenetimiSearchRequest,
17 | AnayasaDecisionSummary,
18 | AnayasaReviewedNormInfo,
19 | AnayasaSearchResult,
20 | AnayasaDocumentMarkdown, # Model for Norm Denetimi document
21 | )
22 |
23 | logger = logging.getLogger(__name__)
24 | if not logger.hasHandlers():
25 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
26 |
27 | class AnayasaMahkemesiApiClient:
28 | BASE_URL = "https://normkararlarbilgibankasi.anayasa.gov.tr"
29 | SEARCH_PATH_SEGMENT = "Ara"
30 | DOCUMENT_MARKDOWN_CHUNK_SIZE = 5000 # Character limit per page
31 |
32 | def __init__(self, request_timeout: float = 60.0):
33 | self.http_client = httpx.AsyncClient(
34 | base_url=self.BASE_URL,
35 | headers={
36 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
37 | "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
38 | "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"
39 | },
40 | timeout=request_timeout,
41 | verify=True,
42 | follow_redirects=True
43 | )
44 |
45 | def _build_search_query_params_for_aym(self, params: AnayasaNormDenetimiSearchRequest) -> List[Tuple[str, str]]:
46 | query_params: List[Tuple[str, str]] = []
47 | if params.keywords_all:
48 | for kw in params.keywords_all: query_params.append(("KelimeAra[]", kw))
49 | if params.keywords_any:
50 | for kw in params.keywords_any: query_params.append(("HerhangiBirKelimeAra[]", kw))
51 | if params.keywords_exclude:
52 | for kw in params.keywords_exclude: query_params.append(("BulunmayanKelimeAra[]", kw))
53 | if params.period and params.period and params.period != "ALL": query_params.append(("Donemler_id", params.period))
54 | if params.case_number_esas: query_params.append(("EsasNo", params.case_number_esas))
55 | if params.decision_number_karar: query_params.append(("KararNo", params.decision_number_karar))
56 | if params.first_review_date_start: query_params.append(("IlkIncelemeTarihiIlk", params.first_review_date_start))
57 | if params.first_review_date_end: query_params.append(("IlkIncelemeTarihiSon", params.first_review_date_end))
58 | if params.decision_date_start: query_params.append(("KararTarihiIlk", params.decision_date_start))
59 | if params.decision_date_end: query_params.append(("KararTarihiSon", params.decision_date_end))
60 | if params.application_type and params.application_type and params.application_type != "ALL": query_params.append(("BasvuruTurler_id", params.application_type))
61 | if params.applicant_general_name: query_params.append(("BasvuranGeneller_id", params.applicant_general_name))
62 | if params.applicant_specific_name: query_params.append(("BasvuranOzeller_id", params.applicant_specific_name))
63 | if params.attending_members_names:
64 | for name in params.attending_members_names: query_params.append(("Uyeler_id[]", name))
65 | if params.rapporteur_name: query_params.append(("Raportorler_id", params.rapporteur_name))
66 | if params.norm_type and params.norm_type and params.norm_type != "ALL": query_params.append(("NormunTurler_id", params.norm_type))
67 | if params.norm_id_or_name: query_params.append(("NormunNumarasiAdlar_id", params.norm_id_or_name))
68 | if params.norm_article: query_params.append(("NormunMaddeNumarasi", params.norm_article))
69 | if params.review_outcomes:
70 | for outcome_val in params.review_outcomes:
71 | if outcome_val and outcome_val != "ALL": query_params.append(("IncelemeTuruKararSonuclar_id[]", outcome_val))
72 | if params.reason_for_final_outcome and params.reason_for_final_outcome and params.reason_for_final_outcome != "ALL":
73 | query_params.append(("KararSonucununGerekcesi", params.reason_for_final_outcome))
74 | if params.basis_constitution_article_numbers:
75 | for article_no in params.basis_constitution_article_numbers: query_params.append(("DayanakHukmu[]", article_no))
76 | if params.official_gazette_date_start: query_params.append(("ResmiGazeteTarihiIlk", params.official_gazette_date_start))
77 | if params.official_gazette_date_end: query_params.append(("ResmiGazeteTarihiSon", params.official_gazette_date_end))
78 | if params.official_gazette_number_start: query_params.append(("ResmiGazeteSayisiIlk", params.official_gazette_number_start))
79 | if params.official_gazette_number_end: query_params.append(("ResmiGazeteSayisiSon", params.official_gazette_number_end))
80 | if params.has_press_release and params.has_press_release and params.has_press_release != "ALL": query_params.append(("BasinDuyurusu", params.has_press_release))
81 | if params.has_dissenting_opinion and params.has_dissenting_opinion and params.has_dissenting_opinion != "ALL": query_params.append(("KarsiOy", params.has_dissenting_opinion))
82 | if params.has_different_reasoning and params.has_different_reasoning and params.has_different_reasoning != "ALL": query_params.append(("FarkliGerekce", params.has_different_reasoning))
83 |
84 | # Add pagination and sorting parameters as query params instead of URL path
85 | if params.results_per_page and params.results_per_page != 10:
86 | query_params.append(("SatirSayisi", str(params.results_per_page)))
87 |
88 | if params.sort_by_criteria and params.sort_by_criteria != "KararTarihi":
89 | query_params.append(("Siralama", params.sort_by_criteria))
90 |
91 | if params.page_to_fetch and params.page_to_fetch > 1:
92 | query_params.append(("page", str(params.page_to_fetch)))
93 | return query_params
94 |
95 | async def search_norm_denetimi_decisions(
96 | self,
97 | params: AnayasaNormDenetimiSearchRequest
98 | ) -> AnayasaSearchResult:
99 | # Use simple /Ara endpoint - the complex path structure seems to cause 404s
100 | request_path = f"/{self.SEARCH_PATH_SEGMENT}"
101 |
102 | final_query_params = self._build_search_query_params_for_aym(params)
103 | logger.info(f"AnayasaMahkemesiApiClient: Performing Norm Denetimi search. Path: {request_path}, Params: {final_query_params}")
104 |
105 | try:
106 | response = await self.http_client.get(request_path, params=final_query_params)
107 | response.raise_for_status()
108 | html_content = response.text
109 | except httpx.RequestError as e:
110 | logger.error(f"AnayasaMahkemesiApiClient: HTTP request error during Norm Denetimi search: {e}")
111 | raise
112 | except Exception as e:
113 | logger.error(f"AnayasaMahkemesiApiClient: Error processing Norm Denetimi search request: {e}")
114 | raise
115 |
116 | soup = BeautifulSoup(html_content, 'html.parser')
117 |
118 | total_records = None
119 | bulunan_karar_div = soup.find("div", class_="bulunankararsayisi")
120 | if not bulunan_karar_div: # Fallback for mobile view
121 | bulunan_karar_div = soup.find("div", class_="bulunankararsayisiMobil")
122 |
123 | if bulunan_karar_div:
124 | match_records = re.search(r'(\d+)\s*Karar Bulundu', bulunan_karar_div.get_text(strip=True))
125 | if match_records:
126 | total_records = int(match_records.group(1))
127 |
128 | processed_decisions: List[AnayasaDecisionSummary] = []
129 | decision_divs = soup.find_all("div", class_="birkarar")
130 |
131 | for decision_div in decision_divs:
132 | link_tag = decision_div.find("a", href=True)
133 | doc_url_path = link_tag['href'] if link_tag else None
134 | decision_page_url_str = urljoin(self.BASE_URL, doc_url_path) if doc_url_path else None
135 |
136 | title_div = decision_div.find("div", class_="bkararbaslik")
137 | ek_no_text_raw = title_div.get_text(strip=True, separator=" ").replace('\xa0', ' ') if title_div else ""
138 | ek_no_match = re.search(r"(E\.\s*\d+/\d+\s*,\s*K\.\s*\d+/\d+)", ek_no_text_raw)
139 | ek_no_text = ek_no_match.group(1) if ek_no_match else ek_no_text_raw.split("Sayılı Karar")[0].strip()
140 |
141 | keyword_count_div = title_div.find("div", class_="BulunanKelimeSayisi") if title_div else None
142 | keyword_count_text = keyword_count_div.get_text(strip=True).replace("Bulunan Kelime Sayısı", "").strip() if keyword_count_div else None
143 | keyword_count = int(keyword_count_text) if keyword_count_text and keyword_count_text.isdigit() else None
144 |
145 | info_div = decision_div.find("div", class_="kararbilgileri")
146 | info_parts = [part.strip() for part in info_div.get_text(separator="|").split("|")] if info_div else []
147 |
148 | app_type_summary = info_parts[0] if len(info_parts) > 0 else None
149 | applicant_summary = info_parts[1] if len(info_parts) > 1 else None
150 | outcome_summary = info_parts[2] if len(info_parts) > 2 else None
151 | dec_date_raw = info_parts[3] if len(info_parts) > 3 else None
152 | decision_date_summary = dec_date_raw.replace("Karar Tarihi:", "").strip() if dec_date_raw else None
153 |
154 | reviewed_norms_list: List[AnayasaReviewedNormInfo] = []
155 | details_table_container = decision_div.find_next_sibling("div", class_=re.compile(r"col-sm-12")) # The details table is in a sibling div
156 | if details_table_container:
157 | details_table = details_table_container.find("table", class_="table")
158 | if details_table and details_table.find("tbody"):
159 | for row in details_table.find("tbody").find_all("tr"):
160 | cells = row.find_all("td")
161 | if len(cells) == 6:
162 | reviewed_norms_list.append(AnayasaReviewedNormInfo(
163 | norm_name_or_number=cells[0].get_text(strip=True) or None,
164 | article_number=cells[1].get_text(strip=True) or None,
165 | review_type_and_outcome=cells[2].get_text(strip=True) or None,
166 | outcome_reason=cells[3].get_text(strip=True) or None,
167 | basis_constitution_articles_cited=[a.strip() for a in cells[4].get_text(strip=True).split(',') if a.strip()] if cells[4].get_text(strip=True) else [],
168 | postponement_period=cells[5].get_text(strip=True) or None
169 | ))
170 |
171 | processed_decisions.append(AnayasaDecisionSummary(
172 | decision_reference_no=ek_no_text,
173 | decision_page_url=decision_page_url_str,
174 | keywords_found_count=keyword_count,
175 | application_type_summary=app_type_summary,
176 | applicant_summary=applicant_summary,
177 | decision_outcome_summary=outcome_summary,
178 | decision_date_summary=decision_date_summary,
179 | reviewed_norms=reviewed_norms_list
180 | ))
181 |
182 | return AnayasaSearchResult(
183 | decisions=processed_decisions,
184 | total_records_found=total_records,
185 | retrieved_page_number=params.page_to_fetch
186 | )
187 |
188 | def _convert_html_to_markdown_norm_denetimi(self, full_decision_html_content: str) -> Optional[str]:
189 | """Converts direct HTML content from an Anayasa Mahkemesi Norm Denetimi decision page to Markdown."""
190 | if not full_decision_html_content:
191 | return None
192 |
193 | processed_html = html.unescape(full_decision_html_content)
194 | soup = BeautifulSoup(processed_html, "html.parser")
195 | html_input_for_markdown = ""
196 |
197 | karar_tab_content = soup.find("div", id="Karar") # "KARAR" tab content
198 | if karar_tab_content:
199 | karar_metni_div = karar_tab_content.find("div", class_="KararMetni")
200 | if karar_metni_div:
201 | # Remove scripts and styles
202 | for script_tag in karar_metni_div.find_all("script"): script_tag.decompose()
203 | for style_tag in karar_metni_div.find_all("style"): style_tag.decompose()
204 | # Remove "Künye Kopyala" button and other non-content divs
205 | for item_div in karar_metni_div.find_all("div", class_="item col-sm-12"): item_div.decompose()
206 | for modal_div in karar_metni_div.find_all("div", class_="modal fade"): modal_div.decompose() # If any modals
207 |
208 | word_section = karar_metni_div.find("div", class_="WordSection1")
209 | html_input_for_markdown = str(word_section) if word_section else str(karar_metni_div)
210 | else:
211 | html_input_for_markdown = str(karar_tab_content)
212 | else:
213 | # Fallback if specific structure is not found
214 | word_section_fallback = soup.find("div", class_="WordSection1")
215 | if word_section_fallback:
216 | html_input_for_markdown = str(word_section_fallback)
217 | else:
218 | # Last resort: use the whole body or the raw HTML
219 | body_tag = soup.find("body")
220 | html_input_for_markdown = str(body_tag) if body_tag else processed_html
221 |
222 | markdown_text = None
223 | try:
224 | # Ensure the content is wrapped in basic HTML structure if it's not already
225 | if not html_input_for_markdown.strip().lower().startswith(("<html", "<!doctype")):
226 | html_content = f"<html><head><meta charset=\"UTF-8\"></head><body>{html_input_for_markdown}</body></html>"
227 | else:
228 | html_content = html_input_for_markdown
229 |
230 | # Convert HTML string to bytes and create BytesIO stream
231 | html_bytes = html_content.encode('utf-8')
232 | html_stream = io.BytesIO(html_bytes)
233 |
234 | # Pass BytesIO stream to MarkItDown to avoid temp file creation
235 | md_converter = MarkItDown()
236 | conversion_result = md_converter.convert(html_stream)
237 | markdown_text = conversion_result.text_content
238 | except Exception as e:
239 | logger.error(f"AnayasaMahkemesiApiClient: MarkItDown conversion error: {e}")
240 | return markdown_text
241 |
242 | async def get_decision_document_as_markdown(
243 | self,
244 | document_url: str,
245 | page_number: int = 1
246 | ) -> AnayasaDocumentMarkdown:
247 | """
248 | Retrieves a specific Anayasa Mahkemesi (Norm Denetimi) decision,
249 | converts its content to Markdown, and returns the requested page/chunk.
250 | """
251 | full_url = urljoin(self.BASE_URL, document_url) if not document_url.startswith("http") else document_url
252 | logger.info(f"AnayasaMahkemesiApiClient: Fetching Norm Denetimi document for Markdown (page {page_number}) from URL: {full_url}")
253 |
254 | decision_ek_no_from_page = None
255 | decision_date_from_page = None
256 | official_gazette_from_page = None
257 |
258 | try:
259 | # Use a new client instance for document fetching if headers/timeout needs to be different,
260 | # or reuse self.http_client if settings are compatible. For now, self.http_client.
261 | get_response = await self.http_client.get(full_url, headers={"Accept": "text/html"})
262 | get_response.raise_for_status()
263 | html_content_from_api = get_response.text
264 |
265 | if not isinstance(html_content_from_api, str) or not html_content_from_api.strip():
266 | logger.warning(f"AnayasaMahkemesiApiClient: Received empty or non-string HTML from URL {full_url}.")
267 | return AnayasaDocumentMarkdown(
268 | source_url=full_url, markdown_chunk=None, current_page=page_number, total_pages=0, is_paginated=False
269 | )
270 |
271 | # Extract metadata from the page content (E.K. No, Date, RG)
272 | soup = BeautifulSoup(html_content_from_api, "html.parser")
273 | karar_metni_div = soup.find("div", class_="KararMetni") # Usually within div#Karar
274 | if not karar_metni_div: # Fallback if not in KararMetni
275 | karar_metni_div = soup.find("div", class_="WordSection1")
276 |
277 | # Initialize with empty string defaults
278 | decision_ek_no_from_page = ""
279 | decision_date_from_page = ""
280 | official_gazette_from_page = ""
281 |
282 | if karar_metni_div:
283 | # Attempt to find E.K. No (Esas No, Karar No)
284 | # Norm Denetimi pages often have this in bold <p> tags directly or in the WordSection1
285 | # Look for patterns like "Esas No.: YYYY/NN" and "Karar No.: YYYY/NN"
286 |
287 | esas_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Esas No.:" in tag.find("b").get_text())
288 | karar_no_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar No.:" in tag.find("b").get_text())
289 | karar_tarihi_tag = karar_metni_div.find(lambda tag: tag.name == "p" and tag.find("b") and "Karar tarihi:" in tag.find("b").get_text()) # Less common on Norm pages
290 | resmi_gazete_tag = karar_metni_div.find(lambda tag: tag.name == "p" and ("Resmî Gazete tarih ve sayısı:" in tag.get_text() or "Resmi Gazete tarih/sayı:" in tag.get_text()))
291 |
292 |
293 | if esas_no_tag and esas_no_tag.find("b") and karar_no_tag and karar_no_tag.find("b"):
294 | esas_str = esas_no_tag.find("b").get_text(strip=True).replace('Esas No.:', '').strip()
295 | karar_str = karar_no_tag.find("b").get_text(strip=True).replace('Karar No.:', '').strip()
296 | decision_ek_no_from_page = f"E.{esas_str}, K.{karar_str}"
297 |
298 | if karar_tarihi_tag and karar_tarihi_tag.find("b"):
299 | decision_date_from_page = karar_tarihi_tag.find("b").get_text(strip=True).replace("Karar tarihi:", "").strip()
300 | elif karar_metni_div: # Fallback for Karar Tarihi if not in specific tag
301 | date_match = re.search(r"Karar Tarihi\s*:\s*([\d\.]+)", karar_metni_div.get_text()) # Norm pages often use DD.MM.YYYY
302 | if date_match: decision_date_from_page = date_match.group(1).strip()
303 |
304 |
305 | if resmi_gazete_tag:
306 | # Try to get the bold part first if it exists
307 | bold_rg_tag = resmi_gazete_tag.find("b")
308 | rg_text_content = bold_rg_tag.get_text(strip=True) if bold_rg_tag else resmi_gazete_tag.get_text(strip=True)
309 | official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip()
310 |
311 |
312 | full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api)
313 |
314 | if not full_markdown_content:
315 | return AnayasaDocumentMarkdown(
316 | source_url=full_url,
317 | decision_reference_no_from_page=decision_ek_no_from_page,
318 | decision_date_from_page=decision_date_from_page,
319 | official_gazette_info_from_page=official_gazette_from_page,
320 | markdown_chunk=None,
321 | current_page=page_number,
322 | total_pages=0,
323 | is_paginated=False
324 | )
325 |
326 | content_length = len(full_markdown_content)
327 | total_pages = math.ceil(content_length / self.DOCUMENT_MARKDOWN_CHUNK_SIZE)
328 | if total_pages == 0: total_pages = 1
329 |
330 | current_page_clamped = max(1, min(page_number, total_pages))
331 | start_index = (current_page_clamped - 1) * self.DOCUMENT_MARKDOWN_CHUNK_SIZE
332 | end_index = start_index + self.DOCUMENT_MARKDOWN_CHUNK_SIZE
333 | markdown_chunk = full_markdown_content[start_index:end_index]
334 |
335 | return AnayasaDocumentMarkdown(
336 | source_url=full_url,
337 | decision_reference_no_from_page=decision_ek_no_from_page,
338 | decision_date_from_page=decision_date_from_page,
339 | official_gazette_info_from_page=official_gazette_from_page,
340 | markdown_chunk=markdown_chunk,
341 | current_page=current_page_clamped,
342 | total_pages=total_pages,
343 | is_paginated=(total_pages > 1)
344 | )
345 |
346 | except httpx.RequestError as e:
347 | logger.error(f"AnayasaMahkemesiApiClient: HTTP error fetching Norm Denetimi document from {full_url}: {e}")
348 | raise
349 | except Exception as e:
350 | logger.error(f"AnayasaMahkemesiApiClient: General error processing Norm Denetimi document from {full_url}: {e}")
351 | raise
352 |
353 | async def close_client_session(self):
354 | if hasattr(self, 'http_client') and self.http_client and not self.http_client.is_closed:
355 | await self.http_client.aclose()
356 | logger.info("AnayasaMahkemesiApiClient (Norm Denetimi): HTTP client session closed.")
```
--------------------------------------------------------------------------------
/mcp_auth_http_simple.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Simplified MCP OAuth HTTP adapter - only Clerk JWT based authentication
3 | Uses Redis for authorization code storage to support multi-machine deployment
4 | """
5 |
6 | import os
7 | import logging
8 | from typing import Optional
9 | from urllib.parse import urlencode, quote
10 |
11 | from fastapi import APIRouter, Request, Query, HTTPException
12 | from fastapi.responses import RedirectResponse, JSONResponse
13 |
14 | # Import Redis session store
15 | from redis_session_store import get_redis_store
16 |
17 | # Try to import Clerk SDK
18 | try:
19 | from clerk_backend_api import Clerk
20 | CLERK_AVAILABLE = True
21 | except ImportError:
22 | CLERK_AVAILABLE = False
23 | Clerk = None
24 |
25 | logger = logging.getLogger(__name__)
26 |
27 | router = APIRouter()
28 |
29 | # OAuth configuration
30 | BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com")
31 | CLERK_DOMAIN = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
32 |
33 | # Initialize Redis store
34 | redis_store = None
35 |
36 | def get_redis_session_store():
37 | """Get Redis store instance with lazy initialization."""
38 | global redis_store
39 | if redis_store is None:
40 | try:
41 | import concurrent.futures
42 | import functools
43 |
44 | # Use thread pool with timeout to prevent hanging
45 | with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
46 | future = executor.submit(get_redis_store)
47 | try:
48 | # 5 second timeout for Redis initialization
49 | redis_store = future.result(timeout=5.0)
50 | if redis_store:
51 | logger.info("Redis session store initialized for OAuth handler")
52 | else:
53 | logger.warning("Redis store initialization returned None")
54 | except concurrent.futures.TimeoutError:
55 | logger.error("Redis initialization timed out after 5 seconds")
56 | redis_store = None
57 | future.cancel() # Try to cancel the hanging operation
58 |
59 | except Exception as e:
60 | logger.error(f"Failed to initialize Redis store: {e}")
61 | redis_store = None
62 |
63 | if redis_store is None:
64 | # Fall back to in-memory storage with warning
65 | logger.warning("Falling back to in-memory storage - multi-machine deployment will not work")
66 |
67 | return redis_store
68 |
69 | @router.get("/.well-known/oauth-authorization-server")
70 | async def get_oauth_metadata():
71 | """OAuth 2.0 Authorization Server Metadata (RFC 8414)"""
72 | return JSONResponse({
73 | "issuer": BASE_URL,
74 | "authorization_endpoint": "https://yargimcp.com/mcp-callback",
75 | "token_endpoint": f"{BASE_URL}/token",
76 | "registration_endpoint": f"{BASE_URL}/register",
77 | "response_types_supported": ["code"],
78 | "grant_types_supported": ["authorization_code"],
79 | "code_challenge_methods_supported": ["S256"],
80 | "token_endpoint_auth_methods_supported": ["none"],
81 | "scopes_supported": ["read", "search", "openid", "profile", "email"],
82 | "service_documentation": f"{BASE_URL}/mcp/"
83 | })
84 |
85 | @router.get("/auth/login")
86 | async def oauth_authorize(
87 | request: Request,
88 | client_id: str = Query(...),
89 | redirect_uri: str = Query(...),
90 | response_type: str = Query("code"),
91 | scope: Optional[str] = Query("read search"),
92 | state: Optional[str] = Query(None),
93 | code_challenge: Optional[str] = Query(None),
94 | code_challenge_method: Optional[str] = Query(None)
95 | ):
96 | """OAuth 2.1 Authorization Endpoint - redirects to Clerk"""
97 |
98 | logger.info(f"OAuth authorize request - client_id: {client_id}")
99 | logger.info(f"Redirect URI: {redirect_uri}")
100 | logger.info(f"State: {state}")
101 | logger.info(f"PKCE Challenge: {bool(code_challenge)}")
102 |
103 | try:
104 | # Build callback URL with all necessary parameters
105 | callback_url = f"{BASE_URL}/auth/callback"
106 | callback_params = {
107 | "client_id": client_id,
108 | "redirect_uri": redirect_uri,
109 | "state": state or "",
110 | "scope": scope or "read search"
111 | }
112 |
113 | # Add PKCE parameters if present
114 | if code_challenge:
115 | callback_params["code_challenge"] = code_challenge
116 | callback_params["code_challenge_method"] = code_challenge_method or "S256"
117 |
118 | # Encode callback URL as redirect_url for Clerk
119 | callback_with_params = f"{callback_url}?{urlencode(callback_params)}"
120 |
121 | # Build Clerk sign-in URL - use yargimcp.com frontend for JWT token generation
122 | clerk_params = {
123 | "redirect_url": callback_with_params
124 | }
125 |
126 | # Use frontend sign-in page that handles JWT token generation
127 | clerk_signin_url = f"https://yargimcp.com/sign-in?{urlencode(clerk_params)}"
128 |
129 | logger.info(f"Redirecting to Clerk: {clerk_signin_url}")
130 |
131 | return RedirectResponse(url=clerk_signin_url)
132 |
133 | except Exception as e:
134 | logger.exception(f"Authorization failed: {e}")
135 | raise HTTPException(status_code=500, detail=str(e))
136 |
137 | @router.get("/auth/callback")
138 | async def oauth_callback(
139 | request: Request,
140 | client_id: str = Query(...),
141 | redirect_uri: str = Query(...),
142 | state: Optional[str] = Query(None),
143 | scope: Optional[str] = Query("read search"),
144 | code_challenge: Optional[str] = Query(None),
145 | code_challenge_method: Optional[str] = Query(None),
146 | clerk_token: Optional[str] = Query(None)
147 | ):
148 | """OAuth callback from Clerk - generates authorization code"""
149 |
150 | logger.info(f"OAuth callback - client_id: {client_id}")
151 | logger.info(f"Clerk token provided: {bool(clerk_token)}")
152 |
153 | try:
154 | # Validate user with Clerk and generate real JWT token
155 | user_authenticated = False
156 | user_id = None
157 | session_id = None
158 | real_jwt_token = None
159 |
160 | if clerk_token and CLERK_AVAILABLE:
161 | try:
162 | # Extract user info from JWT token (no Clerk session verification needed)
163 | import jwt
164 | decoded_token = jwt.decode(clerk_token, options={"verify_signature": False})
165 | user_id = decoded_token.get("user_id") or decoded_token.get("sub")
166 | user_email = decoded_token.get("email")
167 | token_scopes = decoded_token.get("scopes", ["read", "search"])
168 |
169 | logger.info(f"JWT token claims - user_id: {user_id}, email: {user_email}, scopes: {token_scopes}")
170 |
171 | if user_id and user_email:
172 | # JWT token is already signed by Clerk and contains valid user info
173 | user_authenticated = True
174 | logger.info(f"User authenticated via JWT token - user_id: {user_id}")
175 |
176 | # Use the JWT token directly as the real token (it's already from Clerk template)
177 | real_jwt_token = clerk_token
178 | logger.info("Using Clerk JWT token directly (already real token)")
179 |
180 | else:
181 | logger.error(f"Missing required fields in JWT token - user_id: {bool(user_id)}, email: {bool(user_email)}")
182 |
183 | except Exception as e:
184 | logger.error(f"JWT validation failed: {e}")
185 |
186 | # Fallback to cookie validation
187 | if not user_authenticated:
188 | clerk_session = request.cookies.get("__session")
189 | if clerk_session:
190 | user_authenticated = True
191 | logger.info("User authenticated via cookie")
192 |
193 | # Try to get session from cookie and generate JWT
194 | if CLERK_AVAILABLE:
195 | try:
196 | clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
197 | # Note: sessions.verify_session is deprecated, but we'll try
198 | # In practice, you'd need to extract session_id from cookie
199 | logger.info("Cookie authentication - JWT generation not implemented yet")
200 | except Exception as e:
201 | logger.warning(f"Failed to generate JWT from cookie: {e}")
202 |
203 | # Only generate authorization code if we have a real JWT token
204 | if user_authenticated and real_jwt_token:
205 | # Generate authorization code
206 | auth_code = f"clerk_auth_{os.urandom(16).hex()}"
207 |
208 | # Prepare code data
209 | import time
210 | code_data = {
211 | "user_id": user_id,
212 | "session_id": session_id,
213 | "real_jwt_token": real_jwt_token,
214 | "user_authenticated": user_authenticated,
215 | "client_id": client_id,
216 | "redirect_uri": redirect_uri,
217 | "scope": scope or "read search"
218 | }
219 |
220 | # Try to store in Redis, fall back to in-memory if Redis unavailable
221 | store = get_redis_session_store()
222 | if store:
223 | # Store in Redis with automatic expiration
224 | success = store.set_oauth_code(auth_code, code_data)
225 | if success:
226 | logger.info(f"Stored authorization code {auth_code[:10]}... in Redis with real JWT token")
227 | else:
228 | logger.error(f"Failed to store authorization code in Redis, falling back to in-memory")
229 | # Fall back to in-memory storage
230 | if not hasattr(oauth_callback, '_code_storage'):
231 | oauth_callback._code_storage = {}
232 | oauth_callback._code_storage[auth_code] = code_data
233 | else:
234 | # Fall back to in-memory storage
235 | logger.warning("Redis not available, using in-memory storage")
236 | if not hasattr(oauth_callback, '_code_storage'):
237 | oauth_callback._code_storage = {}
238 | oauth_callback._code_storage[auth_code] = code_data
239 | logger.info(f"Stored authorization code in memory (fallback)")
240 |
241 | # Redirect back to client with authorization code
242 | redirect_params = {
243 | "code": auth_code,
244 | "state": state or ""
245 | }
246 |
247 | final_redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
248 | logger.info(f"Redirecting back to client: {final_redirect_url}")
249 |
250 | return RedirectResponse(url=final_redirect_url)
251 | else:
252 | # No JWT token yet - redirect back to sign-in page to wait for authentication
253 | logger.info("No JWT token provided - redirecting back to sign-in to complete authentication")
254 |
255 | # Keep the same redirect URL so the flow continues
256 | sign_in_params = {
257 | "redirect_url": f"{request.url._url}" # Current callback URL with all params
258 | }
259 |
260 | sign_in_url = f"https://yargimcp.com/sign-in?{urlencode(sign_in_params)}"
261 | logger.info(f"Redirecting back to sign-in: {sign_in_url}")
262 |
263 | return RedirectResponse(url=sign_in_url)
264 |
265 | except Exception as e:
266 | logger.exception(f"Callback processing failed: {e}")
267 | return JSONResponse(
268 | status_code=500,
269 | content={"error": "server_error", "error_description": str(e)}
270 | )
271 |
272 | @router.post("/auth/register")
273 | async def register_client(request: Request):
274 | """Dynamic Client Registration (RFC 7591)"""
275 |
276 | data = await request.json()
277 | logger.info(f"Client registration request: {data}")
278 |
279 | # Simple dynamic registration - accept any client
280 | client_id = f"mcp-client-{os.urandom(8).hex()}"
281 |
282 | return JSONResponse({
283 | "client_id": client_id,
284 | "client_secret": None, # Public client
285 | "redirect_uris": data.get("redirect_uris", []),
286 | "grant_types": ["authorization_code"],
287 | "response_types": ["code"],
288 | "client_name": data.get("client_name", "MCP Client"),
289 | "token_endpoint_auth_method": "none"
290 | })
291 |
292 | @router.post("/auth/callback")
293 | async def oauth_callback_post(request: Request):
294 | """OAuth callback POST endpoint for token exchange"""
295 |
296 | # Parse form data (standard OAuth token exchange format)
297 | form_data = await request.form()
298 | grant_type = form_data.get("grant_type")
299 | code = form_data.get("code")
300 | redirect_uri = form_data.get("redirect_uri")
301 | client_id = form_data.get("client_id")
302 | code_verifier = form_data.get("code_verifier")
303 |
304 | logger.info(f"OAuth callback POST - grant_type: {grant_type}")
305 | logger.info(f"Code: {code[:20] if code else 'None'}...")
306 | logger.info(f"Client ID: {client_id}")
307 | logger.info(f"PKCE verifier: {bool(code_verifier)}")
308 |
309 | if grant_type != "authorization_code":
310 | return JSONResponse(
311 | status_code=400,
312 | content={"error": "unsupported_grant_type"}
313 | )
314 |
315 | if not code or not redirect_uri:
316 | return JSONResponse(
317 | status_code=400,
318 | content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
319 | )
320 |
321 | try:
322 | # Validate authorization code
323 | if not code.startswith("clerk_auth_"):
324 | return JSONResponse(
325 | status_code=400,
326 | content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
327 | )
328 |
329 | # Retrieve stored JWT token using authorization code from Redis or in-memory fallback
330 | stored_code_data = None
331 |
332 | # Try to get from Redis first, then fall back to in-memory
333 | store = get_redis_session_store()
334 | if store:
335 | stored_code_data = store.get_oauth_code(code, delete_after_use=True)
336 | if stored_code_data:
337 | logger.info(f"Retrieved authorization code {code[:10]}... from Redis")
338 | else:
339 | logger.warning(f"Authorization code {code[:10]}... not found in Redis")
340 |
341 | # Fall back to in-memory storage if Redis unavailable or code not found
342 | if not stored_code_data and hasattr(oauth_callback, '_code_storage'):
343 | stored_code_data = oauth_callback._code_storage.get(code)
344 | if stored_code_data:
345 | # Clean up in-memory storage
346 | oauth_callback._code_storage.pop(code, None)
347 | logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage")
348 |
349 | if not stored_code_data:
350 | logger.error(f"No stored data found for authorization code: {code}")
351 | return JSONResponse(
352 | status_code=400,
353 | content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"}
354 | )
355 |
356 | # Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback
357 | import time
358 | expires_at = stored_code_data.get("expires_at", 0)
359 | if expires_at and time.time() > expires_at:
360 | logger.error(f"Authorization code expired: {code}")
361 | return JSONResponse(
362 | status_code=400,
363 | content={"error": "invalid_grant", "error_description": "Authorization code expired"}
364 | )
365 |
366 | # Get the real JWT token
367 | real_jwt_token = stored_code_data.get("real_jwt_token")
368 |
369 | if real_jwt_token:
370 | logger.info("Returning real Clerk JWT token")
371 | # Note: Code already deleted from Redis, clean up in-memory fallback if used
372 | if hasattr(oauth_callback, '_code_storage'):
373 | oauth_callback._code_storage.pop(code, None)
374 |
375 | return JSONResponse({
376 | "access_token": real_jwt_token,
377 | "token_type": "Bearer",
378 | "expires_in": 3600,
379 | "scope": "read search"
380 | })
381 | else:
382 | logger.warning("No real JWT token found, generating mock token")
383 | # Fallback to mock token for testing
384 | mock_token = f"mock_clerk_jwt_{code}"
385 | return JSONResponse({
386 | "access_token": mock_token,
387 | "token_type": "Bearer",
388 | "expires_in": 3600,
389 | "scope": "read search"
390 | })
391 |
392 | except Exception as e:
393 | logger.exception(f"OAuth callback POST failed: {e}")
394 | return JSONResponse(
395 | status_code=500,
396 | content={"error": "server_error", "error_description": str(e)}
397 | )
398 |
399 | @router.post("/register")
400 | async def register_client(request: Request):
401 | """Dynamic Client Registration (RFC 7591)"""
402 |
403 | data = await request.json()
404 | logger.info(f"Client registration request: {data}")
405 |
406 | # Simple dynamic registration - accept any client
407 | client_id = f"mcp-client-{os.urandom(8).hex()}"
408 |
409 | return JSONResponse({
410 | "client_id": client_id,
411 | "client_secret": None, # Public client
412 | "redirect_uris": data.get("redirect_uris", []),
413 | "grant_types": ["authorization_code"],
414 | "response_types": ["code"],
415 | "client_name": data.get("client_name", "MCP Client"),
416 | "token_endpoint_auth_method": "none"
417 | })
418 |
419 | @router.post("/token")
420 | async def token_endpoint(request: Request):
421 | """OAuth 2.1 Token Endpoint - exchanges code for Clerk JWT"""
422 |
423 | # Parse form data
424 | form_data = await request.form()
425 | grant_type = form_data.get("grant_type")
426 | code = form_data.get("code")
427 | redirect_uri = form_data.get("redirect_uri")
428 | client_id = form_data.get("client_id")
429 | code_verifier = form_data.get("code_verifier")
430 |
431 | logger.info(f"Token exchange - grant_type: {grant_type}")
432 | logger.info(f"Code: {code[:20] if code else 'None'}...")
433 |
434 | if grant_type != "authorization_code":
435 | return JSONResponse(
436 | status_code=400,
437 | content={"error": "unsupported_grant_type"}
438 | )
439 |
440 | if not code or not redirect_uri:
441 | return JSONResponse(
442 | status_code=400,
443 | content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
444 | )
445 |
446 | try:
447 | # Validate authorization code
448 | if not code.startswith("clerk_auth_"):
449 | return JSONResponse(
450 | status_code=400,
451 | content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
452 | )
453 |
454 | # Retrieve stored JWT token using authorization code from Redis or in-memory fallback
455 | stored_code_data = None
456 |
457 | # Try to get from Redis first, then fall back to in-memory
458 | store = get_redis_session_store()
459 | if store:
460 | stored_code_data = store.get_oauth_code(code, delete_after_use=True)
461 | if stored_code_data:
462 | logger.info(f"Retrieved authorization code {code[:10]}... from Redis (/token endpoint)")
463 | else:
464 | logger.warning(f"Authorization code {code[:10]}... not found in Redis (/token endpoint)")
465 |
466 | # Fall back to in-memory storage if Redis unavailable or code not found
467 | if not stored_code_data and hasattr(oauth_callback, '_code_storage'):
468 | stored_code_data = oauth_callback._code_storage.get(code)
469 | if stored_code_data:
470 | # Clean up in-memory storage
471 | oauth_callback._code_storage.pop(code, None)
472 | logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage (/token endpoint)")
473 |
474 | if not stored_code_data:
475 | logger.error(f"No stored data found for authorization code: {code}")
476 | return JSONResponse(
477 | status_code=400,
478 | content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"}
479 | )
480 |
481 | # Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback
482 | import time
483 | expires_at = stored_code_data.get("expires_at", 0)
484 | if expires_at and time.time() > expires_at:
485 | logger.error(f"Authorization code expired: {code}")
486 | return JSONResponse(
487 | status_code=400,
488 | content={"error": "invalid_grant", "error_description": "Authorization code expired"}
489 | )
490 |
491 | # Get the real JWT token
492 | real_jwt_token = stored_code_data.get("real_jwt_token")
493 |
494 | if real_jwt_token:
495 | logger.info("Returning real Clerk JWT token from /token endpoint")
496 | # Note: Code already deleted from Redis, clean up in-memory fallback if used
497 | if hasattr(oauth_callback, '_code_storage'):
498 | oauth_callback._code_storage.pop(code, None)
499 |
500 | return JSONResponse({
501 | "access_token": real_jwt_token,
502 | "token_type": "Bearer",
503 | "expires_in": 3600,
504 | "scope": "read search"
505 | })
506 | else:
507 | logger.warning("No real JWT token found in /token endpoint, generating mock token")
508 | # Fallback to mock token for testing
509 | mock_token = f"mock_clerk_jwt_{code}"
510 | return JSONResponse({
511 | "access_token": mock_token,
512 | "token_type": "Bearer",
513 | "expires_in": 3600,
514 | "scope": "read search"
515 | })
516 |
517 | except Exception as e:
518 | logger.exception(f"Token exchange failed: {e}")
519 | return JSONResponse(
520 | status_code=500,
521 | content={"error": "server_error", "error_description": str(e)}
522 | )
```
--------------------------------------------------------------------------------
/saidsurucu-yargi-mcp-f5fa007/mcp_auth_http_simple.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Simplified MCP OAuth HTTP adapter - only Clerk JWT based authentication
3 | Uses Redis for authorization code storage to support multi-machine deployment
4 | """
5 |
6 | import os
7 | import logging
8 | from typing import Optional
9 | from urllib.parse import urlencode, quote
10 |
11 | from fastapi import APIRouter, Request, Query, HTTPException
12 | from fastapi.responses import RedirectResponse, JSONResponse
13 |
14 | # Import Redis session store
15 | from redis_session_store import get_redis_store
16 |
17 | # Try to import Clerk SDK
18 | try:
19 | from clerk_backend_api import Clerk
20 | CLERK_AVAILABLE = True
21 | except ImportError:
22 | CLERK_AVAILABLE = False
23 | Clerk = None
24 |
25 | logger = logging.getLogger(__name__)
26 |
27 | router = APIRouter()
28 |
29 | # OAuth configuration
30 | BASE_URL = os.getenv("BASE_URL", "https://api.yargimcp.com")
31 | CLERK_DOMAIN = os.getenv("CLERK_DOMAIN", "accounts.yargimcp.com")
32 |
33 | # Initialize Redis store
34 | redis_store = None
35 |
36 | def get_redis_session_store():
37 | """Get Redis store instance with lazy initialization."""
38 | global redis_store
39 | if redis_store is None:
40 | try:
41 | import concurrent.futures
42 | import functools
43 |
44 | # Use thread pool with timeout to prevent hanging
45 | with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
46 | future = executor.submit(get_redis_store)
47 | try:
48 | # 5 second timeout for Redis initialization
49 | redis_store = future.result(timeout=5.0)
50 | if redis_store:
51 | logger.info("Redis session store initialized for OAuth handler")
52 | else:
53 | logger.warning("Redis store initialization returned None")
54 | except concurrent.futures.TimeoutError:
55 | logger.error("Redis initialization timed out after 5 seconds")
56 | redis_store = None
57 | future.cancel() # Try to cancel the hanging operation
58 |
59 | except Exception as e:
60 | logger.error(f"Failed to initialize Redis store: {e}")
61 | redis_store = None
62 |
63 | if redis_store is None:
64 | # Fall back to in-memory storage with warning
65 | logger.warning("Falling back to in-memory storage - multi-machine deployment will not work")
66 |
67 | return redis_store
68 |
69 | @router.get("/.well-known/oauth-authorization-server")
70 | async def get_oauth_metadata():
71 | """OAuth 2.0 Authorization Server Metadata (RFC 8414)"""
72 | return JSONResponse({
73 | "issuer": BASE_URL,
74 | "authorization_endpoint": "https://yargimcp.com/mcp-callback",
75 | "token_endpoint": f"{BASE_URL}/token",
76 | "registration_endpoint": f"{BASE_URL}/register",
77 | "response_types_supported": ["code"],
78 | "grant_types_supported": ["authorization_code"],
79 | "code_challenge_methods_supported": ["S256"],
80 | "token_endpoint_auth_methods_supported": ["none"],
81 | "scopes_supported": ["read", "search", "openid", "profile", "email"],
82 | "service_documentation": f"{BASE_URL}/mcp/"
83 | })
84 |
85 | @router.get("/auth/login")
86 | async def oauth_authorize(
87 | request: Request,
88 | client_id: str = Query(...),
89 | redirect_uri: str = Query(...),
90 | response_type: str = Query("code"),
91 | scope: Optional[str] = Query("read search"),
92 | state: Optional[str] = Query(None),
93 | code_challenge: Optional[str] = Query(None),
94 | code_challenge_method: Optional[str] = Query(None)
95 | ):
96 | """OAuth 2.1 Authorization Endpoint - redirects to Clerk"""
97 |
98 | logger.info(f"OAuth authorize request - client_id: {client_id}")
99 | logger.info(f"Redirect URI: {redirect_uri}")
100 | logger.info(f"State: {state}")
101 | logger.info(f"PKCE Challenge: {bool(code_challenge)}")
102 |
103 | try:
104 | # Build callback URL with all necessary parameters
105 | callback_url = f"{BASE_URL}/auth/callback"
106 | callback_params = {
107 | "client_id": client_id,
108 | "redirect_uri": redirect_uri,
109 | "state": state or "",
110 | "scope": scope or "read search"
111 | }
112 |
113 | # Add PKCE parameters if present
114 | if code_challenge:
115 | callback_params["code_challenge"] = code_challenge
116 | callback_params["code_challenge_method"] = code_challenge_method or "S256"
117 |
118 | # Encode callback URL as redirect_url for Clerk
119 | callback_with_params = f"{callback_url}?{urlencode(callback_params)}"
120 |
121 | # Build Clerk sign-in URL - use yargimcp.com frontend for JWT token generation
122 | clerk_params = {
123 | "redirect_url": callback_with_params
124 | }
125 |
126 | # Use frontend sign-in page that handles JWT token generation
127 | clerk_signin_url = f"https://yargimcp.com/sign-in?{urlencode(clerk_params)}"
128 |
129 | logger.info(f"Redirecting to Clerk: {clerk_signin_url}")
130 |
131 | return RedirectResponse(url=clerk_signin_url)
132 |
133 | except Exception as e:
134 | logger.exception(f"Authorization failed: {e}")
135 | raise HTTPException(status_code=500, detail=str(e))
136 |
137 | @router.get("/auth/callback")
138 | async def oauth_callback(
139 | request: Request,
140 | client_id: str = Query(...),
141 | redirect_uri: str = Query(...),
142 | state: Optional[str] = Query(None),
143 | scope: Optional[str] = Query("read search"),
144 | code_challenge: Optional[str] = Query(None),
145 | code_challenge_method: Optional[str] = Query(None),
146 | clerk_token: Optional[str] = Query(None)
147 | ):
148 | """OAuth callback from Clerk - generates authorization code"""
149 |
150 | logger.info(f"OAuth callback - client_id: {client_id}")
151 | logger.info(f"Clerk token provided: {bool(clerk_token)}")
152 |
153 | try:
154 | # Validate user with Clerk and generate real JWT token
155 | user_authenticated = False
156 | user_id = None
157 | session_id = None
158 | real_jwt_token = None
159 |
160 | if clerk_token and CLERK_AVAILABLE:
161 | try:
162 | # Extract user info from JWT token (no Clerk session verification needed)
163 | import jwt
164 | decoded_token = jwt.decode(clerk_token, options={"verify_signature": False})
165 | user_id = decoded_token.get("user_id") or decoded_token.get("sub")
166 | user_email = decoded_token.get("email")
167 | token_scopes = decoded_token.get("scopes", ["read", "search"])
168 |
169 | logger.info(f"JWT token claims - user_id: {user_id}, email: {user_email}, scopes: {token_scopes}")
170 |
171 | if user_id and user_email:
172 | # JWT token is already signed by Clerk and contains valid user info
173 | user_authenticated = True
174 | logger.info(f"User authenticated via JWT token - user_id: {user_id}")
175 |
176 | # Use the JWT token directly as the real token (it's already from Clerk template)
177 | real_jwt_token = clerk_token
178 | logger.info("Using Clerk JWT token directly (already real token)")
179 |
180 | else:
181 | logger.error(f"Missing required fields in JWT token - user_id: {bool(user_id)}, email: {bool(user_email)}")
182 |
183 | except Exception as e:
184 | logger.error(f"JWT validation failed: {e}")
185 |
186 | # Fallback to cookie validation
187 | if not user_authenticated:
188 | clerk_session = request.cookies.get("__session")
189 | if clerk_session:
190 | user_authenticated = True
191 | logger.info("User authenticated via cookie")
192 |
193 | # Try to get session from cookie and generate JWT
194 | if CLERK_AVAILABLE:
195 | try:
196 | clerk = Clerk(bearer_auth=os.getenv("CLERK_SECRET_KEY"))
197 | # Note: sessions.verify_session is deprecated, but we'll try
198 | # In practice, you'd need to extract session_id from cookie
199 | logger.info("Cookie authentication - JWT generation not implemented yet")
200 | except Exception as e:
201 | logger.warning(f"Failed to generate JWT from cookie: {e}")
202 |
203 | # Only generate authorization code if we have a real JWT token
204 | if user_authenticated and real_jwt_token:
205 | # Generate authorization code
206 | auth_code = f"clerk_auth_{os.urandom(16).hex()}"
207 |
208 | # Prepare code data
209 | import time
210 | code_data = {
211 | "user_id": user_id,
212 | "session_id": session_id,
213 | "real_jwt_token": real_jwt_token,
214 | "user_authenticated": user_authenticated,
215 | "client_id": client_id,
216 | "redirect_uri": redirect_uri,
217 | "scope": scope or "read search"
218 | }
219 |
220 | # Try to store in Redis, fall back to in-memory if Redis unavailable
221 | store = get_redis_session_store()
222 | if store:
223 | # Store in Redis with automatic expiration
224 | success = store.set_oauth_code(auth_code, code_data)
225 | if success:
226 | logger.info(f"Stored authorization code {auth_code[:10]}... in Redis with real JWT token")
227 | else:
228 | logger.error(f"Failed to store authorization code in Redis, falling back to in-memory")
229 | # Fall back to in-memory storage
230 | if not hasattr(oauth_callback, '_code_storage'):
231 | oauth_callback._code_storage = {}
232 | oauth_callback._code_storage[auth_code] = code_data
233 | else:
234 | # Fall back to in-memory storage
235 | logger.warning("Redis not available, using in-memory storage")
236 | if not hasattr(oauth_callback, '_code_storage'):
237 | oauth_callback._code_storage = {}
238 | oauth_callback._code_storage[auth_code] = code_data
239 | logger.info(f"Stored authorization code in memory (fallback)")
240 |
241 | # Redirect back to client with authorization code
242 | redirect_params = {
243 | "code": auth_code,
244 | "state": state or ""
245 | }
246 |
247 | final_redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
248 | logger.info(f"Redirecting back to client: {final_redirect_url}")
249 |
250 | return RedirectResponse(url=final_redirect_url)
251 | else:
252 | # No JWT token yet - redirect back to sign-in page to wait for authentication
253 | logger.info("No JWT token provided - redirecting back to sign-in to complete authentication")
254 |
255 | # Keep the same redirect URL so the flow continues
256 | sign_in_params = {
257 | "redirect_url": f"{request.url._url}" # Current callback URL with all params
258 | }
259 |
260 | sign_in_url = f"https://yargimcp.com/sign-in?{urlencode(sign_in_params)}"
261 | logger.info(f"Redirecting back to sign-in: {sign_in_url}")
262 |
263 | return RedirectResponse(url=sign_in_url)
264 |
265 | except Exception as e:
266 | logger.exception(f"Callback processing failed: {e}")
267 | return JSONResponse(
268 | status_code=500,
269 | content={"error": "server_error", "error_description": str(e)}
270 | )
271 |
272 | @router.post("/auth/register")
273 | async def register_client(request: Request):
274 | """Dynamic Client Registration (RFC 7591)"""
275 |
276 | data = await request.json()
277 | logger.info(f"Client registration request: {data}")
278 |
279 | # Simple dynamic registration - accept any client
280 | client_id = f"mcp-client-{os.urandom(8).hex()}"
281 |
282 | return JSONResponse({
283 | "client_id": client_id,
284 | "client_secret": None, # Public client
285 | "redirect_uris": data.get("redirect_uris", []),
286 | "grant_types": ["authorization_code"],
287 | "response_types": ["code"],
288 | "client_name": data.get("client_name", "MCP Client"),
289 | "token_endpoint_auth_method": "none"
290 | })
291 |
292 | @router.post("/auth/callback")
293 | async def oauth_callback_post(request: Request):
294 | """OAuth callback POST endpoint for token exchange"""
295 |
296 | # Parse form data (standard OAuth token exchange format)
297 | form_data = await request.form()
298 | grant_type = form_data.get("grant_type")
299 | code = form_data.get("code")
300 | redirect_uri = form_data.get("redirect_uri")
301 | client_id = form_data.get("client_id")
302 | code_verifier = form_data.get("code_verifier")
303 |
304 | logger.info(f"OAuth callback POST - grant_type: {grant_type}")
305 | logger.info(f"Code: {code[:20] if code else 'None'}...")
306 | logger.info(f"Client ID: {client_id}")
307 | logger.info(f"PKCE verifier: {bool(code_verifier)}")
308 |
309 | if grant_type != "authorization_code":
310 | return JSONResponse(
311 | status_code=400,
312 | content={"error": "unsupported_grant_type"}
313 | )
314 |
315 | if not code or not redirect_uri:
316 | return JSONResponse(
317 | status_code=400,
318 | content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
319 | )
320 |
321 | try:
322 | # Validate authorization code
323 | if not code.startswith("clerk_auth_"):
324 | return JSONResponse(
325 | status_code=400,
326 | content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
327 | )
328 |
329 | # Retrieve stored JWT token using authorization code from Redis or in-memory fallback
330 | stored_code_data = None
331 |
332 | # Try to get from Redis first, then fall back to in-memory
333 | store = get_redis_session_store()
334 | if store:
335 | stored_code_data = store.get_oauth_code(code, delete_after_use=True)
336 | if stored_code_data:
337 | logger.info(f"Retrieved authorization code {code[:10]}... from Redis")
338 | else:
339 | logger.warning(f"Authorization code {code[:10]}... not found in Redis")
340 |
341 | # Fall back to in-memory storage if Redis unavailable or code not found
342 | if not stored_code_data and hasattr(oauth_callback, '_code_storage'):
343 | stored_code_data = oauth_callback._code_storage.get(code)
344 | if stored_code_data:
345 | # Clean up in-memory storage
346 | oauth_callback._code_storage.pop(code, None)
347 | logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage")
348 |
349 | if not stored_code_data:
350 | logger.error(f"No stored data found for authorization code: {code}")
351 | return JSONResponse(
352 | status_code=400,
353 | content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"}
354 | )
355 |
356 | # Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback
357 | import time
358 | expires_at = stored_code_data.get("expires_at", 0)
359 | if expires_at and time.time() > expires_at:
360 | logger.error(f"Authorization code expired: {code}")
361 | return JSONResponse(
362 | status_code=400,
363 | content={"error": "invalid_grant", "error_description": "Authorization code expired"}
364 | )
365 |
366 | # Get the real JWT token
367 | real_jwt_token = stored_code_data.get("real_jwt_token")
368 |
369 | if real_jwt_token:
370 | logger.info("Returning real Clerk JWT token")
371 | # Note: Code already deleted from Redis, clean up in-memory fallback if used
372 | if hasattr(oauth_callback, '_code_storage'):
373 | oauth_callback._code_storage.pop(code, None)
374 |
375 | return JSONResponse({
376 | "access_token": real_jwt_token,
377 | "token_type": "Bearer",
378 | "expires_in": 3600,
379 | "scope": "read search"
380 | })
381 | else:
382 | logger.warning("No real JWT token found, generating mock token")
383 | # Fallback to mock token for testing
384 | mock_token = f"mock_clerk_jwt_{code}"
385 | return JSONResponse({
386 | "access_token": mock_token,
387 | "token_type": "Bearer",
388 | "expires_in": 3600,
389 | "scope": "read search"
390 | })
391 |
392 | except Exception as e:
393 | logger.exception(f"OAuth callback POST failed: {e}")
394 | return JSONResponse(
395 | status_code=500,
396 | content={"error": "server_error", "error_description": str(e)}
397 | )
398 |
399 | @router.post("/register")
400 | async def register_client(request: Request):
401 | """Dynamic Client Registration (RFC 7591)"""
402 |
403 | data = await request.json()
404 | logger.info(f"Client registration request: {data}")
405 |
406 | # Simple dynamic registration - accept any client
407 | client_id = f"mcp-client-{os.urandom(8).hex()}"
408 |
409 | return JSONResponse({
410 | "client_id": client_id,
411 | "client_secret": None, # Public client
412 | "redirect_uris": data.get("redirect_uris", []),
413 | "grant_types": ["authorization_code"],
414 | "response_types": ["code"],
415 | "client_name": data.get("client_name", "MCP Client"),
416 | "token_endpoint_auth_method": "none"
417 | })
418 |
419 | @router.post("/token")
420 | async def token_endpoint(request: Request):
421 | """OAuth 2.1 Token Endpoint - exchanges code for Clerk JWT"""
422 |
423 | # Parse form data
424 | form_data = await request.form()
425 | grant_type = form_data.get("grant_type")
426 | code = form_data.get("code")
427 | redirect_uri = form_data.get("redirect_uri")
428 | client_id = form_data.get("client_id")
429 | code_verifier = form_data.get("code_verifier")
430 |
431 | logger.info(f"Token exchange - grant_type: {grant_type}")
432 | logger.info(f"Code: {code[:20] if code else 'None'}...")
433 |
434 | if grant_type != "authorization_code":
435 | return JSONResponse(
436 | status_code=400,
437 | content={"error": "unsupported_grant_type"}
438 | )
439 |
440 | if not code or not redirect_uri:
441 | return JSONResponse(
442 | status_code=400,
443 | content={"error": "invalid_request", "error_description": "Missing code or redirect_uri"}
444 | )
445 |
446 | try:
447 | # Validate authorization code
448 | if not code.startswith("clerk_auth_"):
449 | return JSONResponse(
450 | status_code=400,
451 | content={"error": "invalid_grant", "error_description": "Invalid authorization code"}
452 | )
453 |
454 | # Retrieve stored JWT token using authorization code from Redis or in-memory fallback
455 | stored_code_data = None
456 |
457 | # Try to get from Redis first, then fall back to in-memory
458 | store = get_redis_session_store()
459 | if store:
460 | stored_code_data = store.get_oauth_code(code, delete_after_use=True)
461 | if stored_code_data:
462 | logger.info(f"Retrieved authorization code {code[:10]}... from Redis (/token endpoint)")
463 | else:
464 | logger.warning(f"Authorization code {code[:10]}... not found in Redis (/token endpoint)")
465 |
466 | # Fall back to in-memory storage if Redis unavailable or code not found
467 | if not stored_code_data and hasattr(oauth_callback, '_code_storage'):
468 | stored_code_data = oauth_callback._code_storage.get(code)
469 | if stored_code_data:
470 | # Clean up in-memory storage
471 | oauth_callback._code_storage.pop(code, None)
472 | logger.info(f"Retrieved authorization code {code[:10]}... from in-memory storage (/token endpoint)")
473 |
474 | if not stored_code_data:
475 | logger.error(f"No stored data found for authorization code: {code}")
476 | return JSONResponse(
477 | status_code=400,
478 | content={"error": "invalid_grant", "error_description": "Authorization code not found or expired"}
479 | )
480 |
481 | # Note: Redis TTL handles expiration automatically, but check for manual expiration for in-memory fallback
482 | import time
483 | expires_at = stored_code_data.get("expires_at", 0)
484 | if expires_at and time.time() > expires_at:
485 | logger.error(f"Authorization code expired: {code}")
486 | return JSONResponse(
487 | status_code=400,
488 | content={"error": "invalid_grant", "error_description": "Authorization code expired"}
489 | )
490 |
491 | # Get the real JWT token
492 | real_jwt_token = stored_code_data.get("real_jwt_token")
493 |
494 | if real_jwt_token:
495 | logger.info("Returning real Clerk JWT token from /token endpoint")
496 | # Note: Code already deleted from Redis, clean up in-memory fallback if used
497 | if hasattr(oauth_callback, '_code_storage'):
498 | oauth_callback._code_storage.pop(code, None)
499 |
500 | return JSONResponse({
501 | "access_token": real_jwt_token,
502 | "token_type": "Bearer",
503 | "expires_in": 3600,
504 | "scope": "read search"
505 | })
506 | else:
507 | logger.warning("No real JWT token found in /token endpoint, generating mock token")
508 | # Fallback to mock token for testing
509 | mock_token = f"mock_clerk_jwt_{code}"
510 | return JSONResponse({
511 | "access_token": mock_token,
512 | "token_type": "Bearer",
513 | "expires_in": 3600,
514 | "scope": "read search"
515 | })
516 |
517 | except Exception as e:
518 | logger.exception(f"Token exchange failed: {e}")
519 | return JSONResponse(
520 | status_code=500,
521 | content={"error": "server_error", "error_description": str(e)}
522 | )
```