|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
6 | 6 | import json |
| 7 | +import re |
7 | 8 | from datetime import datetime |
8 | 9 | from pathlib import Path |
9 | 10 | from typing import Any |
|
20 | 21 | get_comses_cache_dir, |
21 | 22 | get_comses_max_download_mb, |
22 | 23 | get_exports_dir, |
| 24 | + get_gui_mode, |
23 | 25 | get_models_dir, |
24 | 26 | get_netlogo_home, |
25 | 27 | ) |
26 | 28 | from .server import mcp |
27 | 29 |
|
| 30 | +# NetLogo identifiers may contain letters, digits, and the punctuation |
| 31 | +# characters NetLogo permits in variable / breed names: ``- _ . ? !``. |
| 32 | +# We reject anything else to prevent command injection through `set_parameter` |
| 33 | +# (the value is escaped, but the name is interpolated literally into a |
| 34 | +# `set <name> <value>` command). |
| 35 | +_NETLOGO_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-.?!]*$") |
| 36 | + |
28 | 37 | # ── Helpers ────────────────────────────────────────────────────────────────── |
29 | 38 |
|
30 | 39 |
|
@@ -216,6 +225,9 @@ async def run_simulation( |
216 | 225 | raise ToolError("ticks must be between 1 and 10000.") |
217 | 226 | if not reporters: |
218 | 227 | raise ToolError("reporters list cannot be empty.") |
| 228 | + for i, r in enumerate(reporters): |
| 229 | + if not isinstance(r, str) or not r.strip(): |
| 230 | + raise ToolError(f"reporters[{i}] must be a non-empty string (got {r!r}).") |
219 | 231 | if max_rows < 0: |
220 | 232 | raise ToolError("max_rows must be >= 0 (0 = no cap).") |
221 | 233 |
|
@@ -285,8 +297,17 @@ async def set_parameter(name: str, value: Any, ctx: Context) -> str: |
285 | 297 |
|
286 | 298 | Args: |
287 | 299 | name: Name of the global variable (e.g. 'initial-number-sheep'). |
| 300 | + Must be a valid NetLogo identifier — letters, digits, and any |
| 301 | + of ``- _ . ? !`` only. Names with whitespace or shell/NetLogo |
| 302 | + meta-characters are rejected to prevent command injection. |
288 | 303 | value: The value to set. Numbers, strings, booleans accepted. |
289 | 304 | """ |
| 305 | + if not isinstance(name, str) or not _NETLOGO_IDENTIFIER_RE.match(name): |
| 306 | + raise ToolError( |
| 307 | + f"Invalid parameter name: {name!r}. " |
| 308 | + "Must start with a letter and contain only letters, digits, " |
| 309 | + "or any of '-', '_', '.', '?', '!' (NetLogo's identifier rules)." |
| 310 | + ) |
290 | 311 | nl = _require_model(ctx) |
291 | 312 | # Format the value for NetLogo |
292 | 313 | if isinstance(value, bool): |
@@ -354,6 +375,8 @@ async def get_patch_data( |
354 | 375 | JSON 2D array (rows = y descending, cols = x ascending) by default, |
355 | 376 | or a compact summary dict if `summary_only=True`. |
356 | 377 | """ |
| 378 | + if not isinstance(attribute, str) or not attribute.strip(): |
| 379 | + raise ToolError("attribute must be a non-empty string.") |
357 | 380 | nl = _require_model(ctx) |
358 | 381 | try: |
359 | 382 | data = nl.patch_report(attribute) |
@@ -573,6 +596,76 @@ async def export_world(ctx: Context) -> str: |
573 | 596 | return f"World exported to {export_path}" |
574 | 597 |
|
575 | 598 |
|
| 599 | +@mcp.tool() |
| 600 | +async def close_model(ctx: Context) -> str: |
| 601 | + """Unload the currently loaded model and reset the workspace. |
| 602 | +
|
| 603 | + Useful when you want to discard pending state (mid-run agents, set |
| 604 | + parameters, pending plots) and start fresh, or before opening a new |
| 605 | + model file from disk to make sure cached compilation state isn't carried |
| 606 | + forward. |
| 607 | +
|
| 608 | + Note: this does NOT shut down the JVM or NetLogo workspace — only the |
| 609 | + model. The next `open_model` / `create_model` call will reuse the same |
| 610 | + JVM (no 30-60s warmup). |
| 611 | + """ |
| 612 | + nl = _nl(ctx) |
| 613 | + if _current_model_path(ctx) is None: |
| 614 | + raise ToolError("No model is currently loaded.") |
| 615 | + try: |
| 616 | + # NetLogo doesn't expose an explicit "close model" primitive; the |
| 617 | + # closest stable equivalent is `clear-all`, which wipes turtles, |
| 618 | + # patches, ticks, plots, and globals. We then forget the path so |
| 619 | + # subsequent BehaviorSpace / world-state tools refuse to run until |
| 620 | + # a new model is loaded. |
| 621 | + nl.command("clear-all") |
| 622 | + except Exception as e: |
| 623 | + raise _wrap_netlogo_error(e) from e |
| 624 | + _set_current_model_path(ctx, None) |
| 625 | + return "Model closed. World cleared. Open another model to continue." |
| 626 | + |
| 627 | + |
| 628 | +@mcp.tool() |
| 629 | +async def server_info(ctx: Context) -> str: |
| 630 | + """Return a snapshot of the running NetLogo MCP server's configuration. |
| 631 | +
|
| 632 | + Useful as a no-cost health check for the AI / user — returns the server |
| 633 | + version, configured paths, GUI mode, currently-loaded model, and whether |
| 634 | + a NetLogo headless launcher is reachable for BehaviorSpace runs. |
| 635 | +
|
| 636 | + No JVM round-trip; this is a pure config / filesystem inspection so it |
| 637 | + works even before the workspace is fully initialized. |
| 638 | + """ |
| 639 | + from . import __version__ |
| 640 | + |
| 641 | + info: dict[str, Any] = { |
| 642 | + "server_version": __version__, |
| 643 | + "gui_mode": get_gui_mode(), |
| 644 | + "models_dir": str(get_models_dir()), |
| 645 | + "exports_dir": str(get_exports_dir()), |
| 646 | + "comses_cache_dir": str(get_comses_cache_dir()), |
| 647 | + "comses_max_download_mb": get_comses_max_download_mb(), |
| 648 | + "current_model_path": _current_model_path(ctx), |
| 649 | + } |
| 650 | + try: |
| 651 | + info["netlogo_home"] = get_netlogo_home() |
| 652 | + except OSError as exc: |
| 653 | + info["netlogo_home"] = None |
| 654 | + info["netlogo_home_error"] = str(exc) |
| 655 | + |
| 656 | + if info.get("netlogo_home"): |
| 657 | + try: |
| 658 | + launcher = _bspace.locate_headless_launcher(info["netlogo_home"]) |
| 659 | + info["headless_launcher"] = str(launcher) |
| 660 | + except _bspace.BSpaceError as exc: |
| 661 | + info["headless_launcher"] = None |
| 662 | + info["headless_launcher_error"] = str(exc) |
| 663 | + else: |
| 664 | + info["headless_launcher"] = None |
| 665 | + |
| 666 | + return json.dumps(info, indent=2) |
| 667 | + |
| 668 | + |
576 | 669 | # ── CoMSES Net integration ────────────────────────────────────────────────── |
577 | 670 | # |
578 | 671 | # Five tools + one prompt that let an AI client browse, inspect, download, and |
|
0 commit comments