mcp-server-if 0.1.0__py3-none-macosx_13_0_x86_64.whl → 0.2.0__py3-none-macosx_13_0_x86_64.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.
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 get_bundled_glulxe() -> Path | None:
17
- """Get the bundled glulxe binary path if it exists."""
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 name in ("glulxe", "glulxe.exe"):
20
- bundled = package_dir / "bin" / name
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 get_glulxe_path() -> Path | None:
27
- """Get the glulxe binary path from environment, bundled, or auto-detect."""
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("IF_GLULXE_PATH")
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 = get_bundled_glulxe()
37
+ bundled = _get_bundled_binary(name)
38
38
  if bundled:
39
39
  return bundled
40
40
 
41
- # 3. Try to find glulxe in PATH
42
- glulxe_in_path = shutil.which("glulxe")
43
- if glulxe_in_path:
44
- return Path(glulxe_in_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" / "glulxe",
49
- Path("/usr/local/bin/glulxe"),
50
- Path("/usr/bin/glulxe"),
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 self.glulxe_path:
119
+ if not path:
90
120
  checked = [
91
- "IF_GLULXE_PATH env var",
121
+ f"IF_{name.upper()}_PATH env var",
92
122
  f"bundled binary at {Path(__file__).parent / 'bin'}",
93
- "glulxe in PATH",
123
+ f"{name} in PATH",
94
124
  ]
95
125
  errors.append(
96
- "glulxe binary not found. Checked:\n"
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 self.glulxe_path.exists():
102
- errors.append(f"glulxe binary not found at: {self.glulxe_path}")
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 URL
29
- IF_ARCHIVE_BASE = "https://ifarchive.org/if-archive/games/glulx"
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
- if not find_game_file(game_dir):
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
- session = GlulxSession(game_dir, config.glulxe_path)
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 = GlulxSession(game_dir, config.glulxe_path)
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, construct IF Archive URL
269
+ # If URL is just a filename, try to route to the right IF Archive path
255
270
  if not url.startswith("http"):
256
- url = f"{IF_ARCHIVE_BASE}/{url}"
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 Glulx or Blorb game (magic bytes: {content[:12]!r})"
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 = GlulxSession(game_dir, config.glulxe_path)
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
- """Glulx game session management with RemGlk protocol."""
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. Returns 'ulx', 'gblorb', or None."""
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
- return "gblorb"
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 (.ulx or .gblorb)."""
25
- for ext in ("ulx", "gblorb"):
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 glulxe session with RemGlk JSON protocol."""
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
- return (self.state_dir / "autosave.json").exists()
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.glulxe_path or not self.glulxe_path.exists():
154
+ if not self.interpreter_path or not self.interpreter_path.exists():
81
155
  raise FileNotFoundError(
82
- f"glulxe binary not found: {self.glulxe_path}\n"
83
- "Set IF_GLULXE_PATH or see README.md for build instructions."
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 glulxe command
93
- cmd = [
94
- str(self.glulxe_path),
95
- "-singleturn",
96
- "-fm",
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 glulxe
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"glulxe failed (exit {proc.returncode}): {error}\nstdout: {stdout_preview}")
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 glulxe")
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 glulxe output: {e}\nOutput: {output_text[:500]}") from e
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.1.0
4
- Summary: MCP server for playing Glulx interactive fiction games
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 Glulx interactive fiction games. Enables AI assistants like Claude to play text adventure games through a standardized interface.
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 Blorb (.gblorb) interactive fiction games
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 interpreter (no manual compilation required)
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 a pre-compiled `glulxe` binary. No additional setup required.
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 (may include graphics/sound)
136
+ - `.gblorb` - Blorb containers with Glulx games
135
137
 
136
- Find games at the [IF Archive](https://ifarchive.org/indexes/if-archive/games/glulx/).
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
- - `game.ulx` or `game.gblorb` - the game file
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 uses glulxe's RemGlk mode for JSON-based I/O
159
- 4. Game state is automatically saved after each turn
160
-
161
- ## Building from Source
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
- If installing from source (not from PyPI), glulxe will be compiled during installation. This requires:
170
+ ## Development
164
171
 
165
- - C compiler (gcc or clang)
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
- pip install .
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=TidTxNcN1F3Oaw_mUbtVnySsxclyLMxTLq-W2rRD0X4,920344
8
+ mcp_server_if/bin/glulxe,sha256=BPVSBmxP7W2hq5S0S5EbUArj-jLvEIbkgYXJKu9Ojro,458616
9
+ mcp_server_if-0.2.0.dist-info/RECORD,,
10
+ mcp_server_if-0.2.0.dist-info/WHEEL,sha256=jM55AgJpFgm9Sn4xbxsixNfsjXQts3022sgwq_90spc,103
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=AFRa_1EAdbqTMoOaaBbpn1oxfkxpoVKJ9GC6NfAhiDs,458616
8
- mcp_server_if-0.1.0.dist-info/RECORD,,
9
- mcp_server_if-0.1.0.dist-info/WHEEL,sha256=jM55AgJpFgm9Sn4xbxsixNfsjXQts3022sgwq_90spc,103
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