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 +69 -0
- cache.py +230 -0
- cloud_bicep_mcp_server-0.3.0.dist-info/METADATA +1470 -0
- cloud_bicep_mcp_server-0.3.0.dist-info/RECORD +11 -0
- cloud_bicep_mcp_server-0.3.0.dist-info/WHEEL +4 -0
- cloud_bicep_mcp_server-0.3.0.dist-info/entry_points.txt +2 -0
- config.py +89 -0
- github.py +80 -0
- images/cloud-bicep-mcp-96.png +0 -0
- server.json +83 -0
- server.py +784 -0
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")
|