gemini-computer-use 0.1.2 → 0.2.4
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.
- package/README.md +271 -90
- package/core/agent.py +258 -33
- package/core/mcp_tools.py +1769 -0
- package/core/policies.py +5 -2
- package/core/server.py +862 -5
- package/gateway.py +1491 -0
- package/install.ps1 +116 -36
- package/install.sh +116 -28
- package/package.json +4 -3
package/core/server.py
CHANGED
|
@@ -1,10 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
core/server.py - Antigravity Mesh Standalone MCP Server (superset module).
|
|
5
|
+
|
|
6
|
+
This module serves two purposes:
|
|
7
|
+
|
|
8
|
+
1. Typed coordinator vitals helper used by the cloud/tunnel deployment:
|
|
9
|
+
``get_coordinator_vitals() -> NodeVitals`` (imported by ``agy_mcp_server.py``
|
|
10
|
+
and by the typed integration / resilience tests).
|
|
11
|
+
|
|
12
|
+
2. A fully self-contained, stdlib-only MCP (Model Context Protocol) HTTP server
|
|
13
|
+
for the local "standalone" mode, launched by ``install.sh`` as::
|
|
14
|
+
|
|
15
|
+
python3 -m core.server --host 127.0.0.1 --port=8096
|
|
16
|
+
|
|
17
|
+
Endpoints:
|
|
18
|
+
GET / -> health JSON
|
|
19
|
+
GET /health -> health JSON
|
|
20
|
+
GET /sse, /mcp -> text/event-stream (MCP SSE transport)
|
|
21
|
+
POST /messages, /sse, /mcp -> JSON-RPC 2.0
|
|
22
|
+
OPTIONS -> CORS preflight
|
|
23
|
+
other paths -> 404
|
|
24
|
+
|
|
25
|
+
SECURITY
|
|
26
|
+
--------
|
|
27
|
+
``--host`` defaults to loopback (``127.0.0.1``) and must NEVER be changed to
|
|
28
|
+
``0.0.0.0`` for a default install: this server can execute arbitrary shell
|
|
29
|
+
commands (``bash_exec``) and read arbitrary files (``read_file``). Binding it to
|
|
30
|
+
a public interface without ``--token`` (or ``MESH_TOKEN``) plus a firewall
|
|
31
|
+
exposes remote code execution to the whole network. If binding a public
|
|
32
|
+
interface is really required, always configure a token and restrict access at
|
|
33
|
+
the firewall level.
|
|
34
|
+
|
|
35
|
+
The module depends only on the Python standard library plus the pre-existing
|
|
36
|
+
local helpers ``core.schemas`` (pydantic) and ``core.vitals``. Both are treated
|
|
37
|
+
as optional so the server still starts under a bare interpreter (e.g.
|
|
38
|
+
``/usr/bin/python3`` without pydantic): ``get_coordinator_vitals()`` then
|
|
39
|
+
returns a light-weight stand-in object exposing the very same attributes.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import hmac
|
|
46
|
+
import json
|
|
47
|
+
import logging
|
|
1
48
|
import os
|
|
49
|
+
import platform
|
|
50
|
+
import re
|
|
2
51
|
import shutil
|
|
3
52
|
import socket
|
|
53
|
+
import subprocess
|
|
54
|
+
import sys
|
|
55
|
+
import time
|
|
4
56
|
from datetime import datetime, timezone
|
|
5
|
-
from
|
|
57
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
60
|
+
from urllib.parse import parse_qs, urlparse
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger("antigravity-mesh.standalone")
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Optional local dependencies.
|
|
66
|
+
#
|
|
67
|
+
# The MCP server must keep working when pydantic (and therefore
|
|
68
|
+
# ``core.schemas``) is not installed, which is the case for the bare system
|
|
69
|
+
# interpreter that systemd uses on a fresh standalone install. Importing them
|
|
70
|
+
# lazily/guarded keeps ``python3 -m core.server`` functional while preserving
|
|
71
|
+
# the exact ``NodeVitals`` return type whenever pydantic *is* available.
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
try: # pragma: no cover - exercised implicitly by the environment
|
|
74
|
+
from core.schemas import NodeVitals # type: ignore
|
|
75
|
+
except Exception as _schemas_import_error: # pragma: no cover
|
|
76
|
+
NodeVitals = None # type: ignore
|
|
77
|
+
logger.debug("core.schemas unavailable (%s); using fallback vitals object", _schemas_import_error)
|
|
78
|
+
|
|
79
|
+
try: # pragma: no cover - exercised implicitly by the environment
|
|
80
|
+
from core.vitals import get_host_vitals as _core_get_host_vitals # type: ignore
|
|
81
|
+
except Exception as _vitals_import_error: # pragma: no cover
|
|
82
|
+
_core_get_host_vitals = None # type: ignore
|
|
83
|
+
logger.debug("core.vitals unavailable (%s); using inline vitals collector", _vitals_import_error)
|
|
84
|
+
|
|
85
|
+
# Shared tool logic. core.mcp_tools is stdlib-only, so this import always works.
|
|
86
|
+
from core import mcp_tools # noqa: E402
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _FallbackVitals(object):
|
|
90
|
+
"""Minimal stand-in for :class:`core.schemas.NodeVitals` without pydantic."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, **fields: Any) -> None:
|
|
93
|
+
self.__dict__.update(fields)
|
|
94
|
+
|
|
95
|
+
def model_dump(self) -> Dict[str, Any]:
|
|
96
|
+
return dict(self.__dict__)
|
|
97
|
+
|
|
98
|
+
def model_dump_json(self, indent: Optional[int] = None) -> str:
|
|
99
|
+
return json.dumps(self.__dict__, ensure_ascii=False, default=str, indent=indent)
|
|
100
|
+
|
|
101
|
+
def dict(self) -> Dict[str, Any]:
|
|
102
|
+
return dict(self.__dict__)
|
|
6
103
|
|
|
7
|
-
def
|
|
104
|
+
def json(self, indent: Optional[int] = None) -> str:
|
|
105
|
+
return self.model_dump_json(indent=indent)
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
return "NodeVitals(%r)" % (self.__dict__,)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fallback_host_vitals() -> Dict[str, Any]:
|
|
112
|
+
"""Stdlib-only equivalent of ``core.vitals.get_host_vitals`` (last resort)."""
|
|
113
|
+
system = platform.system()
|
|
114
|
+
load = {"1m": 0.0, "5m": 0.0, "15m": 0.0}
|
|
115
|
+
try:
|
|
116
|
+
if hasattr(os, "getloadavg"):
|
|
117
|
+
la = os.getloadavg()
|
|
118
|
+
load = {"1m": round(la[0], 2), "5m": round(la[1], 2), "15m": round(la[2], 2)}
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
total_mb = 0.0
|
|
123
|
+
free_mb = 0.0
|
|
124
|
+
if system == "Linux":
|
|
125
|
+
try:
|
|
126
|
+
with open("/proc/meminfo", "r") as f:
|
|
127
|
+
for line in f:
|
|
128
|
+
if line.startswith("MemTotal:"):
|
|
129
|
+
total_mb = round(int(line.split()[1]) / 1024.0, 1)
|
|
130
|
+
elif line.startswith("MemAvailable:"):
|
|
131
|
+
free_mb = round(int(line.split()[1]) / 1024.0, 1)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
used_mb = max(0.0, round(total_mb - free_mb, 1))
|
|
136
|
+
used_pct = round((used_mb / total_mb) * 100.0, 1) if total_mb > 0 else 0.0
|
|
137
|
+
try:
|
|
138
|
+
du = shutil.disk_usage("C:\\" if system == "Windows" else "/")
|
|
139
|
+
disk = {
|
|
140
|
+
"total_gb": round(du.total / (1024 ** 3), 2),
|
|
141
|
+
"used_gb": round(du.used / (1024 ** 3), 2),
|
|
142
|
+
"free_gb": round(du.free / (1024 ** 3), 2),
|
|
143
|
+
"used_pct": round((du.used / du.total) * 100.0, 1) if du.total > 0 else 0.0,
|
|
144
|
+
}
|
|
145
|
+
except Exception:
|
|
146
|
+
disk = {"total_gb": 0.0, "used_gb": 0.0, "free_gb": 0.0, "used_pct": 0.0}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"hostname": socket.gethostname(),
|
|
150
|
+
"os": system,
|
|
151
|
+
"cpu_load": load,
|
|
152
|
+
"ram": {"total_mb": total_mb, "used_mb": used_mb, "free_mb": free_mb, "used_pct": used_pct},
|
|
153
|
+
"disk": disk,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_coordinator_vitals() -> "NodeVitals":
|
|
158
|
+
"""Collect this coordinator's typed vitals.
|
|
159
|
+
|
|
160
|
+
Behaviour is unchanged from the tunnel deployment: it returns a
|
|
161
|
+
``core.schemas.NodeVitals`` instance whenever pydantic is importable. When
|
|
162
|
+
pydantic is unavailable (bare system interpreter) an attribute-compatible
|
|
163
|
+
fallback object is returned instead so the standalone server keeps running.
|
|
164
|
+
"""
|
|
8
165
|
hostname = socket.gethostname()
|
|
9
166
|
ip = "127.0.0.1"
|
|
10
167
|
try:
|
|
@@ -39,8 +196,8 @@ def get_coordinator_vitals() -> NodeVitals:
|
|
|
39
196
|
free_disk_gb = 0.0
|
|
40
197
|
try:
|
|
41
198
|
d = shutil.disk_usage("/")
|
|
42
|
-
total_disk_gb = round(d.total / (1024**3), 2)
|
|
43
|
-
free_disk_gb = round(d.free / (1024**3), 2)
|
|
199
|
+
total_disk_gb = round(d.total / (1024 ** 3), 2)
|
|
200
|
+
free_disk_gb = round(d.free / (1024 ** 3), 2)
|
|
44
201
|
except Exception:
|
|
45
202
|
pass
|
|
46
203
|
|
|
@@ -54,7 +211,7 @@ def get_coordinator_vitals() -> NodeVitals:
|
|
|
54
211
|
except Exception:
|
|
55
212
|
pass
|
|
56
213
|
|
|
57
|
-
|
|
214
|
+
fields = dict(
|
|
58
215
|
hostname=hostname,
|
|
59
216
|
ip=ip,
|
|
60
217
|
is_online=True,
|
|
@@ -67,3 +224,703 @@ def get_coordinator_vitals() -> NodeVitals:
|
|
|
67
224
|
uptime=uptime_str,
|
|
68
225
|
timestamp=datetime.now(timezone.utc),
|
|
69
226
|
)
|
|
227
|
+
if NodeVitals is not None:
|
|
228
|
+
return NodeVitals(**fields)
|
|
229
|
+
return _FallbackVitals(**fields) # type: ignore[return-value]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Output helpers (mirror core/agent.py sanitize_output/truncate_output without
|
|
234
|
+
# importing it, because core.agent imports websockets at module import time).
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
# Chunk size for one response. Larger than before (12000) so ordinary command
|
|
238
|
+
# output arrives whole; anything bigger is paginated via next_cursor rather than
|
|
239
|
+
# silently truncated, and very large output is spooled to a file on the node.
|
|
240
|
+
MAX_OUTPUT_CHARS = 50000
|
|
241
|
+
|
|
242
|
+
ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def sanitize_output(text: str) -> str:
|
|
246
|
+
"""Strip ANSI escape codes and non-printable control characters."""
|
|
247
|
+
if not text:
|
|
248
|
+
return ""
|
|
249
|
+
text = ANSI_ESCAPE_RE.sub("", text)
|
|
250
|
+
return "".join(
|
|
251
|
+
ch for ch in text if ch in ("\n", "\r", "\t") or (ord(ch) >= 32 and ord(ch) != 127)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def truncate_output(text: str, max_chars: int = MAX_OUTPUT_CHARS) -> str:
|
|
256
|
+
"""Keep head+tail and insert an explicit ``... Output truncated ...`` notice."""
|
|
257
|
+
if len(text) <= max_chars:
|
|
258
|
+
return text
|
|
259
|
+
head_size = int(max_chars * 0.7) # 8400 chars
|
|
260
|
+
tail_size = int(max_chars * 0.25) # 3000 chars
|
|
261
|
+
omitted = len(text) - (head_size + tail_size)
|
|
262
|
+
omitted_lines = text[head_size:-tail_size].count("\n")
|
|
263
|
+
notice = (
|
|
264
|
+
f"\n\n... [Output truncated: {omitted} characters / ~{omitted_lines} lines hidden "
|
|
265
|
+
f"to prevent context overflow. Use head, tail, grep, or write to file for full data] ...\n\n"
|
|
266
|
+
)
|
|
267
|
+
return text[:head_size] + notice + text[-tail_size:]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _workspace_dir() -> str:
|
|
271
|
+
"""Base directory used to resolve relative paths (current working dir)."""
|
|
272
|
+
return os.getcwd()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Tool logic now lives in core/mcp_tools.py; the helpers below only delegate.
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def _load_orchestration_skill() -> str:
|
|
280
|
+
"""Read the live orchestrator skill through the shared tool module."""
|
|
281
|
+
return mcp_tools.call_tool("get_orchestration_skill", {})
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# MCP server
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
# Metadata advertised through ``tools/list`` - the single source of truth is
|
|
289
|
+
# core/mcp_tools.TOOLS (name/description/inputSchema), shared with the tunnel node.
|
|
290
|
+
TOOL_DEFINITIONS: List[Dict[str, Any]] = mcp_tools.TOOLS
|
|
291
|
+
|
|
292
|
+
_REQUIRED_ARGS: Dict[str, Tuple[str, ...]] = {
|
|
293
|
+
"bash_exec": ("command",),
|
|
294
|
+
"read_file": ("path",),
|
|
295
|
+
"write_file": ("path", "content"),
|
|
296
|
+
"edit_file": ("path", "old_string", "new_string"),
|
|
297
|
+
"grep_search": ("pattern",),
|
|
298
|
+
"glob_find": ("pattern",),
|
|
299
|
+
"run_job": ("command",),
|
|
300
|
+
"job_output": ("job_id",),
|
|
301
|
+
"job_kill": ("job_id",),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _make_tool_callable(tool_name: str) -> Callable[..., Any]:
|
|
306
|
+
"""Wrap a shared tool as an old-style callable (``f(**kwargs) -> dict``)."""
|
|
307
|
+
|
|
308
|
+
def _call(**kwargs: Any) -> Any:
|
|
309
|
+
return mcp_tools.call_tool(tool_name, kwargs)
|
|
310
|
+
|
|
311
|
+
_call.__name__ = str(tool_name)
|
|
312
|
+
return _call
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _InvalidParams(Exception):
|
|
316
|
+
"""Raised internally when a ``tools/call`` argument fails validation."""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class AntigravityMeshServer(object):
|
|
320
|
+
"""Standalone MCP server: a registry of tools plus a JSON-RPC dispatcher."""
|
|
321
|
+
|
|
322
|
+
name = "antigravity_mesh"
|
|
323
|
+
version = "1.0.0"
|
|
324
|
+
|
|
325
|
+
def __init__(self, name: str = "antigravity_mesh") -> None:
|
|
326
|
+
self.name = name
|
|
327
|
+
self.tools: Dict[str, Callable[..., Any]] = {}
|
|
328
|
+
self._specs: Dict[str, Dict[str, Any]] = {
|
|
329
|
+
spec["name"]: spec for spec in TOOL_DEFINITIONS
|
|
330
|
+
}
|
|
331
|
+
self._spec_order: List[str] = [spec["name"] for spec in TOOL_DEFINITIONS]
|
|
332
|
+
self._register_default_tools()
|
|
333
|
+
|
|
334
|
+
# -- tool registry ----------------------------------------------------
|
|
335
|
+
def _register_default_tools(self) -> None:
|
|
336
|
+
for spec in TOOL_DEFINITIONS:
|
|
337
|
+
self.register_tool(spec["name"], _make_tool_callable(spec["name"]))
|
|
338
|
+
|
|
339
|
+
def register_tool(self, name: str, func: Callable[..., Any]) -> None:
|
|
340
|
+
self.tools[name] = func
|
|
341
|
+
if name not in self._specs:
|
|
342
|
+
self._specs[name] = {
|
|
343
|
+
"name": name,
|
|
344
|
+
"title": name,
|
|
345
|
+
"description": "Custom tool.",
|
|
346
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
347
|
+
}
|
|
348
|
+
if name not in self._spec_order:
|
|
349
|
+
self._spec_order.append(name)
|
|
350
|
+
|
|
351
|
+
def list_tool_specs(self) -> List[Dict[str, Any]]:
|
|
352
|
+
specs = []
|
|
353
|
+
for name in self._spec_order:
|
|
354
|
+
if name in self.tools:
|
|
355
|
+
specs.append(self._specs[name])
|
|
356
|
+
return specs
|
|
357
|
+
|
|
358
|
+
# -- tool implementations (delegate to the shared implementation) ------
|
|
359
|
+
def system_vitals(self) -> Dict[str, Any]:
|
|
360
|
+
return mcp_tools.call_tool("system_vitals", {})
|
|
361
|
+
|
|
362
|
+
def bash_exec(self, command: str) -> Dict[str, Any]:
|
|
363
|
+
return mcp_tools.call_tool("bash_exec", {"command": command})
|
|
364
|
+
|
|
365
|
+
def get_orchestration_skill(self) -> str:
|
|
366
|
+
return mcp_tools.call_tool("get_orchestration_skill", {})
|
|
367
|
+
|
|
368
|
+
def list_dir(self, path: str = "") -> Dict[str, Any]:
|
|
369
|
+
return mcp_tools.call_tool("list_dir", {"path": path})
|
|
370
|
+
|
|
371
|
+
def read_file(self, path: str, start_line: int = 1, end_line: Optional[int] = None) -> Dict[str, Any]:
|
|
372
|
+
return mcp_tools.call_tool(
|
|
373
|
+
"read_file", {"path": path, "start_line": start_line, "end_line": end_line}
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# -- JSON-RPC dispatch ------------------------------------------------
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _error(req_id: Any, code: int, message: str) -> Dict[str, Any]:
|
|
379
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
380
|
+
|
|
381
|
+
def handle_jsonrpc(self, body: Any) -> Tuple[int, Optional[Dict[str, Any]]]:
|
|
382
|
+
"""Handle one JSON-RPC message.
|
|
383
|
+
|
|
384
|
+
Returns ``(http_status, response_or_None)``. ``(204, None)`` means the
|
|
385
|
+
message was a notification and no body must be sent.
|
|
386
|
+
"""
|
|
387
|
+
try:
|
|
388
|
+
return self._handle_jsonrpc(body)
|
|
389
|
+
except Exception: # never let an unexpected failure drop the connection
|
|
390
|
+
logger.exception("Unhandled error while processing JSON-RPC request")
|
|
391
|
+
return 200, self._error(None, -32603, "Internal error")
|
|
392
|
+
|
|
393
|
+
def _handle_jsonrpc(self, body: Any) -> Tuple[int, Optional[Dict[str, Any]]]:
|
|
394
|
+
# -- parse stage ---------------------------------------------------
|
|
395
|
+
if isinstance(body, (bytes, bytearray)):
|
|
396
|
+
try:
|
|
397
|
+
body = json.loads(body.decode("utf-8"))
|
|
398
|
+
except Exception:
|
|
399
|
+
return 200, self._error(None, -32700, "Parse error")
|
|
400
|
+
elif isinstance(body, str):
|
|
401
|
+
try:
|
|
402
|
+
body = json.loads(body)
|
|
403
|
+
except Exception:
|
|
404
|
+
return 200, self._error(None, -32700, "Parse error")
|
|
405
|
+
|
|
406
|
+
# -- structural stage ---------------------------------------------
|
|
407
|
+
if isinstance(body, list):
|
|
408
|
+
# Batches are explicitly unsupported, but the response is a normal
|
|
409
|
+
# JSON-RPC error and the connection stays alive.
|
|
410
|
+
return 200, self._error(None, -32600, "Invalid Request: batch requests are not supported")
|
|
411
|
+
if not isinstance(body, dict):
|
|
412
|
+
return 200, self._error(None, -32600, "Invalid Request")
|
|
413
|
+
|
|
414
|
+
# ONLY an absent "id" means notification; an explicit "id": null is a
|
|
415
|
+
# real request that must receive a response.
|
|
416
|
+
if "id" not in body:
|
|
417
|
+
return 204, None
|
|
418
|
+
|
|
419
|
+
req_id = body.get("id")
|
|
420
|
+
method = body.get("method")
|
|
421
|
+
if not isinstance(method, str) or not method:
|
|
422
|
+
return 200, self._error(req_id, -32600, "Invalid Request: missing or invalid method")
|
|
423
|
+
|
|
424
|
+
params = body.get("params")
|
|
425
|
+
if params is None:
|
|
426
|
+
params = {}
|
|
427
|
+
if not isinstance(params, dict):
|
|
428
|
+
return 200, self._error(req_id, -32602, "Invalid params: 'params' must be an object")
|
|
429
|
+
|
|
430
|
+
# -- method dispatch ----------------------------------------------
|
|
431
|
+
if method == "initialize":
|
|
432
|
+
return 200, {
|
|
433
|
+
"jsonrpc": "2.0",
|
|
434
|
+
"id": req_id,
|
|
435
|
+
"result": {
|
|
436
|
+
"protocolVersion": "2024-11-05",
|
|
437
|
+
"capabilities": {
|
|
438
|
+
"tools": {"listChanged": False},
|
|
439
|
+
"prompts": {"listChanged": False},
|
|
440
|
+
"resources": {"subscribe": False, "listChanged": False},
|
|
441
|
+
},
|
|
442
|
+
"serverInfo": {"name": self.name, "version": self.version},
|
|
443
|
+
"instructions": "Standalone Local Antigravity Mesh Server.",
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if method in ("notifications/initialized", "notifications/cancelled"):
|
|
448
|
+
# Reached only when the client (incorrectly) sent an id: acknowledge.
|
|
449
|
+
return 200, {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
|
450
|
+
|
|
451
|
+
if method == "ping":
|
|
452
|
+
return 200, {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
|
453
|
+
|
|
454
|
+
if method == "tools/list":
|
|
455
|
+
return 200, {
|
|
456
|
+
"jsonrpc": "2.0",
|
|
457
|
+
"id": req_id,
|
|
458
|
+
"result": {"tools": self.list_tool_specs()},
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if method == "tools/call":
|
|
462
|
+
return self._handle_tools_call(req_id, params)
|
|
463
|
+
|
|
464
|
+
if method == "prompts/list":
|
|
465
|
+
return 200, {
|
|
466
|
+
"jsonrpc": "2.0",
|
|
467
|
+
"id": req_id,
|
|
468
|
+
"result": {
|
|
469
|
+
"prompts": [
|
|
470
|
+
{"name": "antigravity-orchestrator", "description": "Local Standalone Orchestrator"}
|
|
471
|
+
]
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if method == "prompts/get":
|
|
476
|
+
return 200, {
|
|
477
|
+
"jsonrpc": "2.0",
|
|
478
|
+
"id": req_id,
|
|
479
|
+
"result": {
|
|
480
|
+
"description": "Antigravity Orchestrator Persona",
|
|
481
|
+
"messages": [
|
|
482
|
+
{
|
|
483
|
+
"role": "user",
|
|
484
|
+
"content": {"type": "text", "text": self.get_orchestration_skill()},
|
|
485
|
+
}
|
|
486
|
+
],
|
|
487
|
+
},
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if method == "resources/list":
|
|
491
|
+
return 200, {
|
|
492
|
+
"jsonrpc": "2.0",
|
|
493
|
+
"id": req_id,
|
|
494
|
+
"result": {
|
|
495
|
+
"resources": [
|
|
496
|
+
{
|
|
497
|
+
"uri": "resource://skills/orchestrator.md",
|
|
498
|
+
"name": "orchestrator.md",
|
|
499
|
+
"mimeType": "text/markdown",
|
|
500
|
+
}
|
|
501
|
+
]
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if method == "resources/read":
|
|
506
|
+
return 200, {
|
|
507
|
+
"jsonrpc": "2.0",
|
|
508
|
+
"id": req_id,
|
|
509
|
+
"result": {
|
|
510
|
+
"contents": [
|
|
511
|
+
{
|
|
512
|
+
"uri": params.get("uri", ""),
|
|
513
|
+
"mimeType": "text/markdown",
|
|
514
|
+
"text": self.get_orchestration_skill(),
|
|
515
|
+
}
|
|
516
|
+
]
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return 200, self._error(req_id, -32601, "Method not found: %s" % method)
|
|
521
|
+
|
|
522
|
+
# -- tools/call -------------------------------------------------------
|
|
523
|
+
def _handle_tools_call(self, req_id: Any, params: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]:
|
|
524
|
+
name = params.get("name")
|
|
525
|
+
if not isinstance(name, str) or not name:
|
|
526
|
+
return 200, self._error(req_id, -32602, "Invalid params: 'name' is required")
|
|
527
|
+
if name not in self.tools:
|
|
528
|
+
return 200, self._error(req_id, -32601, "Unknown tool: %s" % name)
|
|
529
|
+
|
|
530
|
+
arguments = params.get("arguments", {})
|
|
531
|
+
if arguments is None:
|
|
532
|
+
arguments = {}
|
|
533
|
+
if not isinstance(arguments, dict):
|
|
534
|
+
return 200, self._error(req_id, -32602, "Invalid params: 'arguments' must be an object")
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
self._validate_arguments(name, arguments)
|
|
538
|
+
except _InvalidParams as exc:
|
|
539
|
+
return 200, self._error(req_id, -32602, "Invalid params: %s" % exc)
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
value = mcp_tools.call_tool(name, arguments)
|
|
543
|
+
except Exception as exc: # defensive: call_tool itself never raises
|
|
544
|
+
# A failing tool is an MCP tool error, NOT a JSON-RPC error.
|
|
545
|
+
logger.exception("Tool %r raised", name)
|
|
546
|
+
return 200, {
|
|
547
|
+
"jsonrpc": "2.0",
|
|
548
|
+
"id": req_id,
|
|
549
|
+
"result": {
|
|
550
|
+
"content": [
|
|
551
|
+
{"type": "text", "text": "Tool execution failed: %s: %s" % (type(exc).__name__, exc)}
|
|
552
|
+
],
|
|
553
|
+
"isError": True,
|
|
554
|
+
},
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return 200, {
|
|
558
|
+
"jsonrpc": "2.0",
|
|
559
|
+
"id": req_id,
|
|
560
|
+
"result": {
|
|
561
|
+
"content": [{"type": "text", "text": self._result_to_text(value)}],
|
|
562
|
+
"isError": self._is_error_result(value),
|
|
563
|
+
},
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
def _validate_arguments(self, name: str, arguments: Dict[str, Any]) -> None:
|
|
567
|
+
for key in _REQUIRED_ARGS.get(name, ()): # required arguments
|
|
568
|
+
if key not in arguments or arguments[key] is None:
|
|
569
|
+
raise _InvalidParams("'%s' is required" % key)
|
|
570
|
+
if name == "bash_exec":
|
|
571
|
+
command = arguments.get("command")
|
|
572
|
+
if not isinstance(command, str) or not command.strip():
|
|
573
|
+
raise _InvalidParams("'command' is required and must be a non-empty string")
|
|
574
|
+
if name in ("list_dir", "read_file"):
|
|
575
|
+
path = arguments.get("path")
|
|
576
|
+
if path is not None and not isinstance(path, str):
|
|
577
|
+
raise _InvalidParams("'path' must be a string")
|
|
578
|
+
if name == "read_file":
|
|
579
|
+
for key in ("start_line", "end_line"):
|
|
580
|
+
value = arguments.get(key)
|
|
581
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
|
|
582
|
+
raise _InvalidParams("'%s' must be an integer" % key)
|
|
583
|
+
|
|
584
|
+
@staticmethod
|
|
585
|
+
def _is_error_result(value: Any) -> bool:
|
|
586
|
+
if isinstance(value, dict):
|
|
587
|
+
if value.get("error"):
|
|
588
|
+
return True
|
|
589
|
+
if value.get("ok") is False:
|
|
590
|
+
return True
|
|
591
|
+
if "exit_code" in value:
|
|
592
|
+
return value.get("exit_code") not in (0, None)
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def _result_to_text(value: Any) -> str:
|
|
597
|
+
if isinstance(value, str):
|
|
598
|
+
return value
|
|
599
|
+
if isinstance(value, (dict, list, tuple)):
|
|
600
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
601
|
+
return str(value)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _env_flag(name: str) -> bool:
|
|
605
|
+
return str(os.environ.get(name, "")).strip().lower() in ("1", "true", "yes", "on")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def configure_tools_from_env() -> None:
|
|
609
|
+
"""Apply MESH_* environment settings to the shared tool module."""
|
|
610
|
+
mcp_tools.configure(
|
|
611
|
+
workspace=os.environ.get("MESH_WORKSPACE") or os.getcwd(),
|
|
612
|
+
read_only=_env_flag("MESH_READ_ONLY"),
|
|
613
|
+
write_roots=os.environ.get("MESH_WRITE_ROOTS") or None,
|
|
614
|
+
jobs_dir=os.environ.get("MESH_JOBS_DIR") or None,
|
|
615
|
+
max_output_chars=os.environ.get("MESH_MAX_OUTPUT_CHARS"),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def create_mcp_server() -> AntigravityMeshServer:
|
|
620
|
+
"""Factory used by the HTTP handler and by the test-suite."""
|
|
621
|
+
configure_tools_from_env()
|
|
622
|
+
return AntigravityMeshServer(name="antigravity_mesh")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ---------------------------------------------------------------------------
|
|
626
|
+
# HTTP transport
|
|
627
|
+
# ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
# Endpoints that never require a token (liveness/readiness probes only).
|
|
630
|
+
_PUBLIC_PATHS = ("/", "/health")
|
|
631
|
+
# Endpoints accepting JSON-RPC POSTs.
|
|
632
|
+
_JSONRPC_POST_PATHS = ("/messages", "/sse", "/mcp")
|
|
633
|
+
# Matches a ?token=... / &token=... query value so it can be masked in logs.
|
|
634
|
+
_TOKEN_QUERY_RE = re.compile(r"([?&]token=)[^&\s\"]*", re.IGNORECASE)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class StandaloneMCPHandler(BaseHTTPRequestHandler):
|
|
638
|
+
"""Threaded HTTP handler exposing the MCP endpoints of the standalone server."""
|
|
639
|
+
|
|
640
|
+
server_version = "AntigravityMeshStandalone/1.0"
|
|
641
|
+
|
|
642
|
+
# Bound slow/stalled clients (e.g. a Content-Length larger than the body it
|
|
643
|
+
# sends) so a single request cannot pin a handler thread forever.
|
|
644
|
+
timeout = 30
|
|
645
|
+
|
|
646
|
+
# Set by ``run_standalone_server`` (class-level so each request thread sees it).
|
|
647
|
+
server_mcp: Optional[AntigravityMeshServer] = None
|
|
648
|
+
auth_token: str = ""
|
|
649
|
+
|
|
650
|
+
# -- plumbing ---------------------------------------------------------
|
|
651
|
+
def _clean_path(self) -> str:
|
|
652
|
+
try:
|
|
653
|
+
path = urlparse(self.path).path
|
|
654
|
+
except Exception:
|
|
655
|
+
path = self.path or "/"
|
|
656
|
+
if not path:
|
|
657
|
+
path = "/"
|
|
658
|
+
return path
|
|
659
|
+
|
|
660
|
+
def _cors_headers(self) -> None:
|
|
661
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
662
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD")
|
|
663
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
664
|
+
|
|
665
|
+
def _send_headers(self, status: int, content_type: str = "application/json") -> None:
|
|
666
|
+
self.send_response(status)
|
|
667
|
+
self._cors_headers()
|
|
668
|
+
self.send_header("Content-Type", content_type)
|
|
669
|
+
|
|
670
|
+
def _send_json(self, status: int, payload: Any) -> None:
|
|
671
|
+
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
672
|
+
self._send_headers(status, "application/json")
|
|
673
|
+
self.send_header("Content-Length", str(len(body)))
|
|
674
|
+
self.end_headers()
|
|
675
|
+
self.wfile.write(body)
|
|
676
|
+
|
|
677
|
+
def _send_unauthorized(self) -> None:
|
|
678
|
+
body = json.dumps(
|
|
679
|
+
{"error": "unauthorized", "message": "Missing or invalid token"}
|
|
680
|
+
).encode("utf-8")
|
|
681
|
+
self.send_response(401)
|
|
682
|
+
self._cors_headers()
|
|
683
|
+
self.send_header("Content-Type", "application/json")
|
|
684
|
+
self.send_header("WWW-Authenticate", 'Bearer realm="antigravity-mesh"')
|
|
685
|
+
self.send_header("Content-Length", str(len(body)))
|
|
686
|
+
self.end_headers()
|
|
687
|
+
self.wfile.write(body)
|
|
688
|
+
|
|
689
|
+
def _provided_token(self) -> Optional[str]:
|
|
690
|
+
try:
|
|
691
|
+
query = parse_qs(urlparse(self.path).query)
|
|
692
|
+
except Exception:
|
|
693
|
+
query = {}
|
|
694
|
+
token = query.get("token", [None])[0]
|
|
695
|
+
if not token:
|
|
696
|
+
token = self.headers.get("X-Mesh-Token") or None
|
|
697
|
+
if token:
|
|
698
|
+
return str(token)
|
|
699
|
+
auth = self.headers.get("Authorization", "") or ""
|
|
700
|
+
if auth.lower().startswith("bearer "):
|
|
701
|
+
return auth[7:].strip()
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
def _token_valid(self) -> bool:
|
|
705
|
+
"""True when auth is disabled, or the supplied token matches.
|
|
706
|
+
|
|
707
|
+
Unlike ``_authorized()`` this ignores the public-path exemption and is
|
|
708
|
+
used by the liveness endpoints to decide how much detail to disclose.
|
|
709
|
+
"""
|
|
710
|
+
token = self.auth_token or ""
|
|
711
|
+
if not token:
|
|
712
|
+
return True # auth disabled (loopback-only default)
|
|
713
|
+
try:
|
|
714
|
+
provided = self._provided_token()
|
|
715
|
+
if not provided:
|
|
716
|
+
return False
|
|
717
|
+
# Compare raw UTF-8 bytes: hmac.compare_digest() rejects str inputs
|
|
718
|
+
# containing non-ASCII characters, which would otherwise raise a
|
|
719
|
+
# TypeError and drop the connection on a malformed token.
|
|
720
|
+
# surrogateescape keeps any undecodable byte round-trippable.
|
|
721
|
+
return hmac.compare_digest(
|
|
722
|
+
str(provided).encode("utf-8", "surrogateescape"),
|
|
723
|
+
str(token).encode("utf-8", "surrogateescape"),
|
|
724
|
+
)
|
|
725
|
+
except Exception:
|
|
726
|
+
# Defensive: authentication must never crash the handler.
|
|
727
|
+
return False
|
|
728
|
+
|
|
729
|
+
def _authorized(self) -> bool:
|
|
730
|
+
if self._clean_path() in _PUBLIC_PATHS:
|
|
731
|
+
return True
|
|
732
|
+
return self._token_valid()
|
|
733
|
+
|
|
734
|
+
def _read_body(self) -> bytes:
|
|
735
|
+
try:
|
|
736
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
737
|
+
except (TypeError, ValueError):
|
|
738
|
+
length = 0
|
|
739
|
+
if length <= 0:
|
|
740
|
+
return b""
|
|
741
|
+
try:
|
|
742
|
+
return self.rfile.read(length)
|
|
743
|
+
except Exception:
|
|
744
|
+
return b""
|
|
745
|
+
|
|
746
|
+
# -- HTTP verbs -------------------------------------------------------
|
|
747
|
+
def do_OPTIONS(self) -> None: # noqa: N802 - http.server API
|
|
748
|
+
# CORS preflight is unauthenticated: it executes nothing and must
|
|
749
|
+
# succeed even when a token is configured, otherwise browsers cannot
|
|
750
|
+
# discover the endpoint/headers.
|
|
751
|
+
self._send_headers(200, "text/plain")
|
|
752
|
+
self.send_header("Content-Length", "0")
|
|
753
|
+
self.end_headers()
|
|
754
|
+
|
|
755
|
+
def do_HEAD(self) -> None: # noqa: N802 - http.server API
|
|
756
|
+
if not self._authorized():
|
|
757
|
+
self._send_unauthorized()
|
|
758
|
+
return
|
|
759
|
+
path = self._clean_path()
|
|
760
|
+
if path in _PUBLIC_PATHS:
|
|
761
|
+
self._send_headers(200, "application/json")
|
|
762
|
+
self.send_header("Content-Length", "0")
|
|
763
|
+
self.end_headers()
|
|
764
|
+
elif path in ("/sse", "/mcp"):
|
|
765
|
+
self._send_headers(200, "text/event-stream")
|
|
766
|
+
self.end_headers()
|
|
767
|
+
else:
|
|
768
|
+
self._send_headers(404, "application/json")
|
|
769
|
+
self.send_header("Content-Length", "0")
|
|
770
|
+
self.end_headers()
|
|
771
|
+
|
|
772
|
+
def do_GET(self) -> None: # noqa: N802 - http.server API
|
|
773
|
+
if not self._authorized():
|
|
774
|
+
self._send_unauthorized()
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
path = self._clean_path()
|
|
778
|
+
if path in _PUBLIC_PATHS:
|
|
779
|
+
# Liveness is public so the installer health poll keeps working, but
|
|
780
|
+
# host vitals are only disclosed to an authenticated caller when a
|
|
781
|
+
# token is configured.
|
|
782
|
+
payload: Dict[str, Any] = {"status": "healthy", "mode": "standalone"}
|
|
783
|
+
if self._token_valid():
|
|
784
|
+
payload["vitals"] = self.server_mcp.system_vitals() if self.server_mcp else {}
|
|
785
|
+
self._send_json(200, payload)
|
|
786
|
+
elif path in ("/sse", "/mcp"):
|
|
787
|
+
self._stream_sse()
|
|
788
|
+
elif path.startswith("/.well-known/oauth-protected-resource"):
|
|
789
|
+
self._send_json(404, {"error": "not_found"})
|
|
790
|
+
else:
|
|
791
|
+
self._send_json(404, {"error": "not_found", "path": path})
|
|
792
|
+
|
|
793
|
+
def do_POST(self) -> None: # noqa: N802 - http.server API
|
|
794
|
+
if not self._authorized():
|
|
795
|
+
self._send_unauthorized()
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
path = self._clean_path()
|
|
799
|
+
if path not in _JSONRPC_POST_PATHS:
|
|
800
|
+
self._send_json(404, {"error": "not_found", "path": path})
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
raw_body = self._read_body()
|
|
804
|
+
if self.server_mcp is None:
|
|
805
|
+
self._send_json(200, {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": "Internal error"}})
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
status, response = self.server_mcp.handle_jsonrpc(raw_body)
|
|
809
|
+
if status == 204 or response is None:
|
|
810
|
+
self.send_response(204)
|
|
811
|
+
self._cors_headers()
|
|
812
|
+
self.end_headers()
|
|
813
|
+
return
|
|
814
|
+
if isinstance(response, (dict, list)):
|
|
815
|
+
self._send_json(status, response)
|
|
816
|
+
else: # defensive: never drop the connection on a malformed handler result
|
|
817
|
+
self._send_json(500, {"error": "internal_error"})
|
|
818
|
+
|
|
819
|
+
def _stream_sse(self) -> None:
|
|
820
|
+
self._send_headers(200, "text/event-stream")
|
|
821
|
+
self.send_header("Cache-Control", "no-cache")
|
|
822
|
+
self.send_header("Connection", "keep-alive")
|
|
823
|
+
self.end_headers()
|
|
824
|
+
try:
|
|
825
|
+
self.wfile.write(b"event: endpoint\ndata: /messages\n\n")
|
|
826
|
+
self.wfile.flush()
|
|
827
|
+
while True:
|
|
828
|
+
time.sleep(15)
|
|
829
|
+
self.wfile.write(b": keepalive\n\n")
|
|
830
|
+
self.wfile.flush()
|
|
831
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
def log_request(self, code: Any = "-", size: Any = "-") -> None: # noqa: N802
|
|
835
|
+
# Log only the request path: the query string can carry ?token=<secret>
|
|
836
|
+
# and must never reach journald / standalone.log.
|
|
837
|
+
requestline = self.requestline or ""
|
|
838
|
+
parts = requestline.split(" ")
|
|
839
|
+
if len(parts) >= 2 and "?" in parts[1]:
|
|
840
|
+
parts[1] = parts[1].split("?", 1)[0]
|
|
841
|
+
requestline = " ".join(parts)
|
|
842
|
+
self.log_message(
|
|
843
|
+
'"%s" %s %s', requestline, str(getattr(code, "value", code)), str(size)
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
|
847
|
+
try:
|
|
848
|
+
message = fmt % args
|
|
849
|
+
except Exception:
|
|
850
|
+
message = str(fmt)
|
|
851
|
+
# Defense in depth: mask any token=... value that still reaches a log.
|
|
852
|
+
message = _TOKEN_QUERY_RE.sub(r"\1***", message)
|
|
853
|
+
logger.info("%s - - [%s] %s", self.client_address[0], self.log_date_time_string(), message)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def run_standalone_server(host: str = "127.0.0.1", port: int = 8096, token: Optional[str] = None) -> None:
|
|
857
|
+
"""Start the blocking standalone MCP HTTP server."""
|
|
858
|
+
if token is None:
|
|
859
|
+
token = os.environ.get("MESH_TOKEN", "")
|
|
860
|
+
mcp = create_mcp_server()
|
|
861
|
+
StandaloneMCPHandler.server_mcp = mcp
|
|
862
|
+
StandaloneMCPHandler.auth_token = token or ""
|
|
863
|
+
|
|
864
|
+
httpd = ThreadingHTTPServer((host, int(port)), StandaloneMCPHandler)
|
|
865
|
+
httpd.daemon_threads = True
|
|
866
|
+
logger.info(
|
|
867
|
+
"Antigravity Mesh standalone MCP server listening on http://%s:%s/sse (auth: %s)",
|
|
868
|
+
host,
|
|
869
|
+
port,
|
|
870
|
+
"enabled" if token else "disabled",
|
|
871
|
+
)
|
|
872
|
+
try:
|
|
873
|
+
httpd.serve_forever()
|
|
874
|
+
except KeyboardInterrupt: # pragma: no cover - interactive shutdown
|
|
875
|
+
logger.info("Standalone server stopped.")
|
|
876
|
+
finally:
|
|
877
|
+
httpd.server_close()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
881
|
+
parser = argparse.ArgumentParser(
|
|
882
|
+
prog="core.server",
|
|
883
|
+
description="Antigravity Mesh Standalone MCP Server (localhost by default).",
|
|
884
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
885
|
+
epilog=(
|
|
886
|
+
"SECURITY: --host defaults to 127.0.0.1 on purpose. This server executes\n"
|
|
887
|
+
"arbitrary shell commands and reads arbitrary files. Never bind 0.0.0.0\n"
|
|
888
|
+
"without setting --token/MESH_TOKEN and a firewall."
|
|
889
|
+
),
|
|
890
|
+
)
|
|
891
|
+
parser.add_argument(
|
|
892
|
+
"--host",
|
|
893
|
+
default="127.0.0.1",
|
|
894
|
+
help="Bind address. Defaults to loopback 127.0.0.1 for security; do NOT use 0.0.0.0 "
|
|
895
|
+
"unless a token and firewall are configured.",
|
|
896
|
+
)
|
|
897
|
+
parser.add_argument("--port", type=int, default=8096, help="TCP port to listen on (default: 8096).")
|
|
898
|
+
parser.add_argument(
|
|
899
|
+
"--token",
|
|
900
|
+
default=os.environ.get("MESH_TOKEN", ""),
|
|
901
|
+
help="Shared auth token. Defaults to $MESH_TOKEN; empty disables auth (localhost-only).",
|
|
902
|
+
)
|
|
903
|
+
args = parser.parse_args(argv)
|
|
904
|
+
|
|
905
|
+
logging.basicConfig(
|
|
906
|
+
level=logging.INFO,
|
|
907
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
908
|
+
stream=sys.stderr,
|
|
909
|
+
)
|
|
910
|
+
logger.info(
|
|
911
|
+
"Starting standalone MCP server on %s:%s (bind address is %s)",
|
|
912
|
+
args.host,
|
|
913
|
+
args.port,
|
|
914
|
+
"loopback" if args.host in ("127.0.0.1", "localhost", "::1") else args.host,
|
|
915
|
+
)
|
|
916
|
+
if args.host == "0.0.0.0" and not args.token:
|
|
917
|
+
logger.warning(
|
|
918
|
+
"Bound to 0.0.0.0 without a token: anyone on the network can execute shell commands. "
|
|
919
|
+
"Use --host 127.0.0.1 or configure --token."
|
|
920
|
+
)
|
|
921
|
+
run_standalone_server(host=args.host, port=args.port, token=args.token)
|
|
922
|
+
return 0
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
if __name__ == "__main__":
|
|
926
|
+
sys.exit(main())
|