cloud-bicep-mcp-server 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auth.py ADDED
@@ -0,0 +1,69 @@
1
+ """Authentication middleware for the MCP server."""
2
+
3
+ import logging
4
+
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+ from starlette.responses import JSONResponse
7
+
8
+ from config import settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class APIKeyAuthMiddleware(BaseHTTPMiddleware):
14
+ """Middleware to validate API Key for caller authentication."""
15
+
16
+ async def dispatch(self, request, call_next):
17
+ """Validate API Key for caller authentication. Returns 401 if validation fails."""
18
+ logger.debug(f"Auth middleware: {request.method} {request.url.path}")
19
+ if request.method == "GET" and request.url.path in ("/health", "/mcp/icon.png"):
20
+ return await call_next(request)
21
+
22
+ api_key_header = request.headers.get("X-API-Key", "")
23
+
24
+ # Check if request is from loopback (localhost)
25
+ client_host = request.client.host if request.client else ""
26
+ is_loopback = client_host in ("127.0.0.1", "::1", "localhost")
27
+ logger.debug(f"Client: {client_host} (loopback: {is_loopback}), API key header present: {bool(api_key_header)}")
28
+
29
+ # If API Key is not configured, only allow loopback requests
30
+ if not settings.MCP_API_KEY:
31
+ if is_loopback:
32
+ logger.warning(
33
+ "MCP_API_KEY not configured, allowing unauthenticated loopback access "
34
+ "- configure MCP_API_KEY for production"
35
+ )
36
+ else:
37
+ logger.error("MCP_API_KEY not configured and request is not from loopback - rejecting")
38
+ return JSONResponse(
39
+ {"error": "Server configuration error: MCP_API_KEY not configured"},
40
+ status_code=500
41
+ )
42
+ else:
43
+ if not api_key_header:
44
+ logger.warning("API Key header missing")
45
+ return JSONResponse(
46
+ {"error": "Missing X-API-Key header"},
47
+ status_code=401
48
+ )
49
+
50
+ if api_key_header != settings.MCP_API_KEY:
51
+ logger.warning("Invalid API Key provided")
52
+ return JSONResponse(
53
+ {"error": "Invalid API Key"},
54
+ status_code=401
55
+ )
56
+
57
+ logger.info("API Key validated successfully")
58
+
59
+ # Require GITHUB_TOKEN for accessing the private GitHub repository
60
+ if not settings.GITHUB_TOKEN:
61
+ logger.error("GITHUB_TOKEN is not configured")
62
+ return JSONResponse(
63
+ {"error": "Server configuration error: GITHUB_TOKEN is required"},
64
+ status_code=500
65
+ )
66
+
67
+ response = await call_next(request)
68
+ logger.debug(f"Response status: {getattr(response, 'status_code', response)}")
69
+ return response
cache.py ADDED
@@ -0,0 +1,230 @@
1
+ """Cache backends for the Cloud Bicep Modules MCP Server."""
2
+
3
+ import json
4
+ import logging
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+
12
+ from config import settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CacheBackend(ABC):
18
+ """Abstract base class for cache backends."""
19
+
20
+ @abstractmethod
21
+ def get(self, key: str) -> Optional[Dict[str, str]]:
22
+ """Get cached data by key.
23
+
24
+ Args:
25
+ key: Cache key
26
+
27
+ Returns:
28
+ Cached data dict with "version" and "content", or None if not found
29
+ """
30
+ pass
31
+
32
+ @abstractmethod
33
+ def set(self, key: str, version: str, content: str):
34
+ """Set cached data with version.
35
+
36
+ Args:
37
+ key: Cache key
38
+ version: Module version
39
+ content: Extracted markdown content
40
+ """
41
+ pass
42
+
43
+ @abstractmethod
44
+ def delete(self, key: str):
45
+ """Delete cached data by key.
46
+
47
+ Args:
48
+ key: Cache key
49
+ """
50
+ pass
51
+
52
+
53
+ class DiskCacheBackend(CacheBackend):
54
+ """Disk-based cache backend."""
55
+
56
+ def __init__(self, cache_dir: Optional[str] = None, ttl_seconds: Optional[int] = None):
57
+ """Initialize disk cache backend.
58
+
59
+ Args:
60
+ cache_dir: Custom cache directory path. If None, uses system temp directory.
61
+ ttl_seconds: Time-to-live for cache entries in seconds. If None or <= 0, entries never expire.
62
+ """
63
+ self.ttl = ttl_seconds
64
+ if cache_dir:
65
+ self.cache_dir = Path(cache_dir)
66
+ else:
67
+ self.cache_dir = Path(tempfile.gettempdir()) / "bicep-modules-mcp-cache"
68
+
69
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
70
+ logger.info(f"Disk cache initialized: {self.cache_dir}")
71
+
72
+ def _get_cache_file(self, key: str) -> Path:
73
+ """Get cache file path for a key.
74
+
75
+ Args:
76
+ key: Cache key
77
+
78
+ Returns:
79
+ Path to cache file
80
+ """
81
+ safe_name = key.replace("/", "_").replace("\\", "_")
82
+ return self.cache_dir / f"{safe_name}.json"
83
+
84
+ def get(self, key: str) -> Optional[Dict[str, str]]:
85
+ """Get cached data from disk."""
86
+ cache_file = self._get_cache_file(key)
87
+
88
+ if not cache_file.exists():
89
+ logger.debug(f"Disk cache file not found: {cache_file}")
90
+ return None
91
+
92
+ try:
93
+ with open(cache_file, "r", encoding="utf-8") as f:
94
+ data = json.load(f)
95
+
96
+ if self.ttl and self.ttl > 0 and time.time() - data.get("cached_at", 0) > self.ttl:
97
+ logger.debug(f"Disk cache EXPIRED: {key}")
98
+ self.delete(key)
99
+ return None
100
+
101
+ logger.debug(f"Disk cache HIT: {key}")
102
+ return data
103
+ except Exception as e:
104
+ logger.warning(f"Error loading disk cache: {str(e)}")
105
+ return None
106
+
107
+ def set(self, key: str, version: str, content: str):
108
+ """Set cached data to disk."""
109
+ cache_file = self._get_cache_file(key)
110
+
111
+ try:
112
+ data = {
113
+ "version": version,
114
+ "content": content,
115
+ "cached_at": time.time()
116
+ }
117
+
118
+ with open(cache_file, "w", encoding="utf-8") as f:
119
+ json.dump(data, f, indent=2)
120
+
121
+ logger.debug(f"Disk cache SET: {key}")
122
+ except Exception as e:
123
+ logger.error(f"Error saving disk cache: {str(e)}")
124
+
125
+ def delete(self, key: str):
126
+ """Delete cached data from disk."""
127
+ cache_file = self._get_cache_file(key)
128
+ try:
129
+ if cache_file.exists():
130
+ cache_file.unlink()
131
+ logger.debug(f"Disk cache DELETE: {key}")
132
+ except Exception as e:
133
+ logger.warning(f"Error deleting disk cache: {str(e)}")
134
+
135
+
136
+ # Global cache backend instance
137
+ _cache_backend: Optional[CacheBackend] = None
138
+ _cache_lock = threading.Lock()
139
+
140
+
141
+ def get_cache_backend() -> CacheBackend:
142
+ """Get the configured cache backend instance.
143
+
144
+ Returns:
145
+ CacheBackend instance (initialized on first call)
146
+ """
147
+ global _cache_backend
148
+
149
+ with _cache_lock:
150
+ if _cache_backend is None:
151
+ _cache_backend = DiskCacheBackend(settings.MCP_CACHE_DIR, settings.MCP_CACHE_TTL_SECONDS)
152
+
153
+ return _cache_backend
154
+
155
+
156
+ def get_cached_module_content(module_path: str, current_version: str) -> Optional[str]:
157
+ """Get module content from cache if version matches.
158
+
159
+ Args:
160
+ module_path: Module path (e.g., "modules/keyvault")
161
+ current_version: Current version from version.json
162
+
163
+ Returns:
164
+ Cached content if version matches, None otherwise
165
+ """
166
+ cache = get_cache_backend()
167
+ cached_data = cache.get(module_path)
168
+
169
+ if cached_data and cached_data.get("version") == current_version:
170
+ logger.info(f"Cache HIT for {module_path} (version {current_version})")
171
+ return cached_data.get("content")
172
+ elif cached_data:
173
+ logger.info(f"Cache MISS for {module_path}: version changed from {cached_data['version']} to {current_version}")
174
+ else:
175
+ logger.debug(f"Cache MISS for {module_path}: not in cache")
176
+
177
+ return None
178
+
179
+
180
+ def update_module_cache(module_path: str, version: str, content: str):
181
+ """Update cache with new module content.
182
+
183
+ Args:
184
+ module_path: Module path (e.g., "modules/keyvault")
185
+ version: Module version
186
+ content: Extracted markdown content
187
+ """
188
+ cache = get_cache_backend()
189
+ cache.set(module_path, version, content)
190
+ logger.info(f"Cache updated for {module_path} (version {version})")
191
+
192
+
193
+ INVENTORY_CACHE_KEY = "modules-inventory"
194
+
195
+
196
+ def get_cached_inventory(fingerprint: str) -> Optional[str]:
197
+ """Get the cached module inventory if the fingerprint matches.
198
+
199
+ The fingerprint is derived from the module directory listing (names and
200
+ tree SHAs). Any content change inside a module folder (including version
201
+ bumps) changes its tree SHA, so a matching fingerprint guarantees the
202
+ cached inventory is still current.
203
+
204
+ Args:
205
+ fingerprint: Fingerprint of the current module directory listing
206
+
207
+ Returns:
208
+ Cached inventory JSON if the fingerprint matches, None otherwise
209
+ """
210
+ cache = get_cache_backend()
211
+ cached_data = cache.get(INVENTORY_CACHE_KEY)
212
+
213
+ if cached_data and cached_data.get("version") == fingerprint:
214
+ logger.info(f"Inventory cache HIT (fingerprint {fingerprint[:12]}...)")
215
+ return cached_data.get("content")
216
+
217
+ logger.debug("Inventory cache MISS: fingerprint changed or not in cache")
218
+ return None
219
+
220
+
221
+ def update_inventory_cache(fingerprint: str, inventory_json: str):
222
+ """Update the cached module inventory.
223
+
224
+ Args:
225
+ fingerprint: Fingerprint of the module directory listing
226
+ inventory_json: Serialized module inventory (JSON)
227
+ """
228
+ cache = get_cache_backend()
229
+ cache.set(INVENTORY_CACHE_KEY, fingerprint, inventory_json)
230
+ logger.info("Inventory cache updated")