vpnhide/tools/agent-mcp/server.py
Danila Gornushko c523e34747
feat(lsposed): add debug host control bridge (#194)
* feat(lsposed): add debug agent bridge

* feat(tools): add agent bridge MCP server

* fix(lsposed): address agent bridge review feedback

* fix(tools): format agent bridge MCP server

* fix(tools): sort MCP server imports

* fix(lsposed): centralize agent bridge lifecycle

* fix(tools): detach adb stdin in agent bridge MCP server

adb subprocesses inherited the server's stdin, which carries the MCP
JSON-RPC frames, so the adb child consumed handshake bytes and wedged
every MCP session. Run adb with stdin=DEVNULL. Switch the server shebang
to the project's uv run --script convention and add smoke-test.py, an
end-to-end MCP handshake check.

* feat(lsposed): move agent control toggle into the Developer section

The agent bridge is debug-only, so its toggle belongs with the other
developer knobs rather than the Debugging tools section. Gate it behind
BuildConfig.DEBUG so release builds (whose AgentControlBridge is a no-op
stub) don't show a dead switch.

* feat(lsposed): ship agent bridge in release, off by default

The bridge was debug-only (real impl in src/debug, no-op stub in
src/release, toggle gated by BuildConfig.DEBUG), which hid it from the
release builds used for development. Move AgentControlBridge into the
main source set so it builds everywhere, drop the BuildConfig.DEBUG
guards, and keep the toggle off by default. Because an enabled bridge
opens a loopback port other apps can use to detect VPN Hide, surface a
dashboard info note while it is on. The host token path falls back to
logcat on release builds, where run-as is unavailable.

* style(lsposed): expression body for startServer

ktlint's function-expression-body rule fires now that the early
BuildConfig.DEBUG return is gone and startServer is a single expression.
2026-06-30 00:31:17 +03:00

202 lines
6.6 KiB
Python
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "mcp>=1.28,<2",
# ]
# ///
from __future__ import annotations
import argparse
import asyncio
import json
import re
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
DEFAULT_PACKAGE = "dev.okhsunrog.vpnhide"
DEFAULT_HOST_PORT = 27193
DEFAULT_DEVICE_PORT = 27193
TOKEN_RE = re.compile(r"token=([A-Za-z0-9_-]+)")
@dataclass(frozen=True)
class BridgeConfig:
adb: str
serial: str | None
package: str
host_port: int
device_port: int
class BridgeClient:
def __init__(self, config: BridgeConfig) -> None:
self.config = config
self.token: str | None = None
def connect(self) -> None:
self._adb("forward", f"tcp:{self.config.host_port}", f"tcp:{self.config.device_port}")
self.token = self._read_token()
def functions(self) -> list[dict[str, Any]]:
payload = self._request("GET", "/functions")
functions = payload.get("functions")
if not isinstance(functions, list):
raise RuntimeError("Bridge returned an invalid /functions response")
return functions
def call(self, name: str, args: dict[str, Any]) -> Any:
return self._request("POST", "/call", {"fn": name, "args": args})
def _request(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
) -> Any:
if self.token is None:
raise RuntimeError("Bridge token is not loaded")
data = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
f"http://127.0.0.1:{self.config.host_port}{path}",
data=data,
method=method,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode("utf-8")
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"Bridge {method} {path} failed: HTTP {error.code}: {detail}"
) from error
except OSError as error:
raise RuntimeError(
"Could not reach VPN Hide bridge. Install a debug build, open the app, "
"and enable Settings -> Debugging -> Agent control.",
) from error
return json.loads(raw) if raw else None
def _read_token(self) -> str:
token = self._read_token_file()
if token:
return token
token = self._read_token_logcat()
if token:
return token
raise RuntimeError(
"Could not read bridge token. Enable Agent control in the debug app, "
"then retry. Expected files/agent_bridge_token via run-as.",
)
def _read_token_file(self) -> str | None:
result = self._adb_result(
"shell", "run-as", self.config.package, "cat", "files/agent_bridge_token"
)
if result.returncode != 0:
return None
token = result.stdout.strip()
return token or None
def _read_token_logcat(self) -> str | None:
result = self._adb_result("logcat", "-d", "-s", "VpnHideAgentBridge")
if result.returncode != 0:
return None
matches = TOKEN_RE.findall(result.stdout)
return matches[-1] if matches else None
def _adb(self, *args: str) -> str:
result = self._adb_result(*args)
if result.returncode != 0:
raise RuntimeError(
result.stderr.strip() or result.stdout.strip() or f"adb {' '.join(args)} failed"
)
return result.stdout
def _adb_result(self, *args: str) -> subprocess.CompletedProcess[str]:
cmd = [self.config.adb]
if self.config.serial:
cmd += ["-s", self.config.serial]
cmd += list(args)
# Detach adb's stdin from ours: this process speaks MCP JSON-RPC over
# stdin, and an inherited stdin lets the adb child consume those frames
# before the stdio server reads them, which silently wedges the handshake.
return subprocess.run(
cmd,
text=True,
capture_output=True,
check=False,
stdin=subprocess.DEVNULL,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Expose VPN Hide debug bridge as MCP tools.")
parser.add_argument("--serial", help="adb device serial to target")
parser.add_argument("--adb", default="adb", help="adb executable path")
parser.add_argument("--package", default=DEFAULT_PACKAGE, help="VPN Hide application id")
parser.add_argument(
"--host-port", type=int, default=DEFAULT_HOST_PORT, help="localhost port for adb forward"
)
parser.add_argument(
"--device-port", type=int, default=DEFAULT_DEVICE_PORT, help="device bridge port"
)
return parser.parse_args()
def make_tool(spec: dict[str, Any]) -> Tool:
return Tool(
name=spec["name"],
description=spec.get("description", ""),
inputSchema=spec.get("inputSchema", {"type": "object", "properties": {}}),
)
async def main() -> None:
args = parse_args()
config = BridgeConfig(
adb=args.adb,
serial=args.serial,
package=args.package,
host_port=args.host_port,
device_port=args.device_port,
)
client = BridgeClient(config)
try:
client.connect()
function_specs = client.functions()
except Exception as error:
print(f"Failed to initialize VPN Hide MCP bridge: {error}", file=sys.stderr)
raise
tools_by_name = {spec["name"]: make_tool(spec) for spec in function_specs}
server = Server("vpnhide-agent-bridge")
@server.list_tools()
async def list_tools() -> list[Tool]:
return list(tools_by_name.values())
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]:
result = await asyncio.to_thread(client.call, name, arguments or {})
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())