arcade-mcp/tests/integration/mcp_protocol_smoke.py
jottakka fe8ddfd500
[TOO-326] Windows papercuts (#768)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Touches authentication/login flow, credentials-file permissions, and
subprocess lifecycle behavior across platforms; while mostly defensive,
regressions could impact login or process management on Windows/macOS
runners.
> 
> **Overview**
> Improves Windows/cross-platform reliability across the CLI and MCP
server: OAuth login now binds the callback server to `127.0.0.1`, avoids
slow loopback reverse-DNS, adds a configurable callback timeout
(`--timeout` + env default), and opens URLs via a Windows-friendly
`_open_browser` to avoid flashing console windows.
> 
> Centralizes CLI output via a shared `console` that forces UTF-8 on
Windows, standardizes UTF-8 file reads/writes throughout, tightens
credentials-file permissions on Windows using `icacls`, and adds shared
Windows subprocess helpers for **no-window** process creation and
graceful termination (used by `deploy`, MCP reload, and usage-tracking
worker).
> 
> Updates client configuration UX/robustness (Windows AppData resolution
via `platformdirs`, Cursor config path fallbacks + compatibility writes,
overwrite warnings, absolute `uv` path for GUI clients, safer path
display) and improves `deploy` child-process handling to avoid
pipe-buffer deadlocks while giving better debug-aware error messages.
> 
> Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI
integration workflow, disables usage tracking in toolkits CI, and adds
extensive regression tests for Windows signals, subprocess cleanup,
UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and
`arcade-mcp-server` to `1.17.2` (with updated dependency pin).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-25 13:18:16 -03:00

694 lines
26 KiB
Python

#!/usr/bin/env python3
"""MCP protocol smoke test for generated Arcade servers.
Why this exists alongside ``libs/tests/arcade_mcp_server/integration/test_end_to_end.py``
------------------------------------------------------------------------------------------
The existing ``test_end_to_end.py`` is a pytest suite that validates the arcade-mcp-server
*library* against a dedicated test-fixture server with rich coverage (logging, progress
notifications, tool chaining, sampling, elicitation, concurrency).
This smoke test serves a **different purpose**:
1. **Tests ``arcade new`` scaffolded output** — validates that a *generated* project's
``server.py`` works end-to-end, catching template regressions that library tests
cannot detect.
2. **Cross-platform CI entry point** — invoked by
``tests/integration/no_auth_cli_smoke.py`` across the OS matrix. It includes
platform-aware process management (e.g., ``taskkill /T /F`` on Windows and
non-``select()`` stderr draining for Windows pipes) that the pytest suite
does not exercise.
3. **Stdlib-only / zero external deps** — runs with nothing beyond a ``uv run python``
invocation so it works on fresh CI images before any ``pip install``.
4. **Standalone CLI** — uses ``argparse`` so PowerShell/bash scripts can invoke it
directly without a pytest harness.
The basic protocol flow (initialize → initialized → ping → tools/list → tools/call) is
intentionally re-implemented here rather than imported, because the stdlib-only constraint
and the need to run outside the project's virtualenv make shared helpers impractical.
Usage::
uv run python tests/integration/mcp_protocol_smoke.py \\
--server-dir "<path-to>/src/my_server" \\
--transport both
Tool naming convention (for reference)::
MCPApp(name="my_server") + def greet() -> MCP tool name "MyServer_Greet"
Toolkit name is snake_to_pascal_case(app_name), tool is snake_to_pascal_case(func_name).
MCP exposes the fully-qualified name with "." replaced by "_".
We find the greet tool by case-insensitive substring match on "greet".
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import platform
import queue
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
from typing import Any
# ---------------------------------------------------------------------------
# JSON-RPC helpers
# ---------------------------------------------------------------------------
def _build_request(
method: str,
params: dict[str, Any] | None = None,
req_id: int | None = None,
) -> dict[str, Any]:
msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
if req_id is not None:
msg["id"] = req_id
return msg
def _parse_json_line(line: str) -> dict[str, Any] | None:
line = line.strip()
if not line:
return None
try:
result = json.loads(line)
return result if isinstance(result, dict) else None
except json.JSONDecodeError:
return None
def _assert_ok(msg: dict[str, Any], expected_id: int, step: str) -> None:
assert msg.get("jsonrpc") == "2.0", f"[{step}] jsonrpc != '2.0': {msg}"
assert msg.get("id") == expected_id, f"[{step}] id != {expected_id}: {msg}"
assert "error" not in msg, f"[{step}] unexpected error field: {msg['error']}"
assert "result" in msg, f"[{step}] missing 'result' field: {msg}"
def _find_greet_tool(tools: list[dict[str, Any]]) -> str:
"""Return the MCP tool name that contains 'greet' (case-insensitive)."""
for t in tools:
name = str(t.get("name", ""))
if "greet" in name.lower():
return name
names = [t.get("name") for t in tools]
raise AssertionError(
"No tool containing 'greet' (case-insensitive) found.\n"
f"Available tools: {names}\n"
"Expected the generated server to expose a 'greet' tool "
"(e.g. MyServer_Greet from MCPApp(name='my_server') + def greet(...))."
)
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def _kill_process(proc: subprocess.Popen) -> None:
"""Terminate a subprocess, using taskkill on Windows to kill the tree."""
if proc.poll() is not None:
return
try:
if platform.system() == "Windows":
taskkill = shutil.which("taskkill") or "taskkill"
subprocess.run(
[taskkill, "/PID", str(proc.pid), "/T", "/F"],
capture_output=True,
)
else:
proc.terminate()
proc.wait(timeout=5)
except Exception:
with contextlib.suppress(Exception):
proc.kill()
proc.wait(timeout=3)
def _drain_stderr(proc: subprocess.Popen, max_chars: int = 2000) -> str:
"""Non-blocking read of available stderr bytes for diagnostics."""
if proc.stderr is None:
return ""
with contextlib.suppress(Exception):
if platform.system() != "Windows":
import select
ready, _, _ = select.select([proc.stderr], [], [], 0.1)
if ready:
return str(proc.stderr.read(max_chars))
else:
# On Windows select() doesn't work on pipes; try reading with timeout
import threading
buf: list[str] = []
def _reader() -> None:
with contextlib.suppress(Exception):
buf.append(str(proc.stderr.read(max_chars))) # type: ignore[union-attr]
t = threading.Thread(target=_reader, daemon=True)
t.start()
t.join(timeout=0.5)
return buf[0] if buf else ""
return ""
def _tail_text_file(path: str | None, max_chars: int = 4000) -> str:
"""Return the tail of a UTF-8 log file for diagnostics."""
if not path or not os.path.exists(path):
return ""
try:
with open(path, encoding="utf-8", errors="replace") as f:
data = f.read()
return data[-max_chars:]
except Exception:
return ""
# ---------------------------------------------------------------------------
# Stdio transport
# ---------------------------------------------------------------------------
class StdioClient:
"""Communicate with an MCP server over stdin/stdout."""
def __init__(self, proc: subprocess.Popen, timeout: float = 30.0) -> None:
self.proc = proc
self.timeout = timeout
self._next_id = 1
self._stdout_queue: queue.Queue[str | None] = queue.Queue()
self._stdout_reader = threading.Thread(target=self._read_stdout_loop, daemon=True)
self._stdout_reader.start()
def _read_stdout_loop(self) -> None:
if self.proc.stdout is None:
self._stdout_queue.put(None)
return
try:
for raw in self.proc.stdout:
self._stdout_queue.put(raw)
finally:
self._stdout_queue.put(None)
def _next(self) -> int:
rid = self._next_id
self._next_id += 1
return rid
def send_request(self, method: str, params: dict[str, Any] | None = None) -> int:
rid = self._next()
line = json.dumps(_build_request(method, params, rid)) + "\n"
assert self.proc.stdin is not None
self.proc.stdin.write(line)
self.proc.stdin.flush()
return rid
def send_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
line = json.dumps(_build_request(method, params)) + "\n"
assert self.proc.stdin is not None
self.proc.stdin.write(line)
self.proc.stdin.flush()
def read_response(self, expected_id: int) -> dict[str, Any]:
"""Read lines until we get a JSON-RPC message with the expected id."""
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
if self.proc.poll() is not None:
stderr_snippet = _drain_stderr(self.proc)
raise RuntimeError(
f"Server exited (code={self.proc.returncode}) while waiting for id={expected_id}.\n"
f"STDERR snippet:\n{stderr_snippet}"
)
timeout = min(0.5, max(0.0, deadline - time.monotonic()))
try:
raw = self._stdout_queue.get(timeout=timeout)
except queue.Empty:
continue
if raw is None:
stderr_snippet = _drain_stderr(self.proc)
raise RuntimeError(
f"Server stdout closed (EOF) while waiting for id={expected_id}.\n"
f"STDERR snippet:\n{stderr_snippet}"
)
msg = _parse_json_line(raw)
if msg is None:
# Non-JSON line (server log/debug output) — skip
print(f" [stdio/log] {raw.rstrip()}", flush=True)
continue
if msg.get("id") != expected_id:
# Notification or out-of-order message — log and skip
print(f" [stdio/msg] {json.dumps(msg)}", flush=True)
continue
return msg
raise TimeoutError(f"Timed out after {self.timeout}s waiting for id={expected_id}")
def run_stdio(server_dir: str, timeout: float) -> None:
print("\n=== Stdio transport MCP protocol smoke ===", flush=True)
proc: subprocess.Popen | None = None
stderr_sink = None
stderr_log_path: str | None = None
step = "startup"
last_response: dict[str, Any] = {}
try:
uv = shutil.which("uv") or "uv"
stderr_sink = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix="arcade-mcp-stdio-",
suffix=".log",
buffering=1,
delete=False,
)
stderr_log_path = stderr_sink.name
proc = subprocess.Popen(
[uv, "run", "server.py"],
cwd=server_dir,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stderr_sink,
text=True,
bufsize=1,
)
# Give the server a moment to initialize before we write to stdin
time.sleep(3)
if proc.poll() is not None:
stdout_out = proc.stdout.read() if proc.stdout else ""
stderr_out = proc.stderr.read() if proc.stderr else ""
raise RuntimeError( # noqa: TRY301
f"Server exited early (code={proc.returncode}).\n"
f"STDOUT:\n{stdout_out}\nSTDERR:\n{stderr_out}"
)
client = StdioClient(proc, timeout=timeout)
# 1. initialize
step = "initialize"
print(f"Step 1: {step}", flush=True)
init_id = client.send_request(
"initialize",
{
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "arcade-windows-ci", "version": "0.1.0"},
},
)
last_response = client.read_response(expected_id=init_id)
_assert_ok(last_response, init_id, step)
server_info = last_response["result"].get("serverInfo", {})
print(f" OK — serverInfo={server_info}", flush=True)
# 2. notifications/initialized
step = "notifications/initialized"
print(f"Step 2: {step}", flush=True)
client.send_notification("notifications/initialized")
print(" OK — notification sent", flush=True)
# 3. ping
step = "ping"
print(f"Step 3: {step}", flush=True)
ping_id = client.send_request("ping")
last_response = client.read_response(expected_id=ping_id)
assert last_response.get("jsonrpc") == "2.0", f"[ping] jsonrpc != '2.0': {last_response}"
assert last_response.get("id") == ping_id, f"[ping] id mismatch: {last_response}"
assert "error" not in last_response, f"[ping] error in response: {last_response}"
print(" OK", flush=True)
# 4. tools/list
step = "tools/list"
print(f"Step 4: {step}", flush=True)
list_id = client.send_request("tools/list")
last_response = client.read_response(expected_id=list_id)
_assert_ok(last_response, list_id, step)
tools: list[dict[str, Any]] = last_response["result"].get("tools", [])
assert len(tools) > 0, f"[tools/list] empty tools list: {last_response}"
tool_names = [t.get("name") for t in tools]
print(f" OK — {len(tools)} tools: {tool_names}", flush=True)
# 5. tools/call greet
step = "tools/call(greet)"
print(f"Step 5: {step}", flush=True)
greet_name = _find_greet_tool(tools)
print(f" using tool: {greet_name!r}", flush=True)
call_id = client.send_request(
"tools/call",
{"name": greet_name, "arguments": {"name": "Windows CI"}},
)
last_response = client.read_response(expected_id=call_id)
_assert_ok(last_response, call_id, step)
content = last_response["result"].get("content", [])
assert len(content) > 0, f"[{step}] empty content array: {last_response}"
text: str = content[0].get("text", "")
assert "Hello" in text, (
f"[{step}] expected 'Hello' in response text.\n"
f" Got: {text!r}\n"
f" Full response: {last_response}"
)
print(f" OK — response: {text!r}", flush=True)
print("\nStdio transport smoke PASSED.", flush=True)
except Exception as exc:
if stderr_sink is not None:
with contextlib.suppress(Exception):
stderr_sink.flush()
stderr_snippet = _tail_text_file(stderr_log_path) if stderr_log_path else ""
print(
f"\nSTDIO SMOKE FAILED at step '{step}'.\n"
f" Error: {exc}\n"
f" Last response: {json.dumps(last_response) if last_response else 'n/a'}\n"
f" Server STDERR snippet:\n{stderr_snippet}",
file=sys.stderr,
flush=True,
)
raise
finally:
if stderr_sink is not None:
with contextlib.suppress(Exception):
stderr_sink.close()
if proc is not None:
_kill_process(proc)
# ---------------------------------------------------------------------------
# HTTP transport
# ---------------------------------------------------------------------------
def _http_post(
url: str,
payload: dict[str, Any],
extra_headers: dict[str, str] | None = None,
read_response_headers: bool = False,
timeout_seconds: float = 30.0,
) -> tuple[int, dict[str, str], dict[str, Any]]:
"""POST JSON payload, return (status, response_headers, body_dict)."""
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body, method="POST") # noqa: S310
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
if extra_headers:
for k, v in extra_headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: # noqa: S310
status: int = resp.status
# http.client.HTTPMessage supports case-insensitive get()
resp_headers: dict[str, str] = {}
if read_response_headers:
for key in resp.headers:
resp_headers[key.lower()] = resp.headers[key]
raw = resp.read().decode("utf-8")
body_dict: dict[str, Any] = json.loads(raw) if raw.strip() else {}
return status, resp_headers, body_dict
except urllib.error.HTTPError as e:
status = e.code
raw = e.read().decode("utf-8")
try:
return status, {}, json.loads(raw)
except Exception:
return status, {}, {"_raw": raw}
def _wait_for_health(url: str, timeout: float) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with contextlib.suppress(Exception):
req = urllib.request.Request(url) # noqa: S310
with urllib.request.urlopen(req, timeout=2) as resp: # noqa: S310
if resp.status == 200:
return
time.sleep(1)
raise TimeoutError(f"Server did not become healthy at {url} within {timeout}s")
def run_http(server_dir: str, timeout: float) -> None:
print("\n=== HTTP transport MCP protocol smoke ===", flush=True)
port = _find_free_port()
proc: subprocess.Popen | None = None
stdout_sink = None
stderr_sink = None
stdout_log_path: str | None = None
stderr_log_path: str | None = None
step = "startup"
last_response: dict[str, Any] = {}
try:
env = {
**os.environ,
"ARCADE_SERVER_HOST": "127.0.0.1",
"ARCADE_SERVER_PORT": str(port),
"ARCADE_WORKER_SECRET": "arcade-smoke-worker-secret",
}
uv = shutil.which("uv") or "uv"
stdout_sink = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix="arcade-mcp-http-out-",
suffix=".log",
buffering=1,
delete=False,
)
stdout_log_path = stdout_sink.name
stderr_sink = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix="arcade-mcp-http-err-",
suffix=".log",
buffering=1,
delete=False,
)
stderr_log_path = stderr_sink.name
proc = subprocess.Popen(
[uv, "run", "server.py", "http"],
cwd=server_dir,
stdout=stdout_sink,
stderr=stderr_sink,
text=True,
env=env,
)
base_url = f"http://127.0.0.1:{port}"
health_url = f"{base_url}/worker/health"
mcp_url = f"{base_url}/mcp"
print(f" waiting for health at {health_url} (up to 30s)", flush=True)
_wait_for_health(health_url, timeout=30)
if proc.poll() is not None:
stderr_out = proc.stderr.read() if proc.stderr else ""
raise RuntimeError( # noqa: TRY301
f"Server exited (code={proc.returncode}) before health check passed.\n"
f"STDERR:\n{stderr_out}"
)
print(" health OK", flush=True)
session_headers: dict[str, str] = {}
# 1. initialize — capture mcp-session-id from response headers
step = "initialize"
print(f"Step 1: {step}", flush=True)
init_req = _build_request(
"initialize",
{
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "arcade-windows-ci", "version": "0.1.0"},
},
req_id=1,
)
init_status, init_hdrs, last_response = _http_post(
mcp_url, init_req, read_response_headers=True, timeout_seconds=timeout
)
assert (
init_status == 200
), f"[{step}] expected status 200, got {init_status}: {last_response}"
assert last_response.get("jsonrpc") == "2.0", f"[{step}] jsonrpc != '2.0': {last_response}"
assert last_response.get("id") == 1, f"[{step}] id != 1: {last_response}"
assert "error" not in last_response, f"[{step}] error in response: {last_response}"
session_id = init_hdrs.get("mcp-session-id")
assert session_id is not None, (
f"[{step}] mcp-session-id header missing from initialize response.\n"
f" Headers received: {init_hdrs}"
)
session_headers["Mcp-Session-Id"] = session_id
server_info = last_response["result"].get("serverInfo", {})
print(f" OK — serverInfo={server_info}, session_id={session_id}", flush=True)
# 2. notifications/initialized
step = "notifications/initialized"
print(f"Step 2: {step}", flush=True)
notif_req = _build_request("notifications/initialized")
notif_status, _, _ = _http_post(mcp_url, notif_req, extra_headers=session_headers)
assert notif_status == 202, f"[{step}] expected status 202, got {notif_status}"
print(" OK (202)", flush=True)
# 3. ping
step = "ping"
print(f"Step 3: {step}", flush=True)
ping_status, _, last_response = _http_post(
mcp_url,
_build_request("ping", req_id=2),
extra_headers=session_headers,
timeout_seconds=timeout,
)
assert ping_status == 200, f"[{step}] expected 200, got {ping_status}: {last_response}"
assert last_response.get("jsonrpc") == "2.0", f"[{step}] jsonrpc: {last_response}"
assert last_response.get("id") == 2, f"[{step}] id: {last_response}"
assert "error" not in last_response, f"[{step}] error: {last_response}"
print(" OK", flush=True)
# 4. tools/list
step = "tools/list"
print(f"Step 4: {step}", flush=True)
list_status, _, last_response = _http_post(
mcp_url,
_build_request("tools/list", req_id=3),
extra_headers=session_headers,
timeout_seconds=timeout,
)
assert list_status == 200, f"[{step}] expected 200, got {list_status}: {last_response}"
_assert_ok(last_response, 3, step)
tools = last_response["result"].get("tools", [])
assert len(tools) > 0, f"[{step}] empty tools list: {last_response}"
tool_names = [t.get("name") for t in tools]
print(f" OK — {len(tools)} tools: {tool_names}", flush=True)
# 5. tools/call greet
step = "tools/call(greet)"
print(f"Step 5: {step}", flush=True)
greet_name = _find_greet_tool(tools)
print(f" using tool: {greet_name!r}", flush=True)
call_status, _, last_response = _http_post(
mcp_url,
_build_request(
"tools/call",
{"name": greet_name, "arguments": {"name": "Windows CI"}},
req_id=4,
),
extra_headers=session_headers,
timeout_seconds=timeout,
)
assert call_status == 200, f"[{step}] expected 200, got {call_status}: {last_response}"
_assert_ok(last_response, 4, step)
content = last_response["result"].get("content", [])
assert len(content) > 0, f"[{step}] empty content: {last_response}"
text: str = content[0].get("text", "")
assert "Hello" in text, (
f"[{step}] expected 'Hello' in response text.\n"
f" Got: {text!r}\n"
f" Full response: {last_response}"
)
print(f" OK — response: {text!r}", flush=True)
print("\nHTTP transport smoke PASSED.", flush=True)
except Exception as exc:
if stdout_sink is not None:
with contextlib.suppress(Exception):
stdout_sink.flush()
if stderr_sink is not None:
with contextlib.suppress(Exception):
stderr_sink.flush()
stdout_tail = _tail_text_file(stdout_log_path) if stdout_log_path else ""
stderr_tail = _tail_text_file(stderr_log_path) if stderr_log_path else ""
print(
f"\nHTTP SMOKE FAILED at step '{step}'.\n"
f" Error: {exc}\n"
f" Last response: {json.dumps(last_response) if last_response else 'n/a'}\n"
f" Server STDOUT snippet:\n{stdout_tail}\n"
f" Server STDERR snippet:\n{stderr_tail}",
file=sys.stderr,
flush=True,
)
raise
finally:
if stdout_sink is not None:
with contextlib.suppress(Exception):
stdout_sink.close()
if stderr_sink is not None:
with contextlib.suppress(Exception):
stderr_sink.close()
if proc is not None:
_kill_process(proc)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="MCP protocol smoke test for a generated Arcade server."
)
parser.add_argument(
"--server-dir",
required=True,
help="Directory containing server.py (the generated server src dir).",
)
parser.add_argument(
"--transport",
choices=["stdio", "http", "both"],
default="both",
help="Transport(s) to validate (default: both).",
)
parser.add_argument(
"--timeout-seconds",
type=float,
default=30.0,
help="Per-step read timeout in seconds (default: 30).",
)
args = parser.parse_args()
server_dir = os.path.abspath(args.server_dir)
if not os.path.isdir(server_dir):
print(f"ERROR: --server-dir does not exist: {server_dir}", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(os.path.join(server_dir, "server.py")):
print(
f"ERROR: server.py not found in --server-dir: {server_dir}",
file=sys.stderr,
)
sys.exit(1)
print(f"Server dir : {server_dir}")
print(f"Transport : {args.transport}")
print(f"Timeout : {args.timeout_seconds}s per step")
failures: list[str] = []
if args.transport in ("stdio", "both"):
try:
run_stdio(server_dir, timeout=args.timeout_seconds)
except Exception as exc:
failures.append(f"stdio: {exc}")
if args.transport in ("http", "both"):
try:
run_http(server_dir, timeout=args.timeout_seconds)
except Exception as exc:
failures.append(f"http: {exc}")
if failures:
print("\n=== MCP PROTOCOL SMOKE FAILURES ===", file=sys.stderr)
for msg in failures:
print(f"{msg}", file=sys.stderr)
sys.exit(1)
print("\nAll MCP protocol smoke checks PASSED.", flush=True)
if __name__ == "__main__":
main()