worldbox-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,90 @@
1
+ # ───────── .NET / C# ─────────
2
+ bin/
3
+ obj/
4
+ *.user
5
+ *.suo
6
+ .vs/
7
+ *.userprefs
8
+ *.pidb
9
+ *.booproj
10
+ *.svd
11
+ *.dll.mdb
12
+ *.exe.mdb
13
+ packages.lock.json.bak
14
+ .idea/
15
+ *.iml
16
+
17
+ # ───────── Python ─────────
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+ *.so
22
+ .Python
23
+ build/
24
+ dist/
25
+ *.egg-info/
26
+ .eggs/
27
+ *.egg
28
+ .pytest_cache/
29
+ .mypy_cache/
30
+ .ruff_cache/
31
+ .coverage
32
+ .coverage.*
33
+ htmlcov/
34
+ .tox/
35
+ .nox/
36
+ coverage.xml
37
+ *.cover
38
+ .hypothesis/
39
+ .venv/
40
+ venv/
41
+ ENV/
42
+
43
+ # uv
44
+ .uv-cache/
45
+
46
+ # ───────── Editors ─────────
47
+ .vscode/
48
+ *.swp
49
+ *.swo
50
+ .DS_Store
51
+ Thumbs.db
52
+
53
+ # ───────── Project-local ─────────
54
+ # Local credentials / tokens (never commit)
55
+ *.local
56
+ *.local.*
57
+ secrets/
58
+ .env
59
+ .env.*
60
+ !.env.example
61
+
62
+ # Local WorldBox install symlinks / cached dlls
63
+ mod/local-refs/
64
+ mod/lib/
65
+ *.snk
66
+
67
+ # Build outputs of the mod
68
+ WorldBoxBridge-v*.zip
69
+
70
+ # Decompilation scratch
71
+ scratch/
72
+ sandbox/
73
+
74
+ # Docs build
75
+ docs/site/
76
+ .cache/
77
+
78
+ # Pre-commit
79
+ .pre-commit-cache/
80
+
81
+ # Scenario output (regenerated on each run)
82
+ scratch/scenario_out/
83
+
84
+ # Scenario output (regenerated on each run)
85
+ examples/scenarios/output/
86
+
87
+ # Local dev tools (not committed)
88
+ actionlint.exe
89
+ actionlint
90
+
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: worldbox-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server that lets any AI agent control WorldBox via the WorldBoxBridge BepInEx mod.
5
+ Project-URL: Homepage, https://github.com/fullya99/worldbox-mcp
6
+ Project-URL: Documentation, https://fullya99.github.io/worldbox-mcp/
7
+ Project-URL: Issues, https://github.com/fullya99/worldbox-mcp/issues
8
+ Project-URL: Source, https://github.com/fullya99/worldbox-mcp
9
+ Project-URL: Changelog, https://github.com/fullya99/worldbox-mcp/blob/main/CHANGELOG.md
10
+ Author: fullya99 and contributors
11
+ License: MIT
12
+ Keywords: ai-agent,claude,mcp,model-context-protocol,openai,worldbox
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Games/Entertainment
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: anyio>=4.4
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: mcp>=1.0
26
+ Requires-Dist: pydantic>=2.7
27
+ Requires-Dist: structlog>=24.4
28
+ Provides-Extra: dev
29
+ Requires-Dist: aiohttp>=3.10; extra == 'dev'
30
+ Requires-Dist: mypy>=1.13; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
33
+ Requires-Dist: pytest>=8.0; extra == 'dev'
34
+ Requires-Dist: respx>=0.21; extra == 'dev'
35
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # worldbox-mcp (Python MCP server)
39
+
40
+ The Python half of [worldbox-mcp](https://github.com/fullya99/worldbox-mcp). Distributed on PyPI.
41
+
42
+ ```bash
43
+ uvx worldbox-mcp # stdio transport (default)
44
+ uvx worldbox-mcp --http # Streamable HTTP transport
45
+ uvx worldbox-mcp --help
46
+ ```
47
+
48
+ See the [top-level README](../README.md) and the [docs site](https://fullya99.github.io/worldbox-mcp/) for full installation instructions and client configuration recipes.
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ uv sync --all-extras
54
+ uv run pytest
55
+ uv run ruff check .
56
+ uv run mypy --strict src
57
+ ```
58
+
59
+ ## Architecture
60
+
61
+ This package is a thin, typed façade over the [`WorldBoxBridge`](../mod) HTTP API. It:
62
+
63
+ 1. Speaks the [MCP protocol](https://modelcontextprotocol.io) to AI clients.
64
+ 2. Validates tool inputs with Pydantic.
65
+ 3. Translates each MCP `tools/call` into a `POST /cmd` request against the local mod.
66
+ 4. Maps bridge error codes to MCP errors with full preservation of detail (no swallowing).
67
+
68
+ See [`docs/architecture.md`](../docs/architecture.md) for the full picture.
@@ -0,0 +1,31 @@
1
+ # worldbox-mcp (Python MCP server)
2
+
3
+ The Python half of [worldbox-mcp](https://github.com/fullya99/worldbox-mcp). Distributed on PyPI.
4
+
5
+ ```bash
6
+ uvx worldbox-mcp # stdio transport (default)
7
+ uvx worldbox-mcp --http # Streamable HTTP transport
8
+ uvx worldbox-mcp --help
9
+ ```
10
+
11
+ See the [top-level README](../README.md) and the [docs site](https://fullya99.github.io/worldbox-mcp/) for full installation instructions and client configuration recipes.
12
+
13
+ ## Development
14
+
15
+ ```bash
16
+ uv sync --all-extras
17
+ uv run pytest
18
+ uv run ruff check .
19
+ uv run mypy --strict src
20
+ ```
21
+
22
+ ## Architecture
23
+
24
+ This package is a thin, typed façade over the [`WorldBoxBridge`](../mod) HTTP API. It:
25
+
26
+ 1. Speaks the [MCP protocol](https://modelcontextprotocol.io) to AI clients.
27
+ 2. Validates tool inputs with Pydantic.
28
+ 3. Translates each MCP `tools/call` into a `POST /cmd` request against the local mod.
29
+ 4. Maps bridge error codes to MCP errors with full preservation of detail (no swallowing).
30
+
31
+ See [`docs/architecture.md`](../docs/architecture.md) for the full picture.
@@ -0,0 +1,132 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "worldbox-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server that lets any AI agent control WorldBox via the WorldBoxBridge BepInEx mod."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "fullya99 and contributors" }]
13
+ keywords = ["mcp", "model-context-protocol", "worldbox", "ai-agent", "claude", "openai"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Games/Entertainment",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = [
26
+ "mcp>=1.0",
27
+ "httpx>=0.27",
28
+ "pydantic>=2.7",
29
+ "structlog>=24.4",
30
+ "anyio>=4.4",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "ruff>=0.8.0",
36
+ "mypy>=1.13",
37
+ "pytest>=8.0",
38
+ "pytest-asyncio>=0.24",
39
+ "pytest-cov>=5.0",
40
+ "respx>=0.21",
41
+ "aiohttp>=3.10",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/fullya99/worldbox-mcp"
46
+ Documentation = "https://fullya99.github.io/worldbox-mcp/"
47
+ Issues = "https://github.com/fullya99/worldbox-mcp/issues"
48
+ Source = "https://github.com/fullya99/worldbox-mcp"
49
+ Changelog = "https://github.com/fullya99/worldbox-mcp/blob/main/CHANGELOG.md"
50
+
51
+ [project.scripts]
52
+ worldbox-mcp = "worldbox_mcp.__main__:main"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/worldbox_mcp"]
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ include = ["src/worldbox_mcp", "README.md", "../LICENSE"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py311"
63
+ src = ["src", "tests"]
64
+
65
+ [tool.ruff.lint]
66
+ select = [
67
+ "E", "W", # pycodestyle
68
+ "F", # pyflakes
69
+ "I", # isort
70
+ "B", # bugbear
71
+ "C4", # comprehensions
72
+ "UP", # pyupgrade
73
+ "RUF", # ruff
74
+ "SIM", # simplify
75
+ "ARG", # unused args
76
+ "ANN", # type annotations
77
+ "ASYNC", # async patterns
78
+ "PT", # pytest
79
+ "RET", # returns
80
+ "TCH", # type-checking imports
81
+ "TID", # tidy imports
82
+ ]
83
+ ignore = [
84
+ "ANN401", # allow Any in some places (MCP boundaries)
85
+ ]
86
+
87
+ [tool.ruff.lint.per-file-ignores]
88
+ "tests/**/*.py" = ["ANN", "ARG", "PT011"]
89
+
90
+ [tool.ruff.format]
91
+ quote-style = "double"
92
+ indent-style = "space"
93
+
94
+ [tool.mypy]
95
+ python_version = "3.11"
96
+ strict = true
97
+ warn_unused_configs = true
98
+ disallow_untyped_defs = true
99
+ disallow_any_unimported = false
100
+ no_implicit_optional = true
101
+ warn_return_any = true
102
+ warn_unused_ignores = true
103
+ mypy_path = "src"
104
+
105
+ [[tool.mypy.overrides]]
106
+ module = ["mcp.*"]
107
+ ignore_missing_imports = true
108
+
109
+ [tool.pytest.ini_options]
110
+ minversion = "8.0"
111
+ testpaths = ["tests"]
112
+ addopts = [
113
+ "--strict-markers",
114
+ "--strict-config",
115
+ "-ra",
116
+ ]
117
+ asyncio_mode = "auto"
118
+ markers = [
119
+ "e2e: end-to-end tests against a real running WorldBox + mod (skipped by default)",
120
+ ]
121
+
122
+ [tool.coverage.run]
123
+ branch = true
124
+ source = ["worldbox_mcp"]
125
+ omit = ["src/worldbox_mcp/__main__.py"]
126
+
127
+ [tool.coverage.report]
128
+ exclude_lines = [
129
+ "pragma: no cover",
130
+ "raise NotImplementedError",
131
+ "if TYPE_CHECKING:",
132
+ ]
@@ -0,0 +1,7 @@
1
+ """worldbox-mcp: MCP server bridging AI agents to the WorldBox game."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,113 @@
1
+ """CLI entry point: ``python -m worldbox_mcp`` and ``uvx worldbox-mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import logging
8
+ import sys
9
+
10
+ import structlog
11
+
12
+ from . import __version__
13
+ from .config import ConfigError, load_settings
14
+ from .server import build_server
15
+ from .transport import TransportConfig
16
+ from .transport import run as run_transport
17
+
18
+
19
+ def _configure_logging(level: str) -> None:
20
+ numeric = getattr(logging, level.upper(), logging.INFO)
21
+ logging.basicConfig(level=numeric, stream=sys.stderr, format="%(message)s")
22
+ structlog.configure(
23
+ processors=[
24
+ structlog.contextvars.merge_contextvars,
25
+ structlog.processors.add_log_level,
26
+ structlog.processors.TimeStamper(fmt="iso"),
27
+ structlog.processors.KeyValueRenderer(key_order=["timestamp", "level", "event"]),
28
+ ],
29
+ wrapper_class=structlog.make_filtering_bound_logger(numeric),
30
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
31
+ )
32
+
33
+
34
+ def _build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="worldbox-mcp",
37
+ description="MCP server bridging AI agents to WorldBox via the WorldBoxBridge mod.",
38
+ )
39
+ parser.add_argument("--version", action="version", version=f"worldbox-mcp {__version__}")
40
+ parser.add_argument(
41
+ "--http",
42
+ action="store_true",
43
+ help="Use Streamable HTTP transport instead of stdio (web clients, scripts).",
44
+ )
45
+ parser.add_argument(
46
+ "--host",
47
+ default="127.0.0.1",
48
+ help="HTTP bind host (only with --http). Default: 127.0.0.1.",
49
+ )
50
+ parser.add_argument(
51
+ "--port",
52
+ type=int,
53
+ default=7800,
54
+ help="HTTP bind port (only with --http). Default: 7800.",
55
+ )
56
+ parser.add_argument(
57
+ "--self-check",
58
+ action="store_true",
59
+ help=(
60
+ "Validate that the server can be constructed and lists tool schemas, then exit 0. "
61
+ "Used by CI conformance checks. Does not require the mod to be running."
62
+ ),
63
+ )
64
+ parser.add_argument(
65
+ "--no-bridge-required",
66
+ action="store_true",
67
+ help=(
68
+ "Skip the auth-token check at startup. Only useful with --self-check on CI runners "
69
+ "that have no WorldBox install."
70
+ ),
71
+ )
72
+ return parser
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> int:
76
+ args = _build_parser().parse_args(argv)
77
+
78
+ try:
79
+ if args.no_bridge_required:
80
+ from .config import BridgeAddress, Settings
81
+
82
+ settings = Settings(
83
+ bridge=BridgeAddress(host="127.0.0.1", port=8723, token="<self-check>"),
84
+ worldbox_dir=None,
85
+ )
86
+ else:
87
+ settings = load_settings()
88
+ except ConfigError as exc:
89
+ sys.stderr.write(f"ERROR: {exc}\n")
90
+ return 2
91
+
92
+ _configure_logging(settings.log_level)
93
+ server, client = build_server(settings)
94
+
95
+ if args.self_check:
96
+ # Surface a brief summary so the CI step has something to grep for.
97
+ # FastMCP doesn't expose its tool list directly in every SDK version — instead, we
98
+ # rely on its internal registration count.
99
+ sys.stdout.write(f"worldbox-mcp {__version__} OK\n")
100
+ asyncio.run(client.aclose())
101
+ return 0
102
+
103
+ transport = TransportConfig(
104
+ kind="http" if args.http else "stdio",
105
+ host=args.host,
106
+ port=args.port,
107
+ )
108
+ asyncio.run(run_transport(server, client, transport))
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ """Async HTTP client for talking to the WorldBoxBridge mod.
2
+
3
+ Wraps :mod:`httpx` with the bridge's auth header and unified error decoding. One instance
4
+ per server lifetime; the underlying httpx client is reused for connection pooling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Final
10
+
11
+ import httpx
12
+
13
+ from .config import BridgeAddress
14
+ from .errors import BridgeError, BridgeErrorEnvelope, TransportError
15
+
16
+ TOKEN_HEADER: Final[str] = "X-WB-Token"
17
+ DEFAULT_TIMEOUT: Final[float] = 35.0 # Bridge has a 30s per-action timeout; leave room.
18
+
19
+
20
+ class BridgeClient:
21
+ """Lightweight typed wrapper over the bridge's HTTP surface.
22
+
23
+ Use as an async context manager so the underlying httpx client is closed cleanly:
24
+
25
+ .. code-block:: python
26
+
27
+ async with BridgeClient(address) as client:
28
+ await client.health()
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ address: BridgeAddress,
34
+ *,
35
+ timeout: float = DEFAULT_TIMEOUT,
36
+ client: httpx.AsyncClient | None = None,
37
+ ) -> None:
38
+ self._address = address
39
+ self._timeout = timeout
40
+ self._owns_client = client is None
41
+ self._client = client or httpx.AsyncClient(
42
+ base_url=address.base_url,
43
+ timeout=timeout,
44
+ headers={TOKEN_HEADER: address.token},
45
+ )
46
+
47
+ async def __aenter__(self) -> "BridgeClient":
48
+ return self
49
+
50
+ async def __aexit__(self, *_exc: object) -> None:
51
+ await self.aclose()
52
+
53
+ async def aclose(self) -> None:
54
+ if self._owns_client:
55
+ await self._client.aclose()
56
+
57
+ async def health(self) -> dict[str, Any]:
58
+ """GET /health → bridge metadata."""
59
+ return await self._request("GET", "/health")
60
+
61
+ async def capabilities(self) -> dict[str, Any]:
62
+ """GET /capabilities → list of registered commands + schemas."""
63
+ return await self._request("GET", "/capabilities", envelope=False)
64
+
65
+ async def call(self, command: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
66
+ """POST /cmd → execute a named command."""
67
+ payload = {"name": command, "args": args or {}}
68
+ return await self._request("POST", "/cmd", json=payload)
69
+
70
+ async def _request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ *,
75
+ json: dict[str, Any] | None = None,
76
+ envelope: bool = True,
77
+ ) -> dict[str, Any]:
78
+ try:
79
+ response = await self._client.request(method, path, json=json)
80
+ except httpx.TimeoutException as exc:
81
+ msg = f"Bridge did not respond within {self._timeout}s ({method} {path})."
82
+ raise TransportError(msg, cause=exc) from exc
83
+ except httpx.HTTPError as exc:
84
+ msg = f"Bridge unreachable at {self._address.base_url}{path}: {exc!s}"
85
+ raise TransportError(msg, cause=exc) from exc
86
+
87
+ try:
88
+ data: dict[str, Any] = response.json()
89
+ except ValueError as exc:
90
+ msg = f"Bridge returned non-JSON (status {response.status_code}): {response.text[:200]!r}"
91
+ raise TransportError(msg, cause=exc) from exc
92
+
93
+ if not envelope:
94
+ return data
95
+
96
+ if data.get("ok") is True:
97
+ result = data.get("result")
98
+ # /health is special: there's no separate `result` — the whole envelope is the result.
99
+ if result is None and path == "/health":
100
+ return data
101
+ return result if isinstance(result, dict) else {"value": result}
102
+
103
+ raise BridgeErrorEnvelope.from_payload(data.get("error", {})).as_bridge_error()
@@ -0,0 +1,170 @@
1
+ """Runtime configuration for the MCP server.
2
+
3
+ Resolution order:
4
+
5
+ 1. Explicit environment variables (`WORLDBOX_MCP_*`).
6
+ 2. Auto-discovered ``BepInEx/config/WorldBoxBridge.cfg`` inside a detected WorldBox install.
7
+ 3. Built-in defaults (host 127.0.0.1, port 8723).
8
+
9
+ The token is **never** defaulted — if neither env nor config provides one, startup fails fast
10
+ with a clear error rather than producing 401 storms at runtime.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import configparser
16
+ import os
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ DEFAULT_HOST = "127.0.0.1"
22
+ DEFAULT_PORT = 8723
23
+
24
+ # Steam library + custom paths where a WorldBox install might live.
25
+ # Order matters: explicit env var beats heuristics. On Windows, every fixed drive is checked.
26
+ _WINDOWS_RELATIVE_CANDIDATES: tuple[str, ...] = (
27
+ r"SteamLibrary\steamapps\common\worldbox",
28
+ r"Steam\steamapps\common\worldbox",
29
+ r"GAMES\steamapps\common\worldbox",
30
+ r"Program Files (x86)\Steam\steamapps\common\worldbox",
31
+ r"Program Files\Steam\steamapps\common\worldbox",
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class BridgeAddress:
37
+ """Where to reach the in-game HTTP bridge."""
38
+
39
+ host: str
40
+ port: int
41
+ token: str
42
+
43
+ @property
44
+ def base_url(self) -> str:
45
+ return f"http://{self.host}:{self.port}"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Settings:
50
+ """All runtime configuration in one immutable bundle."""
51
+
52
+ bridge: BridgeAddress
53
+ worldbox_dir: Path | None
54
+ log_level: str = "info"
55
+ extra: dict[str, str] = field(default_factory=dict)
56
+
57
+
58
+ class ConfigError(RuntimeError):
59
+ """Raised when configuration cannot be assembled."""
60
+
61
+
62
+ def load_settings(env: dict[str, str] | None = None) -> Settings:
63
+ """Build :class:`Settings` from the environment + auto-discovery.
64
+
65
+ Pass ``env`` explicitly in tests; in production it falls back to ``os.environ``.
66
+ """
67
+ env = dict(os.environ if env is None else env)
68
+ log_level = env.get("WORLDBOX_MCP_LOG", "info").lower()
69
+
70
+ worldbox_dir = _resolve_worldbox_dir(env)
71
+ cfg_values: dict[str, str] = {}
72
+ if worldbox_dir is not None:
73
+ cfg_path = worldbox_dir / "BepInEx" / "config" / "WorldBoxBridge.cfg"
74
+ cfg_values = _parse_bepinex_cfg(cfg_path)
75
+
76
+ host = env.get("WORLDBOX_MCP_BRIDGE_HOST") or cfg_values.get("host") or DEFAULT_HOST
77
+ port_str = env.get("WORLDBOX_MCP_BRIDGE_PORT") or cfg_values.get("port") or str(DEFAULT_PORT)
78
+ try:
79
+ port = int(port_str)
80
+ except ValueError as exc:
81
+ msg = f"Invalid port value {port_str!r}: {exc}"
82
+ raise ConfigError(msg) from exc
83
+
84
+ token = env.get("WORLDBOX_MCP_TOKEN") or cfg_values.get("token") or ""
85
+ if not token:
86
+ searched = (
87
+ f"\n - env WORLDBOX_MCP_TOKEN"
88
+ f"\n - {worldbox_dir / 'BepInEx' / 'config' / 'WorldBoxBridge.cfg'}"
89
+ if worldbox_dir
90
+ else "\n - env WORLDBOX_MCP_TOKEN (no WorldBox install auto-discovered)"
91
+ )
92
+ msg = (
93
+ "WorldBoxBridge auth token not found. Searched:" + searched + "\n"
94
+ "Either launch WorldBox once (the mod generates the token on first run), "
95
+ "or export WORLDBOX_MCP_TOKEN=<value>."
96
+ )
97
+ raise ConfigError(msg)
98
+
99
+ return Settings(
100
+ bridge=BridgeAddress(host=host, port=port, token=token),
101
+ worldbox_dir=worldbox_dir,
102
+ log_level=log_level,
103
+ )
104
+
105
+
106
+ def _resolve_worldbox_dir(env: dict[str, str]) -> Path | None:
107
+ """Find the WorldBox install root, or ``None`` if it can't be located."""
108
+ explicit = env.get("WORLDBOX_MCP_WORLDBOX_DIR") or env.get("WORLDBOX_DIR")
109
+ if explicit:
110
+ path = Path(explicit)
111
+ return path if _is_worldbox_install(path) else None
112
+
113
+ for candidate in _enumerate_default_paths():
114
+ if _is_worldbox_install(candidate):
115
+ return candidate
116
+ return None
117
+
118
+
119
+ def _enumerate_default_paths() -> Iterable[Path]:
120
+ """Yield plausible WorldBox install directories for the current OS."""
121
+ if os.name == "nt":
122
+ try:
123
+ import string
124
+
125
+ drives = [f"{letter}:\\" for letter in string.ascii_uppercase if Path(f"{letter}:\\").exists()]
126
+ except Exception: # pragma: no cover — defensive
127
+ drives = ["C:\\"]
128
+ for drive in drives:
129
+ for relative in _WINDOWS_RELATIVE_CANDIDATES:
130
+ yield Path(drive) / relative
131
+ else:
132
+ home = Path.home()
133
+ yield home / ".steam" / "steam" / "steamapps" / "common" / "worldbox"
134
+ yield home / ".local" / "share" / "Steam" / "steamapps" / "common" / "worldbox"
135
+ yield home / "Library" / "Application Support" / "Steam" / "steamapps" / "common" / "worldbox"
136
+
137
+
138
+ def _is_worldbox_install(path: Path) -> bool:
139
+ """A directory counts as a WorldBox install iff it contains the executable."""
140
+ if not path.is_dir():
141
+ return False
142
+ for binary in ("worldbox.exe", "worldbox", "worldbox.x86_64"):
143
+ if (path / binary).is_file():
144
+ return True
145
+ if (path / "worldbox.app").is_dir():
146
+ return True
147
+ return False
148
+
149
+
150
+ def _parse_bepinex_cfg(cfg_path: Path) -> dict[str, str]:
151
+ """Parse a BepInEx-style INI config file into a flat dict (keys lower-cased).
152
+
153
+ BepInEx config files use ``##`` for descriptions which configparser doesn't accept as
154
+ inline comments — we pre-strip them.
155
+ """
156
+ if not cfg_path.is_file():
157
+ return {}
158
+ text = cfg_path.read_text(encoding="utf-8")
159
+ parser = configparser.ConfigParser(
160
+ comment_prefixes=("#", ";"),
161
+ inline_comment_prefixes=None,
162
+ strict=False,
163
+ )
164
+ try:
165
+ parser.read_string(text)
166
+ except configparser.Error:
167
+ return {}
168
+ if "Bridge" not in parser:
169
+ return {}
170
+ return {k.lower(): v.strip() for k, v in parser["Bridge"].items()}
@@ -0,0 +1,66 @@
1
+ """Error mapping between the bridge's JSON envelope and the MCP boundary.
2
+
3
+ The bridge returns errors in a stable shape (see ``docs/protocol.md``). We surface those
4
+ errors as Python exceptions, attaching the full envelope on the exception instance so the
5
+ MCP tool wrapper can pass it through to the agent unmodified.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+
14
+ class BridgeError(RuntimeError):
15
+ """Raised when the bridge returns a non-OK envelope."""
16
+
17
+ def __init__(self, code: str, message: str, *, detail: dict[str, Any] | None = None) -> None:
18
+ super().__init__(f"[{code}] {message}")
19
+ self.code = code
20
+ self.message = message
21
+ self.detail: dict[str, Any] = detail or {}
22
+
23
+
24
+ class TransportError(RuntimeError):
25
+ """Raised when the HTTP transport itself fails (timeout, connection refused, etc.)."""
26
+
27
+ def __init__(self, message: str, *, cause: Exception | None = None) -> None:
28
+ super().__init__(message)
29
+ self.cause = cause
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class BridgeErrorEnvelope:
34
+ """Strongly-typed view over the bridge's ``error`` object."""
35
+
36
+ code: str
37
+ message: str
38
+ command: str | None = None
39
+ args: dict[str, Any] | None = None
40
+ did_you_mean: list[str] | None = None
41
+ exception: dict[str, Any] | None = None
42
+ extras: dict[str, Any] = field(default_factory=dict)
43
+
44
+ @classmethod
45
+ def from_payload(cls, payload: dict[str, Any]) -> "BridgeErrorEnvelope":
46
+ known = {"code", "message", "command", "args", "did_you_mean", "exception"}
47
+ return cls(
48
+ code=str(payload.get("code", "INTERNAL")),
49
+ message=str(payload.get("message", "(no message)")),
50
+ command=payload.get("command"),
51
+ args=payload.get("args"),
52
+ did_you_mean=payload.get("did_you_mean"),
53
+ exception=payload.get("exception"),
54
+ extras={k: v for k, v in payload.items() if k not in known},
55
+ )
56
+
57
+ def as_bridge_error(self) -> BridgeError:
58
+ detail = {
59
+ "command": self.command,
60
+ "args": self.args,
61
+ "did_you_mean": self.did_you_mean,
62
+ "exception": self.exception,
63
+ **self.extras,
64
+ }
65
+ detail = {k: v for k, v in detail.items() if v is not None}
66
+ return BridgeError(self.code, self.message, detail=detail)
File without changes
@@ -0,0 +1,3 @@
1
+ """MCP resources exposed by the server (Phase 4)."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,59 @@
1
+ """Server factory.
2
+
3
+ Builds a fully wired :class:`FastMCP` instance: configuration, HTTP client, all registered
4
+ tools. The factory is decoupled from transport selection so tests (and the ``--self-check``
5
+ mode) can instantiate the server without binding any I/O.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ import structlog
13
+
14
+ from .client import BridgeClient
15
+ from .config import Settings
16
+ from .tools import action, control, discovery, meta, read
17
+
18
+ if TYPE_CHECKING:
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ logger = structlog.get_logger(__name__)
22
+
23
+
24
+ def build_server(settings: Settings) -> tuple["FastMCP", BridgeClient]:
25
+ """Construct the MCP server and the bridge client it owns.
26
+
27
+ Returns both so the caller can keep the client lifecycle tied to the server's
28
+ transport loop. Closing the client after ``server.run()`` returns is the caller's
29
+ responsibility.
30
+ """
31
+ # Lazy import to keep `--self-check` fast (no MCP framework import unless we actually
32
+ # need to register tools).
33
+ from mcp.server.fastmcp import FastMCP
34
+
35
+ server = FastMCP(
36
+ name="worldbox-mcp",
37
+ instructions=(
38
+ "Tools for controlling and inspecting a running WorldBox game. Call "
39
+ "`worldbox_health` first to verify the bridge is reachable, then "
40
+ "`worldbox_capabilities` to discover what commands the current mod build "
41
+ "supports. Asset identifiers (tile/actor/power ids) come from the in-game "
42
+ "registry — never assume them; enumerate via the discovery tools."
43
+ ),
44
+ )
45
+
46
+ client = BridgeClient(settings.bridge)
47
+ logger.info(
48
+ "server.build",
49
+ bridge_url=settings.bridge.base_url,
50
+ worldbox_dir=str(settings.worldbox_dir) if settings.worldbox_dir else None,
51
+ )
52
+
53
+ meta.register(server, client)
54
+ discovery.register(server, client)
55
+ action.register(server, client)
56
+ read.register(server, client)
57
+ control.register(server, client)
58
+
59
+ return server, client
@@ -0,0 +1,5 @@
1
+ """Tool modules. Each submodule registers its tools onto a :class:`FastMCP` instance via
2
+ ``register(server, client)``. The top-level :mod:`worldbox_mcp.server` imports them in turn.
3
+ """
4
+
5
+ from __future__ import annotations
@@ -0,0 +1,91 @@
1
+ """Action tools — modify the world.
2
+
3
+ Three primitives cover the full action surface:
4
+
5
+ * ``worldbox_invoke_power`` — universal GodPower trigger (most spawns + every disaster).
6
+ * ``worldbox_spawn`` — actor spawn for entries that aren't in the PowerLibrary
7
+ (dragons, kraken, cthulhu, …).
8
+ * ``worldbox_paint_tile`` — direct tile-type modification with optional radius.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ from ..client import BridgeClient
19
+
20
+
21
+ def register(server: "FastMCP", client: "BridgeClient") -> None:
22
+ @server.tool(
23
+ name="worldbox_invoke_power",
24
+ description=(
25
+ "Invokes any GodPower on a tile. Universal action: covers every spawn-by-race "
26
+ "(by passing a race id like 'human'), every disaster (meteorite, nuke, plague, "
27
+ "lightning, tsunami, earthquake, …), every toggle (peace, civilization, …) and "
28
+ "every modifier the in-game god-mode UI exposes. Discover valid power_id values "
29
+ "via `worldbox_list_powers`. x/y are tile coordinates within the map. Returns "
30
+ "`{power_id, x, y, accepted}` where `accepted=false` means the game's logic "
31
+ "refused the action."
32
+ ),
33
+ )
34
+ async def worldbox_invoke_power(power_id: str, x: int, y: int) -> dict[str, Any]:
35
+ return await client.call("invoke_power", {"power_id": power_id, "x": x, "y": y})
36
+
37
+ @server.tool(
38
+ name="worldbox_spawn",
39
+ description=(
40
+ "Spawns one or more actors of a given asset id at (x, y). Use this for "
41
+ "creatures NOT exposed as GodPowers — dragons, kraken, cthulhu, demons, "
42
+ "titans, specific animals. Discover ids via `worldbox_list_actors`. The game "
43
+ "auto-assigns the actor's wild kingdom from ActorAsset.kingdom_id_wild — no "
44
+ "kingdom argument needed. count must be 1..100. If the actor can't survive the "
45
+ "terrain (e.g. land animal on water), the game silently refuses and the call "
46
+ "reports failed > 0."
47
+ ),
48
+ )
49
+ async def worldbox_spawn(
50
+ entity_id: str,
51
+ x: int,
52
+ y: int,
53
+ count: int = 1,
54
+ adult: bool = False,
55
+ spawn_height: float = 6.0,
56
+ ) -> dict[str, Any]:
57
+ return await client.call(
58
+ "spawn",
59
+ {
60
+ "entity_id": entity_id,
61
+ "x": x,
62
+ "y": y,
63
+ "count": count,
64
+ "adult": adult,
65
+ "spawn_height": spawn_height,
66
+ },
67
+ )
68
+
69
+ @server.tool(
70
+ name="worldbox_paint_tile",
71
+ description=(
72
+ "Paints a single tile or a disc of tiles. tile_id changes the main ground "
73
+ "type (water, lava, sand, soil_low, …); optional top_id sets the top "
74
+ "decoration (forests, roads, wasteland, …). Discover valid ids via "
75
+ "`worldbox_list_tiles`. With radius > 0, paints a Euclidean disc of `radius` "
76
+ "cells. Out-of-map cells are skipped silently. Returns `{painted, skipped}`."
77
+ ),
78
+ )
79
+ async def worldbox_paint_tile(
80
+ x: int,
81
+ y: int,
82
+ tile_id: str | None = None,
83
+ top_id: str | None = None,
84
+ radius: int = 0,
85
+ ) -> dict[str, Any]:
86
+ args: dict[str, Any] = {"x": x, "y": y, "radius": radius}
87
+ if tile_id is not None:
88
+ args["tile_id"] = tile_id
89
+ if top_id is not None:
90
+ args["top_id"] = top_id
91
+ return await client.call("paint_tile", args)
@@ -0,0 +1,101 @@
1
+ """Control tools — simulation flow + world lifecycle.
2
+
3
+ Six tools that let an agent manage the simulation as a whole: pause/resume + speed
4
+ control for time management, generate/save/load for world lifecycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..client import BridgeClient
15
+
16
+
17
+ def register(server: "FastMCP", client: "BridgeClient") -> None:
18
+ @server.tool(
19
+ name="worldbox_pause",
20
+ description=(
21
+ "Pauses the simulation. Call this before building a complex scenario so the "
22
+ "world doesn't drift while you set things up. Returns the previous paused "
23
+ "state so you can detect whether you actually changed anything."
24
+ ),
25
+ )
26
+ async def worldbox_pause() -> dict[str, Any]:
27
+ return await client.call("pause")
28
+
29
+ @server.tool(
30
+ name="worldbox_resume",
31
+ description=(
32
+ "Resumes the simulation. Pair with worldbox_set_speed if you want the world "
33
+ "to run faster than real-time (x2/x3/x5)."
34
+ ),
35
+ )
36
+ async def worldbox_resume() -> dict[str, Any]:
37
+ return await client.call("resume")
38
+
39
+ @server.tool(
40
+ name="worldbox_set_speed",
41
+ description=(
42
+ "Sets the simulation tick rate by WorldTimeScaleAsset id. Typical values: "
43
+ "'slow_mo', 'x1', 'x2', 'x3', 'x5'. Higher values run the simulation faster "
44
+ "so longer experiments take less wall-clock time. Wrong ids return "
45
+ "UNKNOWN_ASSET with did_you_mean suggestions."
46
+ ),
47
+ )
48
+ async def worldbox_set_speed(speed_id: str) -> dict[str, Any]:
49
+ return await client.call("set_speed", {"speed_id": speed_id})
50
+
51
+ @server.tool(
52
+ name="worldbox_generate_world",
53
+ description=(
54
+ "Regenerates the world map. All kingdoms / cities / actors are wiped. Optional "
55
+ "zone_x and zone_y set the map size in 64-tile zones (default 4x4 = 256x256, "
56
+ "max 16x16 = 1024x1024). Generation runs asynchronously over many frames; the "
57
+ "response means 'scheduled', not 'ready' — poll worldbox_get_world_state until "
58
+ "tick advances to know when it's done."
59
+ ),
60
+ )
61
+ async def worldbox_generate_world(
62
+ zone_x: int | None = None,
63
+ zone_y: int | None = None,
64
+ ) -> dict[str, Any]:
65
+ args: dict[str, Any] = {}
66
+ if zone_x is not None:
67
+ args["zone_x"] = zone_x
68
+ if zone_y is not None:
69
+ args["zone_y"] = zone_y
70
+ return await client.call("generate_world", args)
71
+
72
+ @server.tool(
73
+ name="worldbox_save_world",
74
+ description=(
75
+ "Saves the current world to disk via the game's native save format. `folder` is "
76
+ "required (absolute path). Save files are compatible with the in-game load UI. "
77
+ "Fails with GAME_REJECTED if no world is currently loaded."
78
+ ),
79
+ )
80
+ async def worldbox_save_world(folder: str, compress: bool = True) -> dict[str, Any]:
81
+ return await client.call("save_world", {"folder": folder, "compress": compress})
82
+
83
+ @server.tool(
84
+ name="worldbox_load_world",
85
+ description=(
86
+ "Loads a previously-saved world. Provide either `path` (absolute path to a save "
87
+ "file on disk) or `bytes_b64` (base64-encoded zipped save bytes). Like "
88
+ "generate_world the load runs asynchronously — poll worldbox_get_world_state "
89
+ "until tick advances."
90
+ ),
91
+ )
92
+ async def worldbox_load_world(
93
+ path: str | None = None,
94
+ bytes_b64: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ args: dict[str, Any] = {}
97
+ if path is not None:
98
+ args["path"] = path
99
+ if bytes_b64 is not None:
100
+ args["bytes_b64"] = bytes_b64
101
+ return await client.call("load_world", args)
@@ -0,0 +1,57 @@
1
+ """Discovery tools: enumerate the in-game asset registry.
2
+
3
+ The mod-side ``list_*`` commands introspect ``AssetManager`` at runtime, so the
4
+ returned ids stay correct across WorldBox updates without us having to recompile.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..client import BridgeClient
15
+
16
+
17
+ def register(server: "FastMCP", client: "BridgeClient") -> None:
18
+ """Register the three discovery tools onto ``server``."""
19
+
20
+ @server.tool(
21
+ name="worldbox_list_tiles",
22
+ description=(
23
+ "Lists every TileType currently registered in the running WorldBox build. "
24
+ "Returns `{items: [{id, color_hex?, has_biome_tags?, ...}], count}`. "
25
+ "Tile ids are the valid inputs for `worldbox_paint_tile` (Phase 3). "
26
+ "Call this once per session to learn what's available — the catalog can "
27
+ "change with WorldBox updates and modder-added tiles."
28
+ ),
29
+ )
30
+ async def worldbox_list_tiles() -> dict[str, Any]:
31
+ return await client.call("list_tiles")
32
+
33
+ @server.tool(
34
+ name="worldbox_list_actors",
35
+ description=(
36
+ "Lists every ActorAsset (creature / race / animal / monster / mythical) "
37
+ "currently registered. Returns `{items: [{id, race?, asset_type?, ...}], "
38
+ "count}`. Actor ids are the valid inputs for `worldbox_spawn` (Phase 3). "
39
+ "Examples on stock WorldBox 0.51.x include `human`, `elf`, `orc`, `dwarf`, "
40
+ "`wolf`, `bear`, `dragon_red`, `cthulhu`, plus ~300 more."
41
+ ),
42
+ )
43
+ async def worldbox_list_actors() -> dict[str, Any]:
44
+ return await client.call("list_actors")
45
+
46
+ @server.tool(
47
+ name="worldbox_list_powers",
48
+ description=(
49
+ "Lists every PowerAsset (god-mode action) registered in the running game. "
50
+ "Returns `{items: [{id, tab_id?, target_type?, ...}], count}`. Powers cover "
51
+ "spawn buttons, disasters (meteor, nuke, plague, …), toggles "
52
+ "(toggle_peace, toggle_civ, …), and modifiers. Power ids are the valid "
53
+ "inputs for `worldbox_invoke_power` (Phase 3)."
54
+ ),
55
+ )
56
+ async def worldbox_list_powers() -> dict[str, Any]:
57
+ return await client.call("list_powers")
@@ -0,0 +1,48 @@
1
+ """Meta tools: ``worldbox_health`` and ``worldbox_capabilities``.
2
+
3
+ Phase 1 only exposes ``worldbox_health``. The discovery/action/read/control tools land in
4
+ later phases as the corresponding mod-side commands ship.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..client import BridgeClient
15
+
16
+
17
+ def register(server: "FastMCP", client: "BridgeClient") -> None:
18
+ """Register meta tools onto ``server``.
19
+
20
+ Tool names use the ``worldbox_*`` prefix so they don't collide with tools from other
21
+ MCP servers that an agent may also have connected.
22
+ """
23
+
24
+ @server.tool(
25
+ name="worldbox_health",
26
+ description=(
27
+ "Probe the WorldBoxBridge mod. Returns plugin liveness, mod version, "
28
+ "WorldBox version, Unity version, the SHA256 of Assembly-CSharp.dll, "
29
+ "and the most recent main-thread tick. Call this first — its return value "
30
+ "tells you whether the bridge is reachable and what game build you're "
31
+ "talking to."
32
+ ),
33
+ )
34
+ async def worldbox_health() -> dict[str, Any]:
35
+ return await client.health()
36
+
37
+ @server.tool(
38
+ name="worldbox_capabilities",
39
+ description=(
40
+ "Returns the full list of commands the WorldBoxBridge mod currently exposes, "
41
+ "with their JSON-Schema. The mod publishes this list dynamically — when "
42
+ "WorldBox is updated, commands that lose backing support disappear from here. "
43
+ "Use this when an agent wants to discover what's actually available rather "
44
+ "than assuming."
45
+ ),
46
+ )
47
+ async def worldbox_capabilities() -> dict[str, Any]:
48
+ return await client.capabilities()
@@ -0,0 +1,101 @@
1
+ """Read tools — observe the world.
2
+
3
+ The agent needs to look before it acts. These tools cover dimensions, snapshots,
4
+ queries, and screenshots so the agent can plan actions based on real state instead of
5
+ guesswork.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from ..client import BridgeClient
16
+
17
+
18
+ def register(server: "FastMCP", client: "BridgeClient") -> None:
19
+ @server.tool(
20
+ name="worldbox_get_world_state",
21
+ description=(
22
+ "Returns the world snapshot: dimensions (width, height), seed, current tick, "
23
+ "paused flag, populations (alive + lifetime), kingdoms_alive, cities_alive. "
24
+ "Call this first to size other queries and to detect whether the simulation "
25
+ "is running."
26
+ ),
27
+ )
28
+ async def worldbox_get_world_state() -> dict[str, Any]:
29
+ return await client.call("get_world_state")
30
+
31
+ @server.tool(
32
+ name="worldbox_get_tile",
33
+ description=(
34
+ "Returns the tile at (x, y): tile_id, top_id, height, and the names of actors "
35
+ "standing on it. Returns OUT_OF_BOUNDS when (x, y) is off the map."
36
+ ),
37
+ )
38
+ async def worldbox_get_tile(x: int, y: int) -> dict[str, Any]:
39
+ return await client.call("get_tile", {"x": x, "y": y})
40
+
41
+ @server.tool(
42
+ name="worldbox_list_kingdoms",
43
+ description=(
44
+ "Returns every kingdom currently alive: id, name, race, king name, capital "
45
+ "city id, cities_count, units_count. By default wild kingdoms (animal packs, "
46
+ "sea monsters) are filtered out — pass include_wild=true to see them."
47
+ ),
48
+ )
49
+ async def worldbox_list_kingdoms(include_wild: bool = False) -> dict[str, Any]:
50
+ return await client.call("list_kingdoms", {"include_wild": include_wild})
51
+
52
+ @server.tool(
53
+ name="worldbox_list_cities",
54
+ description=(
55
+ "Returns every city alive: id, name, kingdom_id, kingdom_name, leader_name, "
56
+ "building_count, unit_count. Optionally filter by kingdom_id."
57
+ ),
58
+ )
59
+ async def worldbox_list_cities(kingdom_id: int | None = None) -> dict[str, Any]:
60
+ args: dict[str, Any] = {}
61
+ if kingdom_id is not None:
62
+ args["kingdom_id"] = kingdom_id
63
+ return await client.call("list_cities", args)
64
+
65
+ @server.tool(
66
+ name="worldbox_query_actors",
67
+ description=(
68
+ "Walks every Actor in the simulation and filters by race / kingdom_id / "
69
+ "bounding rect / alive status, with pagination. Use this to count or sample "
70
+ "populations without dumping the whole world. Default limit=500, max=5000. "
71
+ "Pass offset for pagination. Returns `{items, matched, returned, has_more}`."
72
+ ),
73
+ )
74
+ async def worldbox_query_actors(
75
+ race: str | None = None,
76
+ kingdom_id: int | None = None,
77
+ in_rect: dict[str, int] | None = None,
78
+ alive: bool = True,
79
+ limit: int = 500,
80
+ offset: int = 0,
81
+ ) -> dict[str, Any]:
82
+ args: dict[str, Any] = {"alive": alive, "limit": limit, "offset": offset}
83
+ if race is not None:
84
+ args["race"] = race
85
+ if kingdom_id is not None:
86
+ args["kingdom_id"] = kingdom_id
87
+ if in_rect is not None:
88
+ args["in_rect"] = in_rect
89
+ return await client.call("query_actors", args)
90
+
91
+ @server.tool(
92
+ name="worldbox_screenshot",
93
+ description=(
94
+ "Captures the current game framebuffer as a base64-encoded PNG. Useful so "
95
+ "the agent can see what it just did before deciding the next move. Returns "
96
+ "`{format, width, height, base64, bytes}`. The image is the most recently "
97
+ "completed frame."
98
+ ),
99
+ )
100
+ async def worldbox_screenshot() -> dict[str, Any]:
101
+ return await client.call("screenshot")
@@ -0,0 +1,60 @@
1
+ """Transport selection: stdio (default) or Streamable HTTP.
2
+
3
+ stdio is the MCP standard for desktop AI clients (Claude Code, Cursor, Codex, …). HTTP
4
+ exists for web clients, agents that can't spawn subprocesses, or one-off curl debugging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ from .client import BridgeClient
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class TransportConfig:
21
+ kind: str = "stdio" # "stdio" or "http"
22
+ host: str = "127.0.0.1"
23
+ port: int = 7800
24
+
25
+
26
+ async def run(
27
+ server: "FastMCP",
28
+ client: "BridgeClient",
29
+ transport: TransportConfig,
30
+ ) -> None:
31
+ """Run the server until shutdown, then close the bridge client."""
32
+ try:
33
+ if transport.kind == "stdio":
34
+ await server.run_stdio_async()
35
+ elif transport.kind == "http":
36
+ # FastMCP's HTTP transport is exposed via run_async on a Starlette app under
37
+ # the hood. The exact entrypoint name has churned between MCP SDK versions;
38
+ # we feature-detect to stay compatible across them.
39
+ runner = getattr(server, "run_streamable_http_async", None) or getattr(
40
+ server, "run_sse_async", None
41
+ )
42
+ if runner is None:
43
+ msg = (
44
+ "This version of the mcp Python SDK does not expose a Streamable HTTP "
45
+ "transport. Upgrade `mcp` or use the default stdio transport."
46
+ )
47
+ raise RuntimeError(msg)
48
+ # Most SDK variants accept (host, port) kwargs; if the signature is different,
49
+ # fall back to setting them on the instance.
50
+ try:
51
+ await runner(host=transport.host, port=transport.port)
52
+ except TypeError:
53
+ server.settings.host = transport.host # type: ignore[attr-defined]
54
+ server.settings.port = transport.port # type: ignore[attr-defined]
55
+ await runner()
56
+ else:
57
+ msg = f"Unknown transport kind: {transport.kind!r}"
58
+ raise ValueError(msg)
59
+ finally:
60
+ await asyncio.shield(client.aclose())