mcp-server-if 0.1.0__py3-none-manylinux_2_27_aarch64.manylinux_2_28_aarch64.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.
@@ -0,0 +1,12 @@
1
+ """MCP server for playing Glulx interactive fiction games."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .server import main
6
+
7
+ try:
8
+ __version__ = version("mcp-server-if")
9
+ except PackageNotFoundError:
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = ["__version__", "main"]
@@ -0,0 +1,6 @@
1
+ """Entry point for running as a module: python -m mcp_server_if"""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
Binary file
@@ -0,0 +1,103 @@
1
+ """Configuration handling for mcp-server-if."""
2
+
3
+ import os
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+
8
+ def get_games_dir() -> Path:
9
+ """Get the games directory from environment or default."""
10
+ env_dir = os.environ.get("IF_GAMES_DIR")
11
+ if env_dir:
12
+ return Path(env_dir)
13
+ return Path.home() / ".mcp-server-if" / "games"
14
+
15
+
16
+ def get_bundled_glulxe() -> Path | None:
17
+ """Get the bundled glulxe binary path if it exists."""
18
+ package_dir = Path(__file__).parent
19
+ for name in ("glulxe", "glulxe.exe"):
20
+ bundled = package_dir / "bin" / name
21
+ if bundled.exists() and bundled.is_file():
22
+ return bundled
23
+ return None
24
+
25
+
26
+ def get_glulxe_path() -> Path | None:
27
+ """Get the glulxe binary path from environment, bundled, or auto-detect."""
28
+ # 1. Check environment variable
29
+ env_path = os.environ.get("IF_GLULXE_PATH")
30
+ if env_path:
31
+ path = Path(env_path)
32
+ if path.exists() and path.is_file():
33
+ return path
34
+ return None
35
+
36
+ # 2. Check for bundled binary (installed with package)
37
+ bundled = get_bundled_glulxe()
38
+ if bundled:
39
+ return bundled
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)
45
+
46
+ # 4. Check common locations
47
+ common_paths = [
48
+ Path.home() / ".local" / "bin" / "glulxe",
49
+ Path("/usr/local/bin/glulxe"),
50
+ Path("/usr/bin/glulxe"),
51
+ ]
52
+
53
+ for path in common_paths:
54
+ if path.exists() and path.is_file():
55
+ return path
56
+
57
+ return None
58
+
59
+
60
+ def _get_require_journal() -> bool:
61
+ """Check if journal mode is enabled."""
62
+ return os.environ.get("IF_REQUIRE_JOURNAL", "").lower() in ("1", "true", "yes")
63
+
64
+
65
+ class Config:
66
+ """Server configuration."""
67
+
68
+ def __init__(
69
+ self,
70
+ games_dir: Path | None = None,
71
+ glulxe_path: Path | None = None,
72
+ require_journal: bool | None = None,
73
+ ):
74
+ self.games_dir = games_dir or get_games_dir()
75
+ self.glulxe_path: Path | None = glulxe_path or get_glulxe_path()
76
+ self._require_journal = require_journal if require_journal is not None else _get_require_journal()
77
+
78
+ @property
79
+ def require_journal(self) -> bool:
80
+ return self._require_journal
81
+
82
+ def ensure_games_dir(self) -> None:
83
+ """Ensure the games directory exists."""
84
+ self.games_dir.mkdir(parents=True, exist_ok=True)
85
+
86
+ def validate(self) -> list[str]:
87
+ """Validate configuration. Returns list of errors."""
88
+ errors = []
89
+ if not self.glulxe_path:
90
+ checked = [
91
+ "IF_GLULXE_PATH env var",
92
+ f"bundled binary at {Path(__file__).parent / 'bin'}",
93
+ "glulxe in PATH",
94
+ ]
95
+ errors.append(
96
+ "glulxe binary not found. Checked:\n"
97
+ + "\n".join(f" - {loc}" for loc in checked)
98
+ + "\n\nFor development: run 'uv sync --reinstall-package mcp-server-if' to compile from source."
99
+ + "\nFor production: install the wheel from PyPI."
100
+ )
101
+ elif not self.glulxe_path.exists():
102
+ errors.append(f"glulxe binary not found at: {self.glulxe_path}")
103
+ return errors
mcp_server_if/py.typed ADDED
File without changes
@@ -0,0 +1,412 @@
1
+ """MCP server for interactive fiction games."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import TypedDict
12
+
13
+ import httpx
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ from .config import Config
17
+ from .session import GlulxSession, detect_game_format, find_game_file
18
+
19
+
20
+ class JournalEntry(TypedDict):
21
+ turn: int
22
+ timestamp: str
23
+ command: str
24
+ output: str
25
+ reflection: str
26
+
27
+
28
+ # IF Archive base URL
29
+ IF_ARCHIVE_BASE = "https://ifarchive.org/if-archive/games/glulx"
30
+
31
+ # Global config - set at startup
32
+ _config: Config | None = None
33
+
34
+
35
+ def get_config() -> Config:
36
+ """Get the server configuration."""
37
+ global _config
38
+ if _config is None:
39
+ _config = Config()
40
+ return _config
41
+
42
+
43
+ # Initialize FastMCP server
44
+ mcp = FastMCP("interactive-fiction")
45
+
46
+
47
+ def _get_game_dir(game: str) -> Path:
48
+ """Get the directory for a game by name."""
49
+ safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", game.lower())
50
+ return get_config().games_dir / safe_name
51
+
52
+
53
+ def _append_journal(game_dir: Path, turn: int, command: str, output: str, reflection: str) -> None:
54
+ """Append a complete journal entry with command, output, and reflection."""
55
+ journal_file = game_dir / "journal.jsonl"
56
+
57
+ entry: JournalEntry = {
58
+ "turn": turn,
59
+ "timestamp": datetime.now().isoformat(),
60
+ "command": command,
61
+ "output": output,
62
+ "reflection": reflection.strip(),
63
+ }
64
+
65
+ with open(journal_file, "a") as f:
66
+ f.write(json.dumps(entry) + "\n")
67
+
68
+
69
+ def _load_journal(game_dir: Path) -> list[JournalEntry]:
70
+ """Load journal entries from JSONL file."""
71
+ journal_file = game_dir / "journal.jsonl"
72
+ if not journal_file.exists():
73
+ return []
74
+
75
+ entries = []
76
+ for line in journal_file.read_text().strip().split("\n"):
77
+ if line:
78
+ try:
79
+ entries.append(json.loads(line))
80
+ except json.JSONDecodeError:
81
+ continue
82
+ return entries
83
+
84
+
85
+ def _format_journal_entry(entry: JournalEntry, include_output: bool = True) -> list[str]:
86
+ """Format a single journal entry as lines of text."""
87
+ lines = []
88
+ timestamp = entry["timestamp"][:16].replace("T", " ")
89
+ lines.append(f"## Turn {entry['turn']} ({timestamp})")
90
+ lines.append("")
91
+ lines.append(f"**Command:** `{entry['command']}`")
92
+ lines.append("")
93
+ if include_output:
94
+ lines.append("**Game output:**")
95
+ for output_line in entry["output"].split("\n"):
96
+ lines.append(f"> {output_line}")
97
+ lines.append("")
98
+ lines.append(f"**Reflection:** {entry['reflection']}")
99
+ lines.append("")
100
+ lines.append("---")
101
+ lines.append("")
102
+ return lines
103
+
104
+
105
+ def _list_available_games() -> list[str]:
106
+ """List available game names."""
107
+ games_dir = get_config().games_dir
108
+ if not games_dir.exists():
109
+ return []
110
+ games = []
111
+ for game_dir in games_dir.iterdir():
112
+ if game_dir.is_dir() and find_game_file(game_dir):
113
+ games.append(game_dir.name)
114
+ return sorted(games)
115
+
116
+
117
+ @mcp.tool()
118
+ async def play_if(game: str, command: str = "", journal: str = "") -> str:
119
+ """Play a turn of interactive fiction.
120
+
121
+ Args:
122
+ game: Name of the game to play
123
+ command: Command to send to the game (empty to start/show current state)
124
+ journal: Reflection on the previous turn (required after first turn if journaling enabled)
125
+ """
126
+ config = get_config()
127
+ game = game.strip()
128
+ journal = journal.strip()
129
+
130
+ if not game:
131
+ return "Error: game name required"
132
+
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
+ game_dir = _get_game_dir(game)
140
+ if not find_game_file(game_dir):
141
+ available = _list_available_games()
142
+ msg = f"Game '{game}' not found."
143
+ if available:
144
+ msg += f" Available: {', '.join(available)}"
145
+ msg += "\nUse download_game to get new games."
146
+ return msg
147
+
148
+ session = GlulxSession(game_dir, config.glulxe_path)
149
+
150
+ # Warn about save/restore commands
151
+ if command.strip().lower() in ("save", "restore"):
152
+ return (
153
+ f"Warning: The '{command.strip()}' command triggers an in-game file dialog that isn't supported.\n\n"
154
+ "Your game state is automatically saved after every turn (autosave).\n"
155
+ "To start fresh, use reset_game."
156
+ )
157
+
158
+ # Handle journaling if enabled
159
+ if config.require_journal and session.has_state() and command.strip():
160
+ metadata = session.load_metadata()
161
+ prev_command = metadata.get("last_command")
162
+
163
+ if prev_command is not None:
164
+ if not journal:
165
+ return (
166
+ "Journal entry required. Reflect on the previous turn before continuing.\n\n"
167
+ 'Use: play_if(game, command, journal="Your reflection on what happened...")'
168
+ )
169
+ word_count = len(journal.split())
170
+ if word_count < 100:
171
+ return (
172
+ f"Journal entry too short ({word_count} words). Minimum 100 words required.\n\n"
173
+ "Take your time. Reflect on what happened, what it means, how it connects to the story."
174
+ )
175
+ # Record the journal entry
176
+ turn = metadata.get("turn", 1)
177
+ prev_output = metadata.get("last_output", "")
178
+ _append_journal(game_dir, turn, prev_command, prev_output, journal)
179
+
180
+ try:
181
+ cmd = command if command.strip() else None
182
+ if session.has_state() and not command.strip():
183
+ cmd = ""
184
+
185
+ output, metadata = await session.run_turn(cmd)
186
+
187
+ # Track turn and store for next journal entry
188
+ turn = metadata.get("turn", 0) + 1
189
+ metadata["turn"] = turn
190
+ metadata["last_command"] = command
191
+ metadata["last_output"] = output
192
+ session.save_metadata(metadata)
193
+
194
+ # Add status info
195
+ status = []
196
+ if metadata.get("input_type") == "char":
197
+ status.append("Input: single keypress")
198
+ elif metadata.get("input_window") is None:
199
+ status.append("Game ended or waiting for special input")
200
+
201
+ if status:
202
+ output += "\n\n[" + ", ".join(status) + "]"
203
+
204
+ return output
205
+
206
+ except Exception as e:
207
+ return f"Error: {e}"
208
+
209
+
210
+ @mcp.tool()
211
+ async def list_games() -> str:
212
+ """List available interactive fiction games."""
213
+ games = _list_available_games()
214
+
215
+ if not games:
216
+ return (
217
+ "No games installed.\n\n"
218
+ "Use download_game to get games from the IF Archive.\n"
219
+ 'Example: download_game(name="advent", url="advent.ulx")'
220
+ )
221
+
222
+ lines = ["**Available games:**", ""]
223
+ config = get_config()
224
+ for game in games:
225
+ game_dir = _get_game_dir(game)
226
+ session = GlulxSession(game_dir, config.glulxe_path)
227
+ status = "has saved state" if session.has_state() else "no saved state"
228
+ lines.append(f"- {game} ({status})")
229
+
230
+ lines.append("")
231
+ lines.append("Use play_if(game, command) to play.")
232
+
233
+ return "\n".join(lines)
234
+
235
+
236
+ @mcp.tool()
237
+ async def download_game(name: str, url: str) -> str:
238
+ """Download an interactive fiction game (.ulx or .gblorb).
239
+
240
+ Args:
241
+ name: Local name for the game
242
+ url: Full URL or just filename for IF Archive games (e.g., 'advent.ulx')
243
+ """
244
+ config = get_config()
245
+ name = name.strip()
246
+ url = url.strip()
247
+
248
+ if not name:
249
+ return "Error: name required (used as local game name)"
250
+
251
+ if not url:
252
+ return "Error: url required (full URL or IF Archive filename like 'advent.ulx')"
253
+
254
+ # If URL is just a filename, construct IF Archive URL
255
+ if not url.startswith("http"):
256
+ url = f"{IF_ARCHIVE_BASE}/{url}"
257
+
258
+ game_dir = _get_game_dir(name)
259
+
260
+ try:
261
+ config.ensure_games_dir()
262
+ game_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client:
265
+ response = await client.get(url)
266
+ response.raise_for_status()
267
+ content = response.content
268
+
269
+ game_format = detect_game_format(content)
270
+ if not game_format:
271
+ return f"Error: Downloaded file is not a valid Glulx or Blorb game (magic bytes: {content[:12]!r})"
272
+
273
+ game_file = game_dir / f"game.{game_format}"
274
+ game_file.write_bytes(content)
275
+
276
+ size_kb = len(content) / 1024
277
+ return f'Downloaded \'{name}\' ({size_kb:.1f} KB)\nUse play_if("{name}", "") to start playing.'
278
+
279
+ except httpx.HTTPStatusError as e:
280
+ return f"Download failed: HTTP {e.response.status_code}"
281
+ except Exception as e:
282
+ return f"Download failed: {e}"
283
+
284
+
285
+ @mcp.tool()
286
+ async def reset_game(game: str) -> str:
287
+ """Reset an interactive fiction game to start fresh.
288
+
289
+ Args:
290
+ game: Name of the game to reset
291
+ """
292
+ config = get_config()
293
+ game = game.strip()
294
+
295
+ if not game:
296
+ return "Error: game name required"
297
+
298
+ game_dir = _get_game_dir(game)
299
+ if not find_game_file(game_dir):
300
+ return f"Game '{game}' not found."
301
+
302
+ session = GlulxSession(game_dir, config.glulxe_path)
303
+ session.clear_state()
304
+
305
+ return f"Game '{game}' reset. Journal preserved. Use play_if to start fresh."
306
+
307
+
308
+ @mcp.tool()
309
+ async def read_journal(game: str, recent: int = 0) -> str:
310
+ """Read the playthrough journal for an interactive fiction game.
311
+
312
+ Args:
313
+ game: Name of the game
314
+ recent: Only show last N entries (0 = all)
315
+ """
316
+ game = game.strip()
317
+
318
+ if not game:
319
+ return "Error: game name required"
320
+
321
+ game_dir = _get_game_dir(game)
322
+ entries = _load_journal(game_dir)
323
+
324
+ if not entries:
325
+ return f"No journal yet for '{game}'."
326
+
327
+ if recent > 0:
328
+ entries = entries[-recent:]
329
+
330
+ lines = [f"# {game} Playthrough Journal", ""]
331
+ for entry in entries:
332
+ lines.extend(_format_journal_entry(entry, include_output=True))
333
+
334
+ return "\n".join(lines)
335
+
336
+
337
+ @mcp.tool()
338
+ async def search_journal(game: str, query: str) -> str:
339
+ """Search the playthrough journal for keywords or patterns.
340
+
341
+ Args:
342
+ game: Name of the game
343
+ query: Search query
344
+ """
345
+ game = game.strip()
346
+ query = query.strip().lower()
347
+
348
+ if not game:
349
+ return "Error: game name required"
350
+
351
+ if not query:
352
+ return "Error: search query required"
353
+
354
+ game_dir = _get_game_dir(game)
355
+ entries = _load_journal(game_dir)
356
+
357
+ if not entries:
358
+ return f"No journal yet for '{game}'."
359
+
360
+ matches = [e for e in entries if query in e.get("reflection", "").lower() or query in e.get("output", "").lower()]
361
+
362
+ if not matches:
363
+ return f"No matches for '{query}' in {game} journal."
364
+
365
+ lines = [f"# Found {len(matches)} match(es) for '{query}'", ""]
366
+ for entry in matches:
367
+ lines.extend(_format_journal_entry(entry, include_output=False))
368
+
369
+ return "\n".join(lines)
370
+
371
+
372
+ def main():
373
+ """Main entry point for the MCP server."""
374
+ parser = argparse.ArgumentParser(description="MCP server for interactive fiction games")
375
+ parser.add_argument(
376
+ "--games-dir",
377
+ type=Path,
378
+ help="Directory to store games (default: ~/.mcp-server-if/games or IF_GAMES_DIR)",
379
+ )
380
+ parser.add_argument(
381
+ "--glulxe-path",
382
+ type=Path,
383
+ help="Path to glulxe binary (default: auto-detect or IF_GLULXE_PATH)",
384
+ )
385
+ parser.add_argument(
386
+ "--require-journal",
387
+ action="store_true",
388
+ help="Require journal reflections between turns",
389
+ )
390
+
391
+ args = parser.parse_args()
392
+
393
+ # Set global config
394
+ global _config
395
+ _config = Config(
396
+ games_dir=args.games_dir,
397
+ glulxe_path=args.glulxe_path,
398
+ require_journal=args.require_journal,
399
+ )
400
+
401
+ # Validate config
402
+ errors = _config.validate()
403
+ if errors:
404
+ for error in errors:
405
+ print(f"Warning: {error}", file=sys.stderr)
406
+
407
+ # Run the server
408
+ mcp.run(transport="stdio")
409
+
410
+
411
+ if __name__ == "__main__":
412
+ main()
@@ -0,0 +1,299 @@
1
+ """Glulx game session management with RemGlk protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+
9
+ # Magic bytes for game formats
10
+ GLULX_MAGIC = b"Glul" # Glulx game file
11
+ BLORB_MAGIC = b"FORM" # Blorb container (FORM....IFRS)
12
+
13
+
14
+ def detect_game_format(content: bytes) -> str | None:
15
+ """Detect game format from magic bytes. Returns 'ulx', 'gblorb', or None."""
16
+ if content.startswith(GLULX_MAGIC):
17
+ return "ulx"
18
+ if content.startswith(BLORB_MAGIC) and len(content) > 12 and content[8:12] == b"IFRS":
19
+ return "gblorb"
20
+ return None
21
+
22
+
23
+ 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"):
26
+ game_file = game_dir / f"game.{ext}"
27
+ if game_file.exists():
28
+ return game_file
29
+ return None
30
+
31
+
32
+ class GlulxSession:
33
+ """Manages a glulxe session with RemGlk JSON protocol."""
34
+
35
+ def __init__(self, game_dir: Path, glulxe_path: Path | None = None):
36
+ self.game_dir = game_dir
37
+ self.glulxe_path = glulxe_path
38
+ self.game_file = find_game_file(game_dir)
39
+ self.state_dir = game_dir / "state"
40
+ self.metadata_file = game_dir / "metadata.json"
41
+
42
+ def has_state(self) -> bool:
43
+ """Check if saved state exists."""
44
+ return (self.state_dir / "autosave.json").exists()
45
+
46
+ def load_metadata(self) -> dict:
47
+ """Load session metadata."""
48
+ if self.metadata_file.exists():
49
+ try:
50
+ return json.loads(self.metadata_file.read_text())
51
+ except (OSError, json.JSONDecodeError):
52
+ pass
53
+ return {"gen": 0, "windows": [], "input_window": None, "input_type": "line"}
54
+
55
+ def save_metadata(self, metadata: dict) -> None:
56
+ """Save session metadata."""
57
+ self.metadata_file.write_text(json.dumps(metadata, indent=2))
58
+
59
+ def clear_state(self) -> None:
60
+ """Clear saved game state."""
61
+ if self.state_dir.exists():
62
+ for f in self.state_dir.iterdir():
63
+ f.unlink()
64
+ if self.metadata_file.exists():
65
+ self.metadata_file.unlink()
66
+
67
+ async def run_turn(self, command: str | None = None) -> tuple[str, dict]:
68
+ """
69
+ Run a single turn of the game.
70
+
71
+ Args:
72
+ command: The command to send (None for initial turn)
73
+
74
+ Returns:
75
+ Tuple of (formatted output, updated metadata)
76
+ """
77
+ if not self.game_file or not self.game_file.exists():
78
+ raise FileNotFoundError(f"Game file not found in: {self.game_dir}")
79
+
80
+ if not self.glulxe_path or not self.glulxe_path.exists():
81
+ raise FileNotFoundError(
82
+ f"glulxe binary not found: {self.glulxe_path}\n"
83
+ "Set IF_GLULXE_PATH or see README.md for build instructions."
84
+ )
85
+
86
+ # Ensure state directory exists
87
+ self.state_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ # Load metadata
90
+ metadata = self.load_metadata()
91
+
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))
106
+
107
+ # Build input JSON
108
+ if command is None or not self.has_state():
109
+ # Initial turn
110
+ input_json = {"type": "init", "gen": 0, "metrics": {"width": 80, "height": 24}, "support": []}
111
+ else:
112
+ # Subsequent turn
113
+ input_type = metadata.get("input_type", "line")
114
+ input_window = metadata.get("input_window")
115
+
116
+ if input_window is None:
117
+ raise ValueError("No input window available - game may have ended")
118
+
119
+ if input_type == "char":
120
+ # 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
+ if not command:
125
+ key = " "
126
+ elif command in ("\n", "\r") or command.strip().lower() in ("enter", "return"):
127
+ key = "return"
128
+ elif len(command) == 1:
129
+ key = command.lower()
130
+ else:
131
+ key = command.strip().lower()
132
+ input_json = {"type": "char", "gen": metadata["gen"], "window": input_window, "value": key}
133
+ else:
134
+ # Line input
135
+ input_json = {"type": "line", "gen": metadata["gen"], "window": input_window, "value": command}
136
+
137
+ # Run glulxe
138
+ proc = await asyncio.create_subprocess_exec(
139
+ *cmd,
140
+ stdin=asyncio.subprocess.PIPE,
141
+ stdout=asyncio.subprocess.PIPE,
142
+ stderr=asyncio.subprocess.PIPE,
143
+ )
144
+
145
+ input_bytes = (json.dumps(input_json) + "\n").encode()
146
+ stdout, stderr = await proc.communicate(input_bytes)
147
+
148
+ if proc.returncode != 0:
149
+ error = stderr.decode("utf-8", errors="replace").strip()
150
+ stdout_preview = stdout.decode("utf-8", errors="replace")[:500]
151
+ raise RuntimeError(f"glulxe failed (exit {proc.returncode}): {error}\nstdout: {stdout_preview}")
152
+
153
+ # Parse output - RemGlk sends JSON terminated by blank line
154
+ output_text = stdout.decode("utf-8", errors="replace")
155
+ output_lines = output_text.strip().split("\n\n")
156
+
157
+ if not output_lines:
158
+ raise RuntimeError("No output from glulxe")
159
+
160
+ # Parse the JSON output
161
+ try:
162
+ output = json.loads(output_lines[0])
163
+ except json.JSONDecodeError as e:
164
+ raise RuntimeError(f"Failed to parse glulxe output: {e}\nOutput: {output_text[:500]}") from e
165
+
166
+ # Update metadata from output
167
+ if "gen" in output:
168
+ metadata["gen"] = output["gen"]
169
+
170
+ # Update window list if present
171
+ if "windows" in output:
172
+ metadata["windows"] = output["windows"]
173
+
174
+ # Update input expectations
175
+ if output.get("input"):
176
+ inp = output["input"][0]
177
+ metadata["input_window"] = inp.get("id")
178
+ metadata["input_type"] = inp.get("type", "line")
179
+ metadata["input_gen"] = inp.get("gen", metadata["gen"])
180
+ else:
181
+ metadata["input_window"] = None
182
+ metadata["input_type"] = None
183
+
184
+ # Handle special input (file dialogs)
185
+ if "specialinput" in output:
186
+ special = output["specialinput"]
187
+ if special.get("type") == "fileref_prompt":
188
+ metadata["pending_fileref"] = True
189
+
190
+ self.save_metadata(metadata)
191
+
192
+ # Format output
193
+ formatted = self._format_output(output, metadata.get("windows", []))
194
+
195
+ return formatted, metadata
196
+
197
+ def _format_output(self, output: dict, windows: list) -> str:
198
+ """Format RemGlk output as readable text."""
199
+ result = []
200
+
201
+ # Build window type map
202
+ window_types = {}
203
+ for w in windows:
204
+ window_types[w["id"]] = w.get("type", "buffer")
205
+
206
+ # Process content
207
+ content = output.get("content", [])
208
+ grid_content = []
209
+ buffer_content = []
210
+
211
+ for item in content:
212
+ win_id = item.get("id")
213
+ win_type = window_types.get(win_id, "buffer")
214
+
215
+ if win_type == "grid":
216
+ # Status bar - extract lines
217
+ for line in item.get("lines", []):
218
+ text = self._extract_text(line.get("content", []))
219
+ if text.strip():
220
+ grid_content.append(text)
221
+ else:
222
+ # Buffer window - extract text
223
+ if item.get("clear"):
224
+ buffer_content = [] # Clear previous content
225
+
226
+ for text_item in item.get("text", []):
227
+ if not text_item:
228
+ buffer_content.append("")
229
+ elif text_item.get("append"):
230
+ if buffer_content:
231
+ buffer_content[-1] += self._extract_text(text_item.get("content", []))
232
+ else:
233
+ buffer_content.append(self._extract_text(text_item.get("content", [])))
234
+ else:
235
+ buffer_content.append(self._extract_text(text_item.get("content", [])))
236
+
237
+ # Format output
238
+ if grid_content:
239
+ result.append("=== " + " | ".join(grid_content) + " ===")
240
+ result.append("")
241
+
242
+ if buffer_content:
243
+ result.extend(buffer_content)
244
+
245
+ # Note if character input expected
246
+ if output.get("input") and output["input"][0].get("type") == "char":
247
+ result.append("")
248
+ result.append("[Waiting for keypress]")
249
+
250
+ return "\n".join(result)
251
+
252
+ def _extract_text(self, content: list) -> str:
253
+ """Extract text from RemGlk content array, preserving style info."""
254
+ if not content:
255
+ return ""
256
+
257
+ result = []
258
+ i = 0
259
+ while i < len(content):
260
+ item = content[i]
261
+ if isinstance(item, dict):
262
+ style = item.get("style", "normal")
263
+ text = item.get("text", "")
264
+ result.append(self._apply_style(style, text))
265
+ i += 1
266
+ elif isinstance(item, str):
267
+ style = item
268
+ if i + 1 < len(content) and isinstance(content[i + 1], str):
269
+ text = content[i + 1]
270
+ result.append(self._apply_style(style, text))
271
+ i += 2
272
+ else:
273
+ i += 1
274
+ else:
275
+ i += 1
276
+
277
+ return "".join(result)
278
+
279
+ def _apply_style(self, style: str, text: str) -> str:
280
+ """Apply markdown-style formatting based on Glulx style."""
281
+ if not text:
282
+ return ""
283
+
284
+ if style in ("user1", "user2"):
285
+ return f"[{text}]"
286
+ elif style == "emphasized":
287
+ return f"*{text}*"
288
+ elif style in ("header", "subheader", "alert"):
289
+ return f"**{text}**"
290
+ elif style == "preformatted":
291
+ return f"`{text}`"
292
+ elif style == "note":
293
+ return f"({text})"
294
+ elif style == "blockquote":
295
+ return f'"{text}"'
296
+ elif style == "input":
297
+ return f"> {text}"
298
+ else:
299
+ return text
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-if
3
+ Version: 0.1.0
4
+ Summary: MCP server for playing Glulx interactive fiction games
5
+ Project-URL: Homepage, https://github.com/davidar/mcp-server-if
6
+ Project-URL: Repository, https://github.com/davidar/mcp-server-if
7
+ Project-URL: Issues, https://github.com/davidar/mcp-server-if/issues
8
+ Author-email: David A Roberts <d@vidr.cc>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude,glulx,interactive-fiction,mcp,text-adventure
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
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 :: Role-Playing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.25.0
23
+ Requires-Dist: mcp>=1.0.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # mcp-server-if
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.
29
+
30
+ ## Features
31
+
32
+ - Play Glulx (.ulx) and Blorb (.gblorb) interactive fiction games
33
+ - Automatic game state persistence (save/restore between sessions)
34
+ - Download games directly from the IF Archive
35
+ - Optional journaling mode for reflective playthroughs
36
+ - Works with Claude Desktop, Claude Code, and other MCP clients
37
+ - Bundled glulxe interpreter (no manual compilation required)
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ # Using uvx (recommended)
43
+ uvx mcp-server-if
44
+
45
+ # Or install with pip
46
+ pip install mcp-server-if
47
+ ```
48
+
49
+ The package includes a pre-compiled `glulxe` binary. No additional setup required.
50
+
51
+ ## Configuration
52
+
53
+ ### Environment Variables
54
+
55
+ | Variable | Description | Default |
56
+ |----------|-------------|---------|
57
+ | `IF_GAMES_DIR` | Directory to store games | `~/.mcp-server-if/games` |
58
+ | `IF_GLULXE_PATH` | Override path to glulxe binary | Bundled binary |
59
+ | `IF_REQUIRE_JOURNAL` | Require journal reflections | `false` |
60
+
61
+ ### Command Line Arguments
62
+
63
+ ```bash
64
+ mcp-server-if --help
65
+
66
+ Options:
67
+ --games-dir PATH Directory to store games
68
+ --glulxe-path PATH Path to glulxe binary (overrides bundled)
69
+ --require-journal Require journal reflections between turns
70
+ ```
71
+
72
+ ## Usage with Claude Desktop
73
+
74
+ Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "interactive-fiction": {
80
+ "command": "uvx",
81
+ "args": ["mcp-server-if"]
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## Usage with Claude Code
88
+
89
+ ```bash
90
+ claude mcp add interactive-fiction -- uvx mcp-server-if
91
+ ```
92
+
93
+ ## Available Tools
94
+
95
+ ### `play_if`
96
+ Play a turn of interactive fiction.
97
+
98
+ ```
99
+ play_if(game="zork", command="go north")
100
+ play_if(game="zork", command="", journal="...reflection...") # with journaling
101
+ ```
102
+
103
+ ### `list_games`
104
+ List available games and their save state status.
105
+
106
+ ### `download_game`
107
+ Download a game from the IF Archive or any URL.
108
+
109
+ ```
110
+ download_game(name="advent", url="advent.ulx")
111
+ download_game(name="bronze", url="https://example.com/Bronze.gblorb")
112
+ ```
113
+
114
+ ### `reset_game`
115
+ Reset a game to start fresh (clears save state, preserves journal).
116
+
117
+ ### `read_journal`
118
+ Read the playthrough journal for a game.
119
+
120
+ ```
121
+ read_journal(game="zork", recent=10) # last 10 entries
122
+ ```
123
+
124
+ ### `search_journal`
125
+ Search journal entries by keyword.
126
+
127
+ ```
128
+ search_journal(game="zork", query="treasure")
129
+ ```
130
+
131
+ ## Supported Game Formats
132
+
133
+ - `.ulx` - Raw Glulx game files
134
+ - `.gblorb` - Blorb containers with Glulx games (may include graphics/sound)
135
+
136
+ Find games at the [IF Archive](https://ifarchive.org/indexes/if-archive/games/glulx/).
137
+
138
+ ## Journaling Mode
139
+
140
+ Enable with `--require-journal` or `IF_REQUIRE_JOURNAL=true`. In this mode:
141
+
142
+ 1. After playing your first command, subsequent turns require a journal entry
143
+ 2. Journal entries must be at least 100 words
144
+ 3. Entries are saved to `{game}/journal.jsonl`
145
+ 4. Use `read_journal` and `search_journal` to review your playthrough
146
+
147
+ This encourages thoughtful, reflective gameplay rather than rushing through.
148
+
149
+ ## How It Works
150
+
151
+ 1. Games are stored in `~/.mcp-server-if/games/{name}/`
152
+ 2. Each game directory contains:
153
+ - `game.ulx` or `game.gblorb` - the game file
154
+ - `state/` - autosave data (persists between sessions)
155
+ - `metadata.json` - session metadata
156
+ - `journal.jsonl` - playthrough journal (if enabled)
157
+
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
162
+
163
+ If installing from source (not from PyPI), glulxe will be compiled during installation. This requires:
164
+
165
+ - C compiler (gcc or clang)
166
+ - make
167
+ - git (for submodules)
168
+
169
+ ```bash
170
+ git clone --recursive https://github.com/davidar/mcp-server-if.git
171
+ cd mcp-server-if
172
+ pip install .
173
+ ```
174
+
175
+ ## Troubleshooting
176
+
177
+ ### "glulxe binary not found"
178
+
179
+ This shouldn't happen with pip/uvx installs. If it does:
180
+ - Try reinstalling: `pip install --force-reinstall mcp-server-if`
181
+ - Or set `IF_GLULXE_PATH` to a manually installed glulxe
182
+
183
+ ### "Game file not found"
184
+
185
+ Use `list_games` to see available games, or `download_game` to get new ones.
186
+
187
+ ### Save/restore commands don't work
188
+
189
+ In-game save/restore triggers file dialogs that aren't supported. Use the automatic autosave system instead - your game state persists between sessions automatically.
190
+
191
+ ## License
192
+
193
+ MIT License - see [LICENSE](LICENSE) for details.
194
+
195
+ ## Credits
196
+
197
+ - [glulxe](https://github.com/erkyrath/glulxe) - The Glulx VM interpreter by Andrew Plotkin
198
+ - [RemGlk](https://github.com/erkyrath/remglk) - Remote Glk library for JSON I/O
199
+ - [MCP](https://modelcontextprotocol.io/) - Model Context Protocol by Anthropic
@@ -0,0 +1,12 @@
1
+ mcp_server_if/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mcp_server_if/__main__.py,sha256=HEiagMZ-9v0Ugdszyh4nC7R-esMTX8Bk7vu3ox3McLI,132
3
+ mcp_server_if/__init__.py,sha256=9CHcB-6L566M9Gy_R5tzYWp8gXK1GJ5DhINUiJKK28Y,289
4
+ mcp_server_if/server.py,sha256=lYuWCCmseoRpkiP4zCnd9lk-wZy6AhadfgmMq9Ly2-o,12100
5
+ mcp_server_if/config.py,sha256=MXDDzHPv6vhTEAj9x_Zqn4Ys4rfl_LDNgVfRD0wc1Y4,3239
6
+ mcp_server_if/session.py,sha256=VXamjN85XezxfCuhmfoBO82t78q9YPD_JEWw7vSIHcE,10663
7
+ mcp_server_if/bin/glulxe,sha256=KcFj63eKbRuQvcU1zd2NMP1-S9HAC9haYA6xpsFv-VI,812736
8
+ mcp_server_if-0.1.0.dist-info/WHEEL,sha256=tQPINDmzfU0y7pfVJjQUrngsc-O9y-WskGTTjmtBvXg,145
9
+ mcp_server_if-0.1.0.dist-info/entry_points.txt,sha256=Fh-zkWrbgDBe_2UzNjRKYR6vw_kc-q5GnIojwdFS3vk,53
10
+ mcp_server_if-0.1.0.dist-info/METADATA,sha256=S9cnguCgOVHR1GPMDJJWW5IK6zq70cCT-c2R1IpKDRw,5861
11
+ mcp_server_if-0.1.0.dist-info/RECORD,,
12
+ mcp_server_if-0.1.0.dist-info/licenses/LICENSE,sha256=BXxcwr8hEiK-byLXh_amNTuuES4DlNmCmLmQMlVptNY,1072
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-manylinux_2_27_aarch64
5
+ Tag: py3-none-manylinux_2_28_aarch64
6
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-server-if = mcp_server_if:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 David A Roberts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.