server-management-lib 0.2.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.
- server_management_lib/__init__.py +124 -0
- server_management_lib/audit.py +80 -0
- server_management_lib/base.py +55 -0
- server_management_lib/config.py +385 -0
- server_management_lib/executor.py +53 -0
- server_management_lib/http_clients.py +636 -0
- server_management_lib/local_executor.py +200 -0
- server_management_lib/operations.py +33 -0
- server_management_lib/output.py +61 -0
- server_management_lib/py.typed +0 -0
- server_management_lib/rate_limit.py +118 -0
- server_management_lib/redaction.py +191 -0
- server_management_lib/responses.py +126 -0
- server_management_lib/security.py +1552 -0
- server_management_lib/ssh_manager.py +447 -0
- server_management_lib/transport.py +128 -0
- server_management_lib/tunnel.py +380 -0
- server_management_lib-0.2.0.dist-info/METADATA +46 -0
- server_management_lib-0.2.0.dist-info/RECORD +21 -0
- server_management_lib-0.2.0.dist-info/WHEEL +5 -0
- server_management_lib-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH Connection Manager for Remote Server Operations
|
|
3
|
+
|
|
4
|
+
Handles SSH connections with strict security:
|
|
5
|
+
|
|
6
|
+
- Host keys are verified by default against ``ssh.known_hosts``.
|
|
7
|
+
- Key/agent authentication is used when no password is configured.
|
|
8
|
+
- Commands are validated before execution.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import stat
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import asyncssh
|
|
21
|
+
|
|
22
|
+
from .base import (
|
|
23
|
+
ExecutorBase,
|
|
24
|
+
RemoteDirEntry,
|
|
25
|
+
RemoteFileError,
|
|
26
|
+
RemotePathError,
|
|
27
|
+
is_within,
|
|
28
|
+
)
|
|
29
|
+
from .output import sanitize_output
|
|
30
|
+
from .security import SecurityValidator
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
DEFAULT_KNOWN_HOSTS = "~/.ssh/known_hosts"
|
|
35
|
+
DISABLED_KNOWN_HOSTS = {"", "none", "false", "off"}
|
|
36
|
+
# Bound every SFTP metadata/read operation so a FIFO, stuck server or dropped
|
|
37
|
+
# connection cannot block a tool call forever.
|
|
38
|
+
SFTP_OPERATION_TIMEOUT = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _sftp_operation(awaitable, *, description: str):
|
|
42
|
+
"""Await an SFTP operation with a timeout and a readable error."""
|
|
43
|
+
try:
|
|
44
|
+
return await asyncio.wait_for(awaitable, timeout=SFTP_OPERATION_TIMEOUT)
|
|
45
|
+
except TimeoutError as e:
|
|
46
|
+
raise RemoteFileError(
|
|
47
|
+
f"SFTP {description} timed out after {SFTP_OPERATION_TIMEOUT}s"
|
|
48
|
+
) from e
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SSHConfigurationError(RuntimeError):
|
|
52
|
+
"""Raised when SSH settings cannot be turned into a safe connection."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_known_hosts(path: Path) -> str | bytes:
|
|
56
|
+
"""Load a known_hosts file, skipping entries asyncssh/cryptography cannot parse.
|
|
57
|
+
|
|
58
|
+
Modern OpenSSL/cryptography rejects legacy DSA keys, and a single such line
|
|
59
|
+
makes ``read_known_hosts()`` fail for the whole file. We keep verification
|
|
60
|
+
enabled for every other entry instead of disabling it altogether.
|
|
61
|
+
|
|
62
|
+
Returns the path when the file parses cleanly, otherwise a sanitized
|
|
63
|
+
known_hosts payload (bytes) with unreadable lines removed.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
asyncssh.read_known_hosts(str(path))
|
|
67
|
+
return str(path)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.warning(
|
|
70
|
+
"Could not parse %s as a whole (%s); skipping unreadable entries",
|
|
71
|
+
path,
|
|
72
|
+
e,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
kept: list[str] = []
|
|
76
|
+
skipped_lines: list[int] = []
|
|
77
|
+
for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
|
|
78
|
+
entry = line.strip()
|
|
79
|
+
if not entry or entry.startswith("#"):
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
asyncssh.import_known_hosts(entry)
|
|
83
|
+
except Exception:
|
|
84
|
+
skipped_lines.append(lineno)
|
|
85
|
+
continue
|
|
86
|
+
kept.append(entry)
|
|
87
|
+
|
|
88
|
+
if skipped_lines:
|
|
89
|
+
# One aggregated warning per file keeps the log readable while still
|
|
90
|
+
# making the skipped entries identifiable.
|
|
91
|
+
preview = ", ".join(str(line) for line in skipped_lines[:10])
|
|
92
|
+
suffix = ", ..." if len(skipped_lines) > 10 else ""
|
|
93
|
+
logger.warning(
|
|
94
|
+
"Skipped %d unsupported known_hosts entr%s in %s (lines %s%s); "
|
|
95
|
+
"host key verification stays enabled for the remaining entries",
|
|
96
|
+
len(skipped_lines),
|
|
97
|
+
"y" if len(skipped_lines) == 1 else "ies",
|
|
98
|
+
path,
|
|
99
|
+
preview,
|
|
100
|
+
suffix,
|
|
101
|
+
)
|
|
102
|
+
if not kept:
|
|
103
|
+
raise SSHConfigurationError(
|
|
104
|
+
f"No usable host keys in {path}. Remove legacy entries or regenerate "
|
|
105
|
+
"the file with ssh-keyscan."
|
|
106
|
+
)
|
|
107
|
+
logger.info(
|
|
108
|
+
"Loaded %d known_hosts entries from %s (%d skipped)",
|
|
109
|
+
len(kept),
|
|
110
|
+
path,
|
|
111
|
+
len(skipped_lines),
|
|
112
|
+
)
|
|
113
|
+
return ("\n".join(kept) + "\n").encode()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SSHManager(ExecutorBase):
|
|
117
|
+
"""Manages SSH connections with strict security controls."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, config: dict, security: SecurityValidator):
|
|
120
|
+
"""
|
|
121
|
+
Initialize SSH manager.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config: Configuration dictionary with SSH settings
|
|
125
|
+
security: Security validator instance
|
|
126
|
+
"""
|
|
127
|
+
super().__init__(config, security)
|
|
128
|
+
self.ssh_config = config.get("ssh", {}) or {}
|
|
129
|
+
self.connection: asyncssh.SSHClientConnection | None = None
|
|
130
|
+
self._connected = False
|
|
131
|
+
self._connect_lock = asyncio.Lock()
|
|
132
|
+
self._known_hosts_cache: str | bytes | None = None
|
|
133
|
+
self._known_hosts_loaded = False
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Connection setup
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _known_hosts_option(self) -> str | bytes | None:
|
|
140
|
+
"""Return the known_hosts path, sanitized payload, or ``None``."""
|
|
141
|
+
if self._known_hosts_loaded:
|
|
142
|
+
return self._known_hosts_cache
|
|
143
|
+
|
|
144
|
+
value = self._load_known_hosts_option()
|
|
145
|
+
self._known_hosts_cache = value
|
|
146
|
+
self._known_hosts_loaded = True
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
def _load_known_hosts_option(self) -> str | bytes | None:
|
|
150
|
+
value = self.ssh_config.get("known_hosts", DEFAULT_KNOWN_HOSTS)
|
|
151
|
+
if value is None or (
|
|
152
|
+
isinstance(value, str) and value.strip().lower() in DISABLED_KNOWN_HOSTS
|
|
153
|
+
):
|
|
154
|
+
logger.warning(
|
|
155
|
+
"SSH host key verification is DISABLED. This is insecure; "
|
|
156
|
+
"set ssh.known_hosts to a known_hosts file to enable it."
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
path = Path(str(value)).expanduser()
|
|
161
|
+
if not path.exists():
|
|
162
|
+
raise SSHConfigurationError(
|
|
163
|
+
f"SSH known_hosts file not found: {path}. Add the host key first "
|
|
164
|
+
f"(ssh-keyscan -H <host> >> {path}) or set ssh.known_hosts to "
|
|
165
|
+
"'none' to disable verification (insecure)."
|
|
166
|
+
)
|
|
167
|
+
return _load_known_hosts(path)
|
|
168
|
+
|
|
169
|
+
def build_connect_kwargs(self) -> dict:
|
|
170
|
+
"""Build keyword arguments for :func:`asyncssh.connect`."""
|
|
171
|
+
kwargs: dict = {"host": self.ssh_config.get("host") or "localhost"}
|
|
172
|
+
|
|
173
|
+
kwargs["port"] = int(self.ssh_config.get("port") or 22)
|
|
174
|
+
|
|
175
|
+
username = self.ssh_config.get("username")
|
|
176
|
+
if username:
|
|
177
|
+
kwargs["username"] = username
|
|
178
|
+
|
|
179
|
+
key_path = self.ssh_config.get("key_path")
|
|
180
|
+
if key_path:
|
|
181
|
+
expanded_path = os.path.expanduser(str(key_path))
|
|
182
|
+
kwargs["client_keys"] = [expanded_path]
|
|
183
|
+
logger.info("Using SSH key: %s", expanded_path)
|
|
184
|
+
|
|
185
|
+
password = self.ssh_config.get("password")
|
|
186
|
+
if password:
|
|
187
|
+
kwargs["password"] = password
|
|
188
|
+
|
|
189
|
+
connect_timeout = self.ssh_config.get("connect_timeout")
|
|
190
|
+
if connect_timeout:
|
|
191
|
+
kwargs["connect_timeout"] = int(connect_timeout)
|
|
192
|
+
|
|
193
|
+
# Explicitly pass known_hosts so that a missing file cannot silently
|
|
194
|
+
# degrade to "no verification" (asyncssh does that by default).
|
|
195
|
+
kwargs["known_hosts"] = self._known_hosts_option()
|
|
196
|
+
|
|
197
|
+
return kwargs
|
|
198
|
+
|
|
199
|
+
async def connect(self) -> None:
|
|
200
|
+
"""Establish SSH connection to the remote server.
|
|
201
|
+
|
|
202
|
+
Concurrent tool calls share one connection. Without the lock they can
|
|
203
|
+
all observe ``_connected == False`` and open overlapping connections,
|
|
204
|
+
leaking every connection but the last one. The second check inside the
|
|
205
|
+
lock makes the call idempotent: a caller that waited for an in-flight
|
|
206
|
+
connect reuses the connection instead of opening another.
|
|
207
|
+
"""
|
|
208
|
+
async with self._connect_lock:
|
|
209
|
+
if self._connected and self.connection is not None:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
kwargs = self.build_connect_kwargs()
|
|
213
|
+
host = kwargs["host"]
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
self.connection = await asyncssh.connect(**kwargs)
|
|
217
|
+
self._connected = True
|
|
218
|
+
logger.info(
|
|
219
|
+
"Connected to %s:%s as %s",
|
|
220
|
+
host,
|
|
221
|
+
kwargs.get("port", 22),
|
|
222
|
+
kwargs.get("username", "(default)"),
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
self._connected = False
|
|
226
|
+
self.connection = None
|
|
227
|
+
logger.error("Failed to connect to %s: %s", host, e)
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
async def disconnect(self) -> None:
|
|
231
|
+
"""Close SSH connection."""
|
|
232
|
+
connection = self.connection
|
|
233
|
+
self.connection = None
|
|
234
|
+
self._connected = False
|
|
235
|
+
if connection:
|
|
236
|
+
connection.close()
|
|
237
|
+
wait_closed = getattr(connection, "wait_closed", None)
|
|
238
|
+
if wait_closed is not None:
|
|
239
|
+
try:
|
|
240
|
+
await wait_closed()
|
|
241
|
+
except Exception as e: # pragma: no cover - best effort
|
|
242
|
+
logger.debug("Error while closing SSH connection: %s", e)
|
|
243
|
+
logger.info("SSH connection closed")
|
|
244
|
+
|
|
245
|
+
async def execute_safe_command(self, command: str, timeout: int = 30) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Execute a pre-validated safe command.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
command: Pre-validated command (constructed by application code)
|
|
251
|
+
timeout: Timeout in seconds (default: 30)
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Command output
|
|
255
|
+
"""
|
|
256
|
+
if not self.security.is_command_safe(command):
|
|
257
|
+
logger.error("Attempted to execute unsafe command: %s", command)
|
|
258
|
+
self.audit.log("command_blocked", command=command)
|
|
259
|
+
return "❌ Security violation: Command blocked by security policy"
|
|
260
|
+
|
|
261
|
+
if not self._connected or not self.connection:
|
|
262
|
+
await self.connect()
|
|
263
|
+
|
|
264
|
+
if self.connection is None:
|
|
265
|
+
return "❌ Connection failed"
|
|
266
|
+
|
|
267
|
+
started = time.monotonic()
|
|
268
|
+
try:
|
|
269
|
+
result = await self.connection.run(
|
|
270
|
+
command,
|
|
271
|
+
check=False,
|
|
272
|
+
timeout=timeout,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
output: str = ""
|
|
276
|
+
if result.stdout:
|
|
277
|
+
output += str(result.stdout)
|
|
278
|
+
if result.stderr:
|
|
279
|
+
if output:
|
|
280
|
+
output += "\n--- STDERR ---\n"
|
|
281
|
+
output += str(result.stderr)
|
|
282
|
+
|
|
283
|
+
if result.exit_status != 0:
|
|
284
|
+
output += f"\n\n⚠️ Exit code: {result.exit_status}"
|
|
285
|
+
|
|
286
|
+
sanitized = sanitize_output(
|
|
287
|
+
output, self.max_output_bytes, label="command output"
|
|
288
|
+
)
|
|
289
|
+
self.audit.log(
|
|
290
|
+
"command",
|
|
291
|
+
command=command,
|
|
292
|
+
exit_status=result.exit_status,
|
|
293
|
+
duration_ms=round((time.monotonic() - started) * 1000, 1),
|
|
294
|
+
output_bytes=len(output.encode("utf-8")),
|
|
295
|
+
truncated=sanitized != output,
|
|
296
|
+
)
|
|
297
|
+
return sanitized
|
|
298
|
+
except (asyncssh.ConnectionLost, asyncssh.DisconnectError) as e:
|
|
299
|
+
# Drop the dead connection so the next call reconnects.
|
|
300
|
+
self.connection = None
|
|
301
|
+
self._connected = False
|
|
302
|
+
self.audit.log("command_connection_lost", command=command, error=str(e))
|
|
303
|
+
return f"❌ SSH connection lost: {e}"
|
|
304
|
+
except asyncssh.Error as e:
|
|
305
|
+
self.audit.log("command_error", command=command, error=str(e))
|
|
306
|
+
return f"❌ SSH error: {e}"
|
|
307
|
+
except TimeoutError:
|
|
308
|
+
self.audit.log("command_timeout", command=command, timeout=timeout)
|
|
309
|
+
return f"❌ Command timed out after {timeout}s"
|
|
310
|
+
except Exception as e:
|
|
311
|
+
self.audit.log("command_error", command=command, error=str(e))
|
|
312
|
+
return f"❌ Error executing command: {e}"
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
# Remote files (SFTP; no shell involved)
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
async def _get_sftp(self) -> asyncssh.SFTPClient:
|
|
319
|
+
if not self._connected or not self.connection:
|
|
320
|
+
await self.connect()
|
|
321
|
+
if self.connection is None:
|
|
322
|
+
raise RemoteFileError("SSH connection failed")
|
|
323
|
+
return await self.connection.start_sftp_client()
|
|
324
|
+
|
|
325
|
+
async def read_remote_file(
|
|
326
|
+
self,
|
|
327
|
+
path: str,
|
|
328
|
+
*,
|
|
329
|
+
allowed_base: str | None = None,
|
|
330
|
+
max_bytes: int | None = None,
|
|
331
|
+
) -> str:
|
|
332
|
+
"""Read a remote file over SFTP with an optional base-path check."""
|
|
333
|
+
limit = max_bytes or self.max_output_bytes
|
|
334
|
+
async with await self._get_sftp() as sftp:
|
|
335
|
+
real_path = str(
|
|
336
|
+
await _sftp_operation(sftp.realpath(path), description="realpath")
|
|
337
|
+
)
|
|
338
|
+
real_base = (
|
|
339
|
+
str(
|
|
340
|
+
await _sftp_operation(
|
|
341
|
+
sftp.realpath(allowed_base), description="realpath"
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
if allowed_base is not None
|
|
345
|
+
else None
|
|
346
|
+
)
|
|
347
|
+
if real_base is not None and not is_within(real_path, real_base):
|
|
348
|
+
self.audit.log("file_read_blocked", path=path, resolved=real_path)
|
|
349
|
+
raise RemotePathError(
|
|
350
|
+
f"Remote path escapes the allowed directory: {real_path}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
attrs = await _sftp_operation(sftp.stat(real_path), description="stat")
|
|
354
|
+
if not stat.S_ISREG(attrs.permissions or 0):
|
|
355
|
+
self.audit.log(
|
|
356
|
+
"file_read_blocked",
|
|
357
|
+
path=path,
|
|
358
|
+
resolved=real_path,
|
|
359
|
+
reason="not_regular_file",
|
|
360
|
+
)
|
|
361
|
+
raise RemoteFileError(f"Remote path is not a regular file: {real_path}")
|
|
362
|
+
size = attrs.size or 0
|
|
363
|
+
async with sftp.open(real_path, "rb") as handle:
|
|
364
|
+
data = await _sftp_operation(handle.read(limit + 1), description="read")
|
|
365
|
+
|
|
366
|
+
text = data.decode("utf-8", errors="replace")
|
|
367
|
+
sanitized = sanitize_output(text, limit, label="file contents")
|
|
368
|
+
self.audit.log(
|
|
369
|
+
"file_read",
|
|
370
|
+
path=real_path,
|
|
371
|
+
size=size,
|
|
372
|
+
truncated=len(data) > limit or sanitized != text,
|
|
373
|
+
)
|
|
374
|
+
return sanitized
|
|
375
|
+
|
|
376
|
+
async def list_remote_dir(
|
|
377
|
+
self,
|
|
378
|
+
path: str,
|
|
379
|
+
*,
|
|
380
|
+
allowed_base: str | None = None,
|
|
381
|
+
) -> list[RemoteDirEntry]:
|
|
382
|
+
"""List a remote directory over SFTP with an optional base-path check."""
|
|
383
|
+
async with await self._get_sftp() as sftp:
|
|
384
|
+
real_path = str(
|
|
385
|
+
await _sftp_operation(sftp.realpath(path), description="realpath")
|
|
386
|
+
)
|
|
387
|
+
real_base = (
|
|
388
|
+
str(
|
|
389
|
+
await _sftp_operation(
|
|
390
|
+
sftp.realpath(allowed_base), description="realpath"
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
if allowed_base is not None
|
|
394
|
+
else None
|
|
395
|
+
)
|
|
396
|
+
if real_base is not None and not is_within(real_path, real_base):
|
|
397
|
+
self.audit.log("dir_list_blocked", path=path, resolved=real_path)
|
|
398
|
+
raise RemotePathError(
|
|
399
|
+
f"Remote path escapes the allowed directory: {real_path}"
|
|
400
|
+
)
|
|
401
|
+
names = await _sftp_operation(
|
|
402
|
+
sftp.readdir(real_path), description="readdir"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
entries = [
|
|
406
|
+
RemoteDirEntry(
|
|
407
|
+
name=entry.filename,
|
|
408
|
+
longname=entry.longname,
|
|
409
|
+
size=entry.attrs.size or 0,
|
|
410
|
+
)
|
|
411
|
+
for entry in names
|
|
412
|
+
if entry.filename not in {".", ".."}
|
|
413
|
+
]
|
|
414
|
+
entries.sort(key=lambda entry: entry.name)
|
|
415
|
+
self.audit.log("dir_list", path=real_path, entries=len(entries))
|
|
416
|
+
return entries
|
|
417
|
+
|
|
418
|
+
async def check_service_exists(self, service: str) -> bool:
|
|
419
|
+
"""
|
|
420
|
+
Check if a service directory exists under ``security.services_path``.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
service: Service name (should be validated already)
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
True if service exists
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
service_path = f"{self.services_base_path.rstrip('/')}/{service}"
|
|
430
|
+
cmd = f"test -d {shlex.quote(service_path)}"
|
|
431
|
+
result = await self.execute_safe_command(cmd)
|
|
432
|
+
if "❌" in result:
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
match = re.search(r"⚠️ Exit code:\s*(-?\d+)", result)
|
|
436
|
+
return match is None or int(match.group(1)) == 0
|
|
437
|
+
except Exception:
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
async def __aenter__(self):
|
|
441
|
+
"""Async context manager entry."""
|
|
442
|
+
await self.connect()
|
|
443
|
+
return self
|
|
444
|
+
|
|
445
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
446
|
+
"""Async context manager exit."""
|
|
447
|
+
await self.disconnect()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Streamable HTTP transport helpers for the MCP servers.
|
|
2
|
+
|
|
3
|
+
The default transport stays stdio. When an operator opts into Streamable HTTP,
|
|
4
|
+
this module enforces two rules:
|
|
5
|
+
|
|
6
|
+
- a non-loopback bind address requires a bearer token;
|
|
7
|
+
- when a token is configured, every HTTP request must present it.
|
|
8
|
+
|
|
9
|
+
The middleware is a plain ASGI application, so the library does not depend on
|
|
10
|
+
Starlette. Uvicorn is imported lazily where it is actually used (it is already
|
|
11
|
+
a dependency of the ``mcp`` package that the servers install).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hmac
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
21
|
+
DEFAULT_HTTP_PATH = "/mcp"
|
|
22
|
+
DEFAULT_HTTP_PORT = 8000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_loopback_host(host: str) -> bool:
|
|
26
|
+
"""Return True when ``host`` is a loopback bind address."""
|
|
27
|
+
return host in LOOPBACK_HOSTS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_http_binding(host: str, token: str | None) -> None:
|
|
31
|
+
"""Refuse a non-loopback Streamable HTTP bind without a token.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: The binding would expose the tools unauthenticated.
|
|
35
|
+
"""
|
|
36
|
+
if is_loopback_host(host) or token:
|
|
37
|
+
return
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Refusing to bind Streamable HTTP to '{host}' without an access token. "
|
|
40
|
+
"Use a loopback host (127.0.0.1/::1/localhost) or configure a token "
|
|
41
|
+
"(transport.token, MCP_HTTP_TOKEN or --http-token-env)."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BearerTokenMiddleware:
|
|
46
|
+
"""Reject HTTP requests that do not carry the configured bearer token."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, app: Any, *, token: str, header: str = "authorization"):
|
|
49
|
+
if not token:
|
|
50
|
+
raise ValueError("A non-empty token is required")
|
|
51
|
+
self.app = app
|
|
52
|
+
self.token = token
|
|
53
|
+
self.header = header.lower().encode("latin-1")
|
|
54
|
+
self._token = token.encode("utf-8")
|
|
55
|
+
|
|
56
|
+
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
|
57
|
+
if scope.get("type") != "http":
|
|
58
|
+
await self.app(scope, receive, send)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
provided = b""
|
|
62
|
+
for name, value in scope.get("headers") or []:
|
|
63
|
+
if name.lower() == self.header:
|
|
64
|
+
provided = value
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
# RFC 9110 auth schemes are case-insensitive: ``bearer`` and ``Bearer``
|
|
68
|
+
# are equivalent. The credential itself is compared in constant time.
|
|
69
|
+
scheme = b""
|
|
70
|
+
credential = b""
|
|
71
|
+
if provided:
|
|
72
|
+
parts = provided.split(None, 1)
|
|
73
|
+
if len(parts) == 2:
|
|
74
|
+
scheme, credential = parts
|
|
75
|
+
|
|
76
|
+
if scheme.lower() != b"bearer" or not hmac.compare_digest(
|
|
77
|
+
credential, self._token
|
|
78
|
+
):
|
|
79
|
+
logger.warning("Rejected unauthenticated Streamable HTTP request")
|
|
80
|
+
body = b'{"error":"unauthorized"}'
|
|
81
|
+
await send(
|
|
82
|
+
{
|
|
83
|
+
"type": "http.response.start",
|
|
84
|
+
"status": 401,
|
|
85
|
+
"headers": [
|
|
86
|
+
(b"content-type", b"application/json"),
|
|
87
|
+
(b"content-length", str(len(body)).encode()),
|
|
88
|
+
(b"www-authenticate", b"Bearer"),
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
await send({"type": "http.response.body", "body": body})
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
await self.app(scope, receive, send)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def run_streamable_http(
|
|
99
|
+
server: Any,
|
|
100
|
+
*,
|
|
101
|
+
host: str = "127.0.0.1",
|
|
102
|
+
port: int = DEFAULT_HTTP_PORT,
|
|
103
|
+
token: str | None = None,
|
|
104
|
+
path: str = DEFAULT_HTTP_PATH,
|
|
105
|
+
json_response: bool = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Serve an MCPServer over Streamable HTTP with the safety rules above."""
|
|
108
|
+
validate_http_binding(host, token)
|
|
109
|
+
|
|
110
|
+
import uvicorn
|
|
111
|
+
|
|
112
|
+
app = server.streamable_http_app(
|
|
113
|
+
streamable_http_path=path,
|
|
114
|
+
json_response=json_response,
|
|
115
|
+
host=host,
|
|
116
|
+
)
|
|
117
|
+
if token:
|
|
118
|
+
app = BearerTokenMiddleware(app, token=token)
|
|
119
|
+
|
|
120
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
|
121
|
+
logger.info(
|
|
122
|
+
"Serving Streamable HTTP on %s:%d%s (bearer token: %s)",
|
|
123
|
+
host,
|
|
124
|
+
port,
|
|
125
|
+
path,
|
|
126
|
+
"required" if token else "not set, loopback only",
|
|
127
|
+
)
|
|
128
|
+
await uvicorn.Server(config).serve()
|