mcp-server-if 0.1.0__py3-none-macosx_13_0_arm64.whl → 0.2.0__py3-none-macosx_13_0_arm64.whl
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.
- mcp_server_if/bin/bocfel +0 -0
- mcp_server_if/bin/glulxe +0 -0
- mcp_server_if/config.py +52 -22
- mcp_server_if/server.py +39 -18
- mcp_server_if/session.py +125 -32
- {mcp_server_if-0.1.0.dist-info → mcp_server_if-0.2.0.dist-info}/METADATA +29 -20
- mcp_server_if-0.2.0.dist-info/RECORD +13 -0
- mcp_server_if-0.1.0.dist-info/RECORD +0 -12
- {mcp_server_if-0.1.0.dist-info → mcp_server_if-0.2.0.dist-info}/WHEEL +0 -0
- {mcp_server_if-0.1.0.dist-info → mcp_server_if-0.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_server_if-0.1.0.dist-info → mcp_server_if-0.2.0.dist-info}/licenses/LICENSE +0 -0
mcp_server_if/bin/bocfel
ADDED
|
Binary file
|
mcp_server_if/bin/glulxe
CHANGED
|
Binary file
|
mcp_server_if/config.py
CHANGED
|
@@ -13,20 +13,20 @@ def get_games_dir() -> Path:
|
|
|
13
13
|
return Path.home() / ".mcp-server-if" / "games"
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
"""Get
|
|
16
|
+
def _get_bundled_binary(name: str) -> Path | None:
|
|
17
|
+
"""Get a bundled binary path if it exists."""
|
|
18
18
|
package_dir = Path(__file__).parent
|
|
19
|
-
for
|
|
20
|
-
bundled = package_dir / "bin" /
|
|
19
|
+
for suffix in (name, f"{name}.exe"):
|
|
20
|
+
bundled = package_dir / "bin" / suffix
|
|
21
21
|
if bundled.exists() and bundled.is_file():
|
|
22
22
|
return bundled
|
|
23
23
|
return None
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
"""
|
|
26
|
+
def _find_binary(name: str, env_var: str) -> Path | None:
|
|
27
|
+
"""Find a binary from env var, bundled, PATH, or common locations."""
|
|
28
28
|
# 1. Check environment variable
|
|
29
|
-
env_path = os.environ.get(
|
|
29
|
+
env_path = os.environ.get(env_var)
|
|
30
30
|
if env_path:
|
|
31
31
|
path = Path(env_path)
|
|
32
32
|
if path.exists() and path.is_file():
|
|
@@ -34,20 +34,20 @@ def get_glulxe_path() -> Path | None:
|
|
|
34
34
|
return None
|
|
35
35
|
|
|
36
36
|
# 2. Check for bundled binary (installed with package)
|
|
37
|
-
bundled =
|
|
37
|
+
bundled = _get_bundled_binary(name)
|
|
38
38
|
if bundled:
|
|
39
39
|
return bundled
|
|
40
40
|
|
|
41
|
-
# 3. Try to find
|
|
42
|
-
|
|
43
|
-
if
|
|
44
|
-
return Path(
|
|
41
|
+
# 3. Try to find in PATH
|
|
42
|
+
in_path = shutil.which(name)
|
|
43
|
+
if in_path:
|
|
44
|
+
return Path(in_path)
|
|
45
45
|
|
|
46
46
|
# 4. Check common locations
|
|
47
47
|
common_paths = [
|
|
48
|
-
Path.home() / ".local" / "bin" /
|
|
49
|
-
Path("/usr/local/bin
|
|
50
|
-
Path("/usr/bin
|
|
48
|
+
Path.home() / ".local" / "bin" / name,
|
|
49
|
+
Path("/usr/local/bin") / name,
|
|
50
|
+
Path("/usr/bin") / name,
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
for path in common_paths:
|
|
@@ -57,6 +57,27 @@ def get_glulxe_path() -> Path | None:
|
|
|
57
57
|
return None
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
# Keep public API for backwards compatibility
|
|
61
|
+
def get_bundled_glulxe() -> Path | None:
|
|
62
|
+
"""Get the bundled glulxe binary path if it exists."""
|
|
63
|
+
return _get_bundled_binary("glulxe")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_glulxe_path() -> Path | None:
|
|
67
|
+
"""Get the glulxe binary path from environment, bundled, or auto-detect."""
|
|
68
|
+
return _find_binary("glulxe", "IF_GLULXE_PATH")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_bundled_bocfel() -> Path | None:
|
|
72
|
+
"""Get the bundled bocfel binary path if it exists."""
|
|
73
|
+
return _get_bundled_binary("bocfel")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_bocfel_path() -> Path | None:
|
|
77
|
+
"""Get the bocfel binary path from environment, bundled, or auto-detect."""
|
|
78
|
+
return _find_binary("bocfel", "IF_BOCFEL_PATH")
|
|
79
|
+
|
|
80
|
+
|
|
60
81
|
def _get_require_journal() -> bool:
|
|
61
82
|
"""Check if journal mode is enabled."""
|
|
62
83
|
return os.environ.get("IF_REQUIRE_JOURNAL", "").lower() in ("1", "true", "yes")
|
|
@@ -69,10 +90,12 @@ class Config:
|
|
|
69
90
|
self,
|
|
70
91
|
games_dir: Path | None = None,
|
|
71
92
|
glulxe_path: Path | None = None,
|
|
93
|
+
bocfel_path: Path | None = None,
|
|
72
94
|
require_journal: bool | None = None,
|
|
73
95
|
):
|
|
74
96
|
self.games_dir = games_dir or get_games_dir()
|
|
75
97
|
self.glulxe_path: Path | None = glulxe_path or get_glulxe_path()
|
|
98
|
+
self.bocfel_path: Path | None = bocfel_path or get_bocfel_path()
|
|
76
99
|
self._require_journal = require_journal if require_journal is not None else _get_require_journal()
|
|
77
100
|
|
|
78
101
|
@property
|
|
@@ -84,20 +107,27 @@ class Config:
|
|
|
84
107
|
self.games_dir.mkdir(parents=True, exist_ok=True)
|
|
85
108
|
|
|
86
109
|
def validate(self) -> list[str]:
|
|
87
|
-
"""Validate configuration. Returns list of errors."""
|
|
110
|
+
"""Validate glulxe configuration. Returns list of errors."""
|
|
111
|
+
return self._validate_binary("glulxe", self.glulxe_path)
|
|
112
|
+
|
|
113
|
+
def validate_bocfel(self) -> list[str]:
|
|
114
|
+
"""Validate bocfel configuration. Returns list of errors."""
|
|
115
|
+
return self._validate_binary("bocfel", self.bocfel_path)
|
|
116
|
+
|
|
117
|
+
def _validate_binary(self, name: str, path: Path | None) -> list[str]:
|
|
88
118
|
errors = []
|
|
89
|
-
if not
|
|
119
|
+
if not path:
|
|
90
120
|
checked = [
|
|
91
|
-
"
|
|
121
|
+
f"IF_{name.upper()}_PATH env var",
|
|
92
122
|
f"bundled binary at {Path(__file__).parent / 'bin'}",
|
|
93
|
-
"
|
|
123
|
+
f"{name} in PATH",
|
|
94
124
|
]
|
|
95
125
|
errors.append(
|
|
96
|
-
"
|
|
126
|
+
f"{name} binary not found. Checked:\n"
|
|
97
127
|
+ "\n".join(f" - {loc}" for loc in checked)
|
|
98
128
|
+ "\n\nFor development: run 'uv sync --reinstall-package mcp-server-if' to compile from source."
|
|
99
129
|
+ "\nFor production: install the wheel from PyPI."
|
|
100
130
|
)
|
|
101
|
-
elif not
|
|
102
|
-
errors.append(f"
|
|
131
|
+
elif not path.exists():
|
|
132
|
+
errors.append(f"{name} binary not found at: {path}")
|
|
103
133
|
return errors
|
mcp_server_if/server.py
CHANGED
|
@@ -14,7 +14,7 @@ import httpx
|
|
|
14
14
|
from mcp.server.fastmcp import FastMCP
|
|
15
15
|
|
|
16
16
|
from .config import Config
|
|
17
|
-
from .session import GlulxSession, detect_game_format, find_game_file
|
|
17
|
+
from .session import GlulxSession, detect_game_format, find_game_file, is_zcode_format
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class JournalEntry(TypedDict):
|
|
@@ -25,8 +25,9 @@ class JournalEntry(TypedDict):
|
|
|
25
25
|
reflection: str
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
# IF Archive base
|
|
29
|
-
|
|
28
|
+
# IF Archive base URLs
|
|
29
|
+
IF_ARCHIVE_GLULX = "https://ifarchive.org/if-archive/games/glulx"
|
|
30
|
+
IF_ARCHIVE_ZCODE = "https://ifarchive.org/if-archive/games/zcode"
|
|
30
31
|
|
|
31
32
|
# Global config - set at startup
|
|
32
33
|
_config: Config | None = None
|
|
@@ -114,6 +115,15 @@ def _list_available_games() -> list[str]:
|
|
|
114
115
|
return sorted(games)
|
|
115
116
|
|
|
116
117
|
|
|
118
|
+
def _make_session(game_dir: Path) -> GlulxSession:
|
|
119
|
+
"""Create a session with the correct interpreter for the game format."""
|
|
120
|
+
config = get_config()
|
|
121
|
+
game_file = find_game_file(game_dir)
|
|
122
|
+
if game_file and is_zcode_format(game_file.suffix.lstrip(".")):
|
|
123
|
+
return GlulxSession(game_dir, interpreter_path=config.bocfel_path)
|
|
124
|
+
return GlulxSession(game_dir, glulxe_path=config.glulxe_path)
|
|
125
|
+
|
|
126
|
+
|
|
117
127
|
@mcp.tool()
|
|
118
128
|
async def play_if(game: str, command: str = "", journal: str = "") -> str:
|
|
119
129
|
"""Play a turn of interactive fiction.
|
|
@@ -130,14 +140,9 @@ async def play_if(game: str, command: str = "", journal: str = "") -> str:
|
|
|
130
140
|
if not game:
|
|
131
141
|
return "Error: game name required"
|
|
132
142
|
|
|
133
|
-
# Validate glulxe
|
|
134
|
-
errors = config.validate()
|
|
135
|
-
if errors:
|
|
136
|
-
return "Error: " + "; ".join(errors)
|
|
137
|
-
assert config.glulxe_path is not None
|
|
138
|
-
|
|
139
143
|
game_dir = _get_game_dir(game)
|
|
140
|
-
|
|
144
|
+
game_file = find_game_file(game_dir)
|
|
145
|
+
if not game_file:
|
|
141
146
|
available = _list_available_games()
|
|
142
147
|
msg = f"Game '{game}' not found."
|
|
143
148
|
if available:
|
|
@@ -145,7 +150,18 @@ async def play_if(game: str, command: str = "", journal: str = "") -> str:
|
|
|
145
150
|
msg += "\nUse download_game to get new games."
|
|
146
151
|
return msg
|
|
147
152
|
|
|
148
|
-
|
|
153
|
+
# Validate the appropriate interpreter
|
|
154
|
+
ext = game_file.suffix.lstrip(".")
|
|
155
|
+
if is_zcode_format(ext):
|
|
156
|
+
errors = config.validate_bocfel()
|
|
157
|
+
if errors:
|
|
158
|
+
return "Error: " + "; ".join(errors)
|
|
159
|
+
else:
|
|
160
|
+
errors = config.validate()
|
|
161
|
+
if errors:
|
|
162
|
+
return "Error: " + "; ".join(errors)
|
|
163
|
+
|
|
164
|
+
session = _make_session(game_dir)
|
|
149
165
|
|
|
150
166
|
# Warn about save/restore commands
|
|
151
167
|
if command.strip().lower() in ("save", "restore"):
|
|
@@ -220,10 +236,9 @@ async def list_games() -> str:
|
|
|
220
236
|
)
|
|
221
237
|
|
|
222
238
|
lines = ["**Available games:**", ""]
|
|
223
|
-
config = get_config()
|
|
224
239
|
for game in games:
|
|
225
240
|
game_dir = _get_game_dir(game)
|
|
226
|
-
session =
|
|
241
|
+
session = _make_session(game_dir)
|
|
227
242
|
status = "has saved state" if session.has_state() else "no saved state"
|
|
228
243
|
lines.append(f"- {game} ({status})")
|
|
229
244
|
|
|
@@ -251,9 +266,10 @@ async def download_game(name: str, url: str) -> str:
|
|
|
251
266
|
if not url:
|
|
252
267
|
return "Error: url required (full URL or IF Archive filename like 'advent.ulx')"
|
|
253
268
|
|
|
254
|
-
# If URL is just a filename,
|
|
269
|
+
# If URL is just a filename, try to route to the right IF Archive path
|
|
255
270
|
if not url.startswith("http"):
|
|
256
|
-
|
|
271
|
+
ext = url.rsplit(".", 1)[-1].lower() if "." in url else ""
|
|
272
|
+
url = f"{IF_ARCHIVE_ZCODE}/{url}" if is_zcode_format(ext) else f"{IF_ARCHIVE_GLULX}/{url}"
|
|
257
273
|
|
|
258
274
|
game_dir = _get_game_dir(name)
|
|
259
275
|
|
|
@@ -268,7 +284,7 @@ async def download_game(name: str, url: str) -> str:
|
|
|
268
284
|
|
|
269
285
|
game_format = detect_game_format(content)
|
|
270
286
|
if not game_format:
|
|
271
|
-
return f"Error: Downloaded file is not a valid
|
|
287
|
+
return f"Error: Downloaded file is not a valid game (magic bytes: {content[:12]!r})"
|
|
272
288
|
|
|
273
289
|
game_file = game_dir / f"game.{game_format}"
|
|
274
290
|
game_file.write_bytes(content)
|
|
@@ -289,7 +305,6 @@ async def reset_game(game: str) -> str:
|
|
|
289
305
|
Args:
|
|
290
306
|
game: Name of the game to reset
|
|
291
307
|
"""
|
|
292
|
-
config = get_config()
|
|
293
308
|
game = game.strip()
|
|
294
309
|
|
|
295
310
|
if not game:
|
|
@@ -299,7 +314,7 @@ async def reset_game(game: str) -> str:
|
|
|
299
314
|
if not find_game_file(game_dir):
|
|
300
315
|
return f"Game '{game}' not found."
|
|
301
316
|
|
|
302
|
-
session =
|
|
317
|
+
session = _make_session(game_dir)
|
|
303
318
|
session.clear_state()
|
|
304
319
|
|
|
305
320
|
return f"Game '{game}' reset. Journal preserved. Use play_if to start fresh."
|
|
@@ -382,6 +397,11 @@ def main():
|
|
|
382
397
|
type=Path,
|
|
383
398
|
help="Path to glulxe binary (default: auto-detect or IF_GLULXE_PATH)",
|
|
384
399
|
)
|
|
400
|
+
parser.add_argument(
|
|
401
|
+
"--bocfel-path",
|
|
402
|
+
type=Path,
|
|
403
|
+
help="Path to bocfel binary (default: auto-detect or IF_BOCFEL_PATH)",
|
|
404
|
+
)
|
|
385
405
|
parser.add_argument(
|
|
386
406
|
"--require-journal",
|
|
387
407
|
action="store_true",
|
|
@@ -395,6 +415,7 @@ def main():
|
|
|
395
415
|
_config = Config(
|
|
396
416
|
games_dir=args.games_dir,
|
|
397
417
|
glulxe_path=args.glulxe_path,
|
|
418
|
+
bocfel_path=args.bocfel_path,
|
|
398
419
|
require_journal=args.require_journal,
|
|
399
420
|
)
|
|
400
421
|
|
mcp_server_if/session.py
CHANGED
|
@@ -1,47 +1,121 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Game session management with RemGlk protocol."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
# Magic bytes for game formats
|
|
10
11
|
GLULX_MAGIC = b"Glul" # Glulx game file
|
|
11
12
|
BLORB_MAGIC = b"FORM" # Blorb container (FORM....IFRS)
|
|
12
13
|
|
|
14
|
+
# Z-code versions (byte 0 of the file)
|
|
15
|
+
ZCODE_VERSIONS = range(1, 9) # 1-8
|
|
16
|
+
|
|
17
|
+
# File extensions by format family
|
|
18
|
+
GLULX_EXTENSIONS = ("ulx", "gblorb")
|
|
19
|
+
ZCODE_EXTENSIONS = ("z3", "z4", "z5", "z7", "z8", "zblorb")
|
|
20
|
+
ALL_GAME_EXTENSIONS = GLULX_EXTENSIONS + ZCODE_EXTENSIONS
|
|
21
|
+
|
|
13
22
|
|
|
14
23
|
def detect_game_format(content: bytes) -> str | None:
|
|
15
|
-
"""Detect game format from magic bytes.
|
|
24
|
+
"""Detect game format from magic bytes.
|
|
25
|
+
|
|
26
|
+
Returns 'ulx', 'gblorb', 'zblorb', 'z3', 'z5', 'z8', etc., or None.
|
|
27
|
+
"""
|
|
16
28
|
if content.startswith(GLULX_MAGIC):
|
|
17
29
|
return "ulx"
|
|
30
|
+
|
|
18
31
|
if content.startswith(BLORB_MAGIC) and len(content) > 12 and content[8:12] == b"IFRS":
|
|
19
|
-
|
|
32
|
+
# Blorb container — scan for exec chunk to disambiguate
|
|
33
|
+
return _detect_blorb_type(content)
|
|
34
|
+
|
|
35
|
+
# Z-code: byte 0 is version (1-8), check for valid serial number at bytes 18-23
|
|
36
|
+
if len(content) >= 64 and content[0] in ZCODE_VERSIONS:
|
|
37
|
+
serial = content[18:24]
|
|
38
|
+
if all(0x20 <= b <= 0x7E for b in serial):
|
|
39
|
+
return f"z{content[0]}"
|
|
40
|
+
|
|
20
41
|
return None
|
|
21
42
|
|
|
22
43
|
|
|
44
|
+
def _detect_blorb_type(content: bytes) -> str:
|
|
45
|
+
"""Scan a Blorb IFF to find exec resource type (ZCOD or GLUL)."""
|
|
46
|
+
pos = 12 # skip FORM + size + IFRS
|
|
47
|
+
while pos + 8 <= len(content):
|
|
48
|
+
chunk_type = content[pos : pos + 4]
|
|
49
|
+
chunk_size = int.from_bytes(content[pos + 4 : pos + 8], "big")
|
|
50
|
+
|
|
51
|
+
if chunk_type == b"RIdx":
|
|
52
|
+
# Resource index — scan entries for Exec resources
|
|
53
|
+
if pos + 12 <= len(content):
|
|
54
|
+
num_entries = int.from_bytes(content[pos + 8 : pos + 12], "big")
|
|
55
|
+
entry_pos = pos + 12
|
|
56
|
+
for _ in range(num_entries):
|
|
57
|
+
if entry_pos + 12 > len(content):
|
|
58
|
+
break
|
|
59
|
+
usage = content[entry_pos : entry_pos + 4]
|
|
60
|
+
if usage == b"Exec":
|
|
61
|
+
# The entry points to a chunk; find that chunk's type
|
|
62
|
+
chunk_offset = int.from_bytes(content[entry_pos + 8 : entry_pos + 12], "big")
|
|
63
|
+
if chunk_offset + 4 <= len(content):
|
|
64
|
+
exec_type = content[chunk_offset : chunk_offset + 4]
|
|
65
|
+
if exec_type == b"ZCOD":
|
|
66
|
+
return "zblorb"
|
|
67
|
+
elif exec_type == b"GLUL":
|
|
68
|
+
return "gblorb"
|
|
69
|
+
entry_pos += 12
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
# Advance to next chunk (chunks are 2-byte aligned)
|
|
73
|
+
pos += 8 + chunk_size
|
|
74
|
+
if chunk_size % 2:
|
|
75
|
+
pos += 1
|
|
76
|
+
|
|
77
|
+
# Default to gblorb if we can't determine
|
|
78
|
+
return "gblorb"
|
|
79
|
+
|
|
80
|
+
|
|
23
81
|
def find_game_file(game_dir: Path) -> Path | None:
|
|
24
|
-
"""Find the game file in a game directory
|
|
25
|
-
for ext in
|
|
82
|
+
"""Find the game file in a game directory."""
|
|
83
|
+
for ext in ALL_GAME_EXTENSIONS:
|
|
26
84
|
game_file = game_dir / f"game.{ext}"
|
|
27
85
|
if game_file.exists():
|
|
28
86
|
return game_file
|
|
29
87
|
return None
|
|
30
88
|
|
|
31
89
|
|
|
90
|
+
def is_zcode_format(ext: str) -> bool:
|
|
91
|
+
"""Check if a file extension is a Z-code format."""
|
|
92
|
+
return ext in ZCODE_EXTENSIONS
|
|
93
|
+
|
|
94
|
+
|
|
32
95
|
class GlulxSession:
|
|
33
|
-
"""Manages a
|
|
96
|
+
"""Manages a game session with RemGlk JSON protocol."""
|
|
34
97
|
|
|
35
|
-
def __init__(self, game_dir: Path, glulxe_path: Path | None = None):
|
|
98
|
+
def __init__(self, game_dir: Path, glulxe_path: Path | None = None, interpreter_path: Path | None = None):
|
|
36
99
|
self.game_dir = game_dir
|
|
37
100
|
self.glulxe_path = glulxe_path
|
|
101
|
+
self.interpreter_path = interpreter_path or glulxe_path
|
|
38
102
|
self.game_file = find_game_file(game_dir)
|
|
39
103
|
self.state_dir = game_dir / "state"
|
|
40
104
|
self.metadata_file = game_dir / "metadata.json"
|
|
41
105
|
|
|
106
|
+
@property
|
|
107
|
+
def _is_zcode(self) -> bool:
|
|
108
|
+
"""Check if current game is Z-code format."""
|
|
109
|
+
if self.game_file is None:
|
|
110
|
+
return False
|
|
111
|
+
return is_zcode_format(self.game_file.suffix.lstrip("."))
|
|
112
|
+
|
|
42
113
|
def has_state(self) -> bool:
|
|
43
114
|
"""Check if saved state exists."""
|
|
44
|
-
|
|
115
|
+
if not self.state_dir.exists():
|
|
116
|
+
return False
|
|
117
|
+
# glulxe uses autosave.json; bocfel uses {checksum}.json
|
|
118
|
+
return any(f.suffix == ".json" for f in self.state_dir.iterdir())
|
|
45
119
|
|
|
46
120
|
def load_metadata(self) -> dict:
|
|
47
121
|
"""Load session metadata."""
|
|
@@ -77,10 +151,10 @@ class GlulxSession:
|
|
|
77
151
|
if not self.game_file or not self.game_file.exists():
|
|
78
152
|
raise FileNotFoundError(f"Game file not found in: {self.game_dir}")
|
|
79
153
|
|
|
80
|
-
if not self.
|
|
154
|
+
if not self.interpreter_path or not self.interpreter_path.exists():
|
|
81
155
|
raise FileNotFoundError(
|
|
82
|
-
f"
|
|
83
|
-
"
|
|
156
|
+
f"Interpreter binary not found: {self.interpreter_path}\n"
|
|
157
|
+
"Run 'uv sync --reinstall-package mcp-server-if' to compile from source."
|
|
84
158
|
)
|
|
85
159
|
|
|
86
160
|
# Ensure state directory exists
|
|
@@ -89,20 +163,11 @@ class GlulxSession:
|
|
|
89
163
|
# Load metadata
|
|
90
164
|
metadata = self.load_metadata()
|
|
91
165
|
|
|
92
|
-
# Build
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"--autosave",
|
|
98
|
-
"--autodir",
|
|
99
|
-
str(self.state_dir),
|
|
100
|
-
]
|
|
101
|
-
|
|
102
|
-
if self.has_state():
|
|
103
|
-
cmd.append("--autorestore")
|
|
104
|
-
|
|
105
|
-
cmd.append(str(self.game_file))
|
|
166
|
+
# Build command and environment
|
|
167
|
+
if self._is_zcode:
|
|
168
|
+
cmd, env = self._build_bocfel_cmd()
|
|
169
|
+
else:
|
|
170
|
+
cmd, env = self._build_glulxe_cmd()
|
|
106
171
|
|
|
107
172
|
# Build input JSON
|
|
108
173
|
if command is None or not self.has_state():
|
|
@@ -118,9 +183,6 @@ class GlulxSession:
|
|
|
118
183
|
|
|
119
184
|
if input_type == "char":
|
|
120
185
|
# Character input - send single char or RemGlk special key name.
|
|
121
|
-
# RemGlk special keys: return, escape, tab, left, right, up, down,
|
|
122
|
-
# pageup, pagedown, home, end, func1-func12.
|
|
123
|
-
# Regular chars (including space) are sent as literal single chars.
|
|
124
186
|
if not command:
|
|
125
187
|
key = " "
|
|
126
188
|
elif command in ("\n", "\r") or command.strip().lower() in ("enter", "return"):
|
|
@@ -134,12 +196,14 @@ class GlulxSession:
|
|
|
134
196
|
# Line input
|
|
135
197
|
input_json = {"type": "line", "gen": metadata["gen"], "window": input_window, "value": command}
|
|
136
198
|
|
|
137
|
-
# Run
|
|
199
|
+
# Run interpreter
|
|
138
200
|
proc = await asyncio.create_subprocess_exec(
|
|
139
201
|
*cmd,
|
|
140
202
|
stdin=asyncio.subprocess.PIPE,
|
|
141
203
|
stdout=asyncio.subprocess.PIPE,
|
|
142
204
|
stderr=asyncio.subprocess.PIPE,
|
|
205
|
+
cwd=self.game_dir,
|
|
206
|
+
env=env,
|
|
143
207
|
)
|
|
144
208
|
|
|
145
209
|
input_bytes = (json.dumps(input_json) + "\n").encode()
|
|
@@ -148,20 +212,20 @@ class GlulxSession:
|
|
|
148
212
|
if proc.returncode != 0:
|
|
149
213
|
error = stderr.decode("utf-8", errors="replace").strip()
|
|
150
214
|
stdout_preview = stdout.decode("utf-8", errors="replace")[:500]
|
|
151
|
-
raise RuntimeError(f"
|
|
215
|
+
raise RuntimeError(f"Interpreter failed (exit {proc.returncode}): {error}\nstdout: {stdout_preview}")
|
|
152
216
|
|
|
153
217
|
# Parse output - RemGlk sends JSON terminated by blank line
|
|
154
218
|
output_text = stdout.decode("utf-8", errors="replace")
|
|
155
219
|
output_lines = output_text.strip().split("\n\n")
|
|
156
220
|
|
|
157
221
|
if not output_lines:
|
|
158
|
-
raise RuntimeError("No output from
|
|
222
|
+
raise RuntimeError("No output from interpreter")
|
|
159
223
|
|
|
160
224
|
# Parse the JSON output
|
|
161
225
|
try:
|
|
162
226
|
output = json.loads(output_lines[0])
|
|
163
227
|
except json.JSONDecodeError as e:
|
|
164
|
-
raise RuntimeError(f"Failed to parse
|
|
228
|
+
raise RuntimeError(f"Failed to parse interpreter output: {e}\nOutput: {output_text[:500]}") from e
|
|
165
229
|
|
|
166
230
|
# Update metadata from output
|
|
167
231
|
if "gen" in output:
|
|
@@ -194,6 +258,35 @@ class GlulxSession:
|
|
|
194
258
|
|
|
195
259
|
return formatted, metadata
|
|
196
260
|
|
|
261
|
+
def _build_glulxe_cmd(self) -> tuple[list[str], dict[str, str] | None]:
|
|
262
|
+
"""Build command for glulxe interpreter."""
|
|
263
|
+
cmd = [
|
|
264
|
+
str(self.interpreter_path),
|
|
265
|
+
"-singleturn",
|
|
266
|
+
"-fm",
|
|
267
|
+
"--autosave",
|
|
268
|
+
"--autodir",
|
|
269
|
+
str(self.state_dir),
|
|
270
|
+
]
|
|
271
|
+
if self.has_state():
|
|
272
|
+
cmd.append("--autorestore")
|
|
273
|
+
cmd.append(str(self.game_file))
|
|
274
|
+
return cmd, None
|
|
275
|
+
|
|
276
|
+
def _build_bocfel_cmd(self) -> tuple[list[str], dict[str, str]]:
|
|
277
|
+
"""Build command and environment for bocfel interpreter."""
|
|
278
|
+
cmd = [
|
|
279
|
+
str(self.interpreter_path),
|
|
280
|
+
"-singleturn",
|
|
281
|
+
"-fm",
|
|
282
|
+
"-H", # Disable history playback (unnecessary with AutosaveLib)
|
|
283
|
+
str(self.game_file),
|
|
284
|
+
]
|
|
285
|
+
# Bocfel reads autosave config from environment variables
|
|
286
|
+
env = os.environ.copy()
|
|
287
|
+
env["BOCFEL_AUTOSAVE_DIRECTORY"] = str(self.state_dir)
|
|
288
|
+
return cmd, env
|
|
289
|
+
|
|
197
290
|
def _format_output(self, output: dict, windows: list) -> str:
|
|
198
291
|
"""Format RemGlk output as readable text."""
|
|
199
292
|
result = []
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-server-if
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: MCP server for playing
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for playing interactive fiction games (Glulx and Z-machine)
|
|
5
5
|
Project-URL: Homepage, https://github.com/davidar/mcp-server-if
|
|
6
6
|
Project-URL: Repository, https://github.com/davidar/mcp-server-if
|
|
7
7
|
Project-URL: Issues, https://github.com/davidar/mcp-server-if/issues
|
|
8
8
|
Author-email: David A Roberts <d@vidr.cc>
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Keywords: claude,glulx,interactive-fiction,mcp,text-adventure
|
|
11
|
+
Keywords: claude,glulx,interactive-fiction,mcp,text-adventure,z-machine,zcode
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -25,16 +25,16 @@ Description-Content-Type: text/markdown
|
|
|
25
25
|
|
|
26
26
|
# mcp-server-if
|
|
27
27
|
|
|
28
|
-
An MCP (Model Context Protocol) server for playing
|
|
28
|
+
An MCP (Model Context Protocol) server for playing interactive fiction games. Enables AI assistants like Claude to play text adventure games through a standardized interface.
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
32
|
-
- Play Glulx (.ulx) and
|
|
32
|
+
- Play Glulx (.ulx, .gblorb) and Z-machine (.z3-.z8, .zblorb) games
|
|
33
33
|
- Automatic game state persistence (save/restore between sessions)
|
|
34
34
|
- Download games directly from the IF Archive
|
|
35
35
|
- Optional journaling mode for reflective playthroughs
|
|
36
36
|
- Works with Claude Desktop, Claude Code, and other MCP clients
|
|
37
|
-
- Bundled glulxe
|
|
37
|
+
- Bundled interpreters (glulxe for Glulx, bocfel for Z-machine)
|
|
38
38
|
|
|
39
39
|
## Installation
|
|
40
40
|
|
|
@@ -46,7 +46,7 @@ uvx mcp-server-if
|
|
|
46
46
|
pip install mcp-server-if
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
The package includes
|
|
49
|
+
The package includes pre-compiled interpreters. No additional setup required.
|
|
50
50
|
|
|
51
51
|
## Configuration
|
|
52
52
|
|
|
@@ -56,6 +56,7 @@ The package includes a pre-compiled `glulxe` binary. No additional setup require
|
|
|
56
56
|
|----------|-------------|---------|
|
|
57
57
|
| `IF_GAMES_DIR` | Directory to store games | `~/.mcp-server-if/games` |
|
|
58
58
|
| `IF_GLULXE_PATH` | Override path to glulxe binary | Bundled binary |
|
|
59
|
+
| `IF_BOCFEL_PATH` | Override path to bocfel binary | Bundled binary |
|
|
59
60
|
| `IF_REQUIRE_JOURNAL` | Require journal reflections | `false` |
|
|
60
61
|
|
|
61
62
|
### Command Line Arguments
|
|
@@ -130,10 +131,15 @@ search_journal(game="zork", query="treasure")
|
|
|
130
131
|
|
|
131
132
|
## Supported Game Formats
|
|
132
133
|
|
|
134
|
+
**Glulx** (modern, uses glulxe interpreter):
|
|
133
135
|
- `.ulx` - Raw Glulx game files
|
|
134
|
-
- `.gblorb` - Blorb containers with Glulx games
|
|
136
|
+
- `.gblorb` - Blorb containers with Glulx games
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
**Z-machine** (classic Infocom format, uses bocfel interpreter):
|
|
139
|
+
- `.z3`, `.z4`, `.z5`, `.z7`, `.z8` - Z-code game files
|
|
140
|
+
- `.zblorb` - Blorb containers with Z-machine games
|
|
141
|
+
|
|
142
|
+
Find games at the IF Archive: [Glulx games](https://ifarchive.org/indexes/if-archive/games/glulx/), [Z-code games](https://ifarchive.org/indexes/if-archive/games/zcode/).
|
|
137
143
|
|
|
138
144
|
## Journaling Mode
|
|
139
145
|
|
|
@@ -150,34 +156,36 @@ This encourages thoughtful, reflective gameplay rather than rushing through.
|
|
|
150
156
|
|
|
151
157
|
1. Games are stored in `~/.mcp-server-if/games/{name}/`
|
|
152
158
|
2. Each game directory contains:
|
|
153
|
-
-
|
|
159
|
+
- The game file (`.ulx`, `.gblorb`, `.z5`, etc.)
|
|
154
160
|
- `state/` - autosave data (persists between sessions)
|
|
155
161
|
- `metadata.json` - session metadata
|
|
156
162
|
- `journal.jsonl` - playthrough journal (if enabled)
|
|
157
163
|
|
|
158
|
-
3. The server
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
3. The server selects the appropriate interpreter based on file format:
|
|
165
|
+
- Glulx games → glulxe
|
|
166
|
+
- Z-machine games → bocfel
|
|
167
|
+
4. Both interpreters use RemGlk for JSON-based I/O
|
|
168
|
+
5. Game state is automatically saved after each turn
|
|
162
169
|
|
|
163
|
-
|
|
170
|
+
## Development
|
|
164
171
|
|
|
165
|
-
|
|
166
|
-
- make
|
|
167
|
-
- git (for submodules)
|
|
172
|
+
Requires [uv](https://docs.astral.sh/uv/), a C compiler (gcc or clang), a C++ compiler (g++ or clang++), make, and git.
|
|
168
173
|
|
|
169
174
|
```bash
|
|
170
175
|
git clone --recursive https://github.com/davidar/mcp-server-if.git
|
|
171
176
|
cd mcp-server-if
|
|
172
|
-
|
|
177
|
+
uv sync --group dev
|
|
178
|
+
uv run pytest -v
|
|
173
179
|
```
|
|
174
180
|
|
|
181
|
+
`uv sync` compiles the bundled interpreters (glulxe and bocfel) from source automatically. If binaries are missing after a fresh clone, run `uv sync --reinstall-package mcp-server-if` to force recompilation.
|
|
182
|
+
|
|
175
183
|
## Troubleshooting
|
|
176
184
|
|
|
177
185
|
### "glulxe binary not found"
|
|
178
186
|
|
|
179
187
|
This shouldn't happen with pip/uvx installs. If it does:
|
|
180
|
-
- Try reinstalling: `pip install --force-reinstall mcp-server-if`
|
|
188
|
+
- Try reinstalling: `pip install --force-reinstall mcp-server-if` or `uv sync --reinstall-package mcp-server-if`
|
|
181
189
|
- Or set `IF_GLULXE_PATH` to a manually installed glulxe
|
|
182
190
|
|
|
183
191
|
### "Game file not found"
|
|
@@ -195,5 +203,6 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|
|
195
203
|
## Credits
|
|
196
204
|
|
|
197
205
|
- [glulxe](https://github.com/erkyrath/glulxe) - The Glulx VM interpreter by Andrew Plotkin
|
|
206
|
+
- [bocfel](https://github.com/garglk/garglk/tree/master/terps/bocfel) - Z-machine interpreter by Chris Spiegel
|
|
198
207
|
- [RemGlk](https://github.com/erkyrath/remglk) - Remote Glk library for JSON I/O
|
|
199
208
|
- [MCP](https://modelcontextprotocol.io/) - Model Context Protocol by Anthropic
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
mcp_server_if/server.py,sha256=JmdIeiAVdGLDA1KfXIDo5hhwplS7A7Znw6KkAwH8L2k,13006
|
|
2
|
+
mcp_server_if/config.py,sha256=1eu-4zTnXaSv3kAhwoM-xWiirDnuApiY4qGcIfR4bhM,4307
|
|
3
|
+
mcp_server_if/session.py,sha256=1NhQZu0jIxXSTsl7Wv10-r2f67k0UxAljnCdgqHwZbI,14229
|
|
4
|
+
mcp_server_if/__init__.py,sha256=9CHcB-6L566M9Gy_R5tzYWp8gXK1GJ5DhINUiJKK28Y,289
|
|
5
|
+
mcp_server_if/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
mcp_server_if/__main__.py,sha256=HEiagMZ-9v0Ugdszyh4nC7R-esMTX8Bk7vu3ox3McLI,132
|
|
7
|
+
mcp_server_if/bin/bocfel,sha256=E9Rjj8TrxxC5Ce_CDqHjF0iNnnaMtzxHUqhxJ63zrbQ,954008
|
|
8
|
+
mcp_server_if/bin/glulxe,sha256=uldM0whGQ8-Yb0_rzoRW4QT66Sit5jbn3xcYkQABlHc,511784
|
|
9
|
+
mcp_server_if-0.2.0.dist-info/RECORD,,
|
|
10
|
+
mcp_server_if-0.2.0.dist-info/WHEEL,sha256=hjZsBS66YMok_dT7wQSTJC0OsKhPojW1tCbjA-hXXlg,102
|
|
11
|
+
mcp_server_if-0.2.0.dist-info/entry_points.txt,sha256=Fh-zkWrbgDBe_2UzNjRKYR6vw_kc-q5GnIojwdFS3vk,53
|
|
12
|
+
mcp_server_if-0.2.0.dist-info/METADATA,sha256=IvnQjR5aDcHRx7vclAtIoBWDnSzYA4OoU-aYZ3nc5YE,6697
|
|
13
|
+
mcp_server_if-0.2.0.dist-info/licenses/LICENSE,sha256=BXxcwr8hEiK-byLXh_amNTuuES4DlNmCmLmQMlVptNY,1072
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
mcp_server_if/server.py,sha256=lYuWCCmseoRpkiP4zCnd9lk-wZy6AhadfgmMq9Ly2-o,12100
|
|
2
|
-
mcp_server_if/config.py,sha256=MXDDzHPv6vhTEAj9x_Zqn4Ys4rfl_LDNgVfRD0wc1Y4,3239
|
|
3
|
-
mcp_server_if/session.py,sha256=VXamjN85XezxfCuhmfoBO82t78q9YPD_JEWw7vSIHcE,10663
|
|
4
|
-
mcp_server_if/__init__.py,sha256=9CHcB-6L566M9Gy_R5tzYWp8gXK1GJ5DhINUiJKK28Y,289
|
|
5
|
-
mcp_server_if/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
mcp_server_if/__main__.py,sha256=HEiagMZ-9v0Ugdszyh4nC7R-esMTX8Bk7vu3ox3McLI,132
|
|
7
|
-
mcp_server_if/bin/glulxe,sha256=ePqNih0EGXXCu1ah9IQRAq-QRw2Ab4_7tMy1hKb4Tk8,511784
|
|
8
|
-
mcp_server_if-0.1.0.dist-info/RECORD,,
|
|
9
|
-
mcp_server_if-0.1.0.dist-info/WHEEL,sha256=hjZsBS66YMok_dT7wQSTJC0OsKhPojW1tCbjA-hXXlg,102
|
|
10
|
-
mcp_server_if-0.1.0.dist-info/entry_points.txt,sha256=Fh-zkWrbgDBe_2UzNjRKYR6vw_kc-q5GnIojwdFS3vk,53
|
|
11
|
-
mcp_server_if-0.1.0.dist-info/METADATA,sha256=S9cnguCgOVHR1GPMDJJWW5IK6zq70cCT-c2R1IpKDRw,5861
|
|
12
|
-
mcp_server_if-0.1.0.dist-info/licenses/LICENSE,sha256=BXxcwr8hEiK-byLXh_amNTuuES4DlNmCmLmQMlVptNY,1072
|
|
File without changes
|
|
File without changes
|
|
File without changes
|