mcp-server-if 0.1.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/__init__.py +12 -0
- mcp_server_if/__main__.py +6 -0
- mcp_server_if/bin/glulxe +0 -0
- mcp_server_if/config.py +103 -0
- mcp_server_if/py.typed +0 -0
- mcp_server_if/server.py +412 -0
- mcp_server_if/session.py +299 -0
- mcp_server_if-0.1.0.dist-info/METADATA +199 -0
- mcp_server_if-0.1.0.dist-info/RECORD +12 -0
- mcp_server_if-0.1.0.dist-info/WHEEL +5 -0
- mcp_server_if-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_server_if-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|
mcp_server_if/bin/glulxe
ADDED
|
Binary file
|
mcp_server_if/config.py
ADDED
|
@@ -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
|
mcp_server_if/server.py
ADDED
|
@@ -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()
|
mcp_server_if/session.py
ADDED
|
@@ -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/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
|
|
@@ -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.
|