Skip to content

Commit 3b88950

Browse files
authored
Merge pull request #6 from Razee4315/feat/security-quality-and-feature-improvements
feat: security hardening + close_model/server_info tools + dead code cleanup
2 parents 9140f5d + 3b513ff commit 3b88950

5 files changed

Lines changed: 258 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Security & validation
11+
12+
- `set_parameter` now validates the `name` argument against a NetLogo
13+
identifier regex before interpolating it into a `set <name> <value>`
14+
command. Closes a command-injection vector where a name like
15+
`"x setup -- "` would have appended an arbitrary NetLogo command past
16+
the `set`. Legitimate kebab-case + predicate names (`show-energy?`,
17+
`initial-number-sheep`) are unaffected.
18+
- `run_simulation` rejects empty / non-string entries in `reporters`,
19+
and `get_patch_data` rejects blank `attribute`s — these formerly
20+
produced confusing NetLogo compile errors deep in the call.
21+
- 25 new tests cover the validation surface (injection-shape names,
22+
blank reporters, blank attributes).
23+
24+
### Added — workspace control & introspection
25+
26+
- New `close_model` tool — issues `clear-all` and forgets the current
27+
model path so AI clients can drop pending state without bouncing the
28+
JVM (which costs 30-60s on the next startup).
29+
- New `server_info` tool — pure config / filesystem inspection that
30+
returns server version, GUI mode, configured paths, and whether the
31+
BehaviorSpace headless launcher is reachable. Useful as a pre-flight
32+
check before launching long sweeps; does not require the JVM to be
33+
warm.
34+
35+
### Removed
36+
37+
- Dead `get_or_create_netlogo` lazy-init helper in `server.py` — the
38+
eager `lifespan()` startup is the only path now, and the duplicated
39+
init was an attractive nuisance for future contributors.
40+
1041
### Added — BehaviorSpace integration & NetLogo 7 transition guide
1142

1243
- 3 new tools for running NetLogo BehaviorSpace experiments:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ By default, a real NetLogo window opens so you can watch your simulations run li
6060
| `export_view()` | Export current view as PNG image |
6161
| `save_model(name, code)` | Save model to file |
6262
| `export_world()` | Export full world state to CSV |
63+
| `close_model()` | Unload the current model and `clear-all` the workspace |
64+
| `server_info()` | Inspect server config (paths, GUI mode, headless launcher) |
6365
| `list_models()` | List model files in models directory |
6466
| `search_comses(query)` | Search the CoMSES Net model library |
6567
| `get_comses_model(uuid)` | Fetch metadata + citation text for one COMSES model |

src/netlogo_mcp/server.py

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,13 @@
1414
import logging
1515
from collections.abc import AsyncIterator, Callable, Iterator
1616
from contextlib import asynccontextmanager, contextmanager
17-
from threading import RLock
1817
from typing import Any, TypeVar
1918

2019
from fastmcp import FastMCP
2120

2221
from .config import get_gui_mode, get_jvm_path, get_netlogo_home
2322

2423
logger = logging.getLogger("netlogo_mcp")
25-
_workspace_lock = RLock()
2624
T = TypeVar("T")
2725

2826

@@ -45,49 +43,6 @@ def run_with_stdout_protection(
4543
return func(*args, **kwargs)
4644

4745

48-
def get_or_create_netlogo(lifespan_context: dict[str, Any]) -> Any:
49-
"""Create the shared NetLogo workspace on first use, then reuse it.
50-
51-
Lazy init: the JVM starts on the first tool call (30-60s), but
52-
this ensures it runs on the correct thread with the right Java
53-
class loader context. All subsequent calls are instant.
54-
"""
55-
existing = lifespan_context.get("netlogo")
56-
if existing is not None:
57-
return existing
58-
59-
with _workspace_lock:
60-
existing = lifespan_context.get("netlogo")
61-
if existing is not None:
62-
return existing
63-
64-
with protect_stdout():
65-
import pynetlogo
66-
67-
nl_home = get_netlogo_home()
68-
jvm_path = get_jvm_path()
69-
gui = get_gui_mode()
70-
mode_str = "GUI (live window)" if gui else "headless"
71-
72-
logger.info("Starting Java Virtual Machine...")
73-
logger.info(
74-
"Initializing NetLogo in %s mode (NETLOGO_HOME=%s)",
75-
mode_str,
76-
nl_home,
77-
)
78-
79-
nl = pynetlogo.NetLogoLink(
80-
netlogo_home=nl_home,
81-
gui=gui,
82-
thd=False, # JPype handles Swing EDT; thd=True hangs on Windows
83-
jvm_path=jvm_path,
84-
)
85-
86-
lifespan_context["netlogo"] = nl
87-
logger.info("NetLogo workspace ready (%s) — all tools available", mode_str)
88-
return nl
89-
90-
9146
def shutdown_netlogo(lifespan_context: dict[str, Any]) -> None:
9247
"""Close the shared NetLogo workspace if it was created."""
9348
nl = lifespan_context.pop("netlogo", None)

src/netlogo_mcp/tools.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import json
7+
import re
78
from datetime import datetime
89
from pathlib import Path
910
from typing import Any
@@ -20,11 +21,19 @@
2021
get_comses_cache_dir,
2122
get_comses_max_download_mb,
2223
get_exports_dir,
24+
get_gui_mode,
2325
get_models_dir,
2426
get_netlogo_home,
2527
)
2628
from .server import mcp
2729

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+
2837
# ── Helpers ──────────────────────────────────────────────────────────────────
2938

3039

@@ -216,6 +225,9 @@ async def run_simulation(
216225
raise ToolError("ticks must be between 1 and 10000.")
217226
if not reporters:
218227
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}).")
219231
if max_rows < 0:
220232
raise ToolError("max_rows must be >= 0 (0 = no cap).")
221233

@@ -285,8 +297,17 @@ async def set_parameter(name: str, value: Any, ctx: Context) -> str:
285297
286298
Args:
287299
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.
288303
value: The value to set. Numbers, strings, booleans accepted.
289304
"""
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+
)
290311
nl = _require_model(ctx)
291312
# Format the value for NetLogo
292313
if isinstance(value, bool):
@@ -354,6 +375,8 @@ async def get_patch_data(
354375
JSON 2D array (rows = y descending, cols = x ascending) by default,
355376
or a compact summary dict if `summary_only=True`.
356377
"""
378+
if not isinstance(attribute, str) or not attribute.strip():
379+
raise ToolError("attribute must be a non-empty string.")
357380
nl = _require_model(ctx)
358381
try:
359382
data = nl.patch_report(attribute)
@@ -573,6 +596,76 @@ async def export_world(ctx: Context) -> str:
573596
return f"World exported to {export_path}"
574597

575598

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+
576669
# ── CoMSES Net integration ──────────────────────────────────────────────────
577670
#
578671
# Five tools + one prompt that let an AI client browse, inspect, download, and

0 commit comments

Comments
 (0)