#
tokens: 48606/50000 9/145 files (page 6/11)
lines: on (toggle) GitHub
raw markdown copy reset
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 |         )
```
Page 6/11FirstPrevNextLast