gemini-computer-use 0.1.2 → 0.2.3

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/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 core.schemas import NodeVitals
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 get_coordinator_vitals() -> NodeVitals:
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
- return NodeVitals(
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())