Skip to content

Commit cef60fd

Browse files
vosesoftclaude
andcommitted
Release v0.3.0-alpha.17: full bug-list sweep
Tackles every still-broken item from the running bug list — the biggest correctness release since the v0.3 pivot. Envelope sweep (#1, #2, validates #15) Every list-returning MCP tool now wraps its response in a dict envelope `{<noun>, count}` so FastMCP serialises as one structured payload instead of exploding into N per-element content blocks. Tools updated: list_open_workbooks, list_modelrisk_inputs, list_modelrisk_outputs, list_distributions, get_simulation_results, find_hard_coded_inputs, list_vmrs_variables, read_vmrs, propose_distributions_for_inputs, discover_inputs. New test_no_tool_returns_bare_list CI guard prevents regressions. run_simulation post-condition verification (#20) The "most important reliability bug" — run_simulation no longer reports success when ModelRisk's post-simulation phase crashed silently leaving an unusable .vmrs. The bridge captures expected VoseOutput names before the sim and verifies at least one resolves to a var_id in the produced .vmrs afterwards. On failure, raises SimulationFailedError AND triggers auto-recovery via restore_deterministic_state. restore_deterministic_state tool (#21) New MCP tool that recalculates the workbook to clear VoseOutput cells stuck on per-iteration sample values. The recovery path for the frozen-sample state. MRService activation error message (#8) Now lists both activation flavours (single-int64 + split-int64), explains MRSERVICE_DISABLE_BUNDLED_KEY, points at docs URL. Already fixed (acknowledged from list) #4 wrap_with_output non-Vose: code already passes allow_overwrite_non_vose=True. #5 save_workbook_as: registered as MCP tool since alpha.2. #6 set_cell_formula: exposed as write_formula since alpha.2. #7 get_active_workbook OneDrive: graceful fallback already present. #12 unsaved-workbook path: detected and zeroed out already. Obsoleted by the v0.3 pivot #10 use_vba_helper_for_simulation, #11 ensure_modelrisk_active — both refer to code removed when MRService.dll replaced the VBA helper approach. 398 tests pass (+5 in test_modelrisk_mocked covering #20 happy path, #20 failure path, auto-restore on failure, #21 with explicit workbook, #21 default active). Plus the new envelope CI guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9ab8f8e commit cef60fd

15 files changed

Lines changed: 464 additions & 60 deletions

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,37 @@ All notable changes to ModelRisk MCP. Follows [Keep a Changelog](https://keepach
44

55
## [Unreleased]
66

7+
## [0.3.0-alpha.17] — 2026-05-22
8+
9+
Full sweep against the running bug list — the biggest correctness release since the v0.3 pivot. Tackles every still-broken item: the response-envelope cross-cutting fix (#1, #2, validates #15), `run_simulation` false-positive reliability (#20), and the workbook-recovery tool (#21), plus a CI guard so the envelope category can't regress.
10+
11+
### Fixed
12+
13+
- **Envelope sweep across every list-returning MCP tool (#1, #2, validates #15).** FastMCP serialises a bare `list[T]` return as one MCP content block per element — which makes the LLM see N concatenated objects instead of a single array. Symptoms ranged from "list_modelrisk_outputs returns a single record" (#1) to "list_vmrs_variables returns concatenated JSON objects" (#2) to "get_samples wraps each float in a text-block dict" (#15, fixed in alpha.14). Fixed all of them in one pass by wrapping every list-typed response in a dict envelope with a semantic noun key: `list_open_workbooks` → `{workbooks, count}`, `list_modelrisk_inputs` → `{inputs, count}`, `list_modelrisk_outputs` → `{outputs, count}`, `list_distributions` → `{distributions, count}`, `get_simulation_results` → `{results, count}`, `find_hard_coded_inputs` → `{candidates, count}`, `list_vmrs_variables` → `{variables, count}`, `read_vmrs` → `{results, count}`, `propose_distributions_for_inputs` → `{proposals, count}`, `discover_inputs` → `{candidates, count}`. New CI guard test (`test_no_tool_returns_bare_list`) scans every tool module and fails if any uses `-> list[`, so the next instance of this category gets caught before merge.
14+
- **`run_simulation` no longer reports false-positive success (#20).** Previously the tool returned `samples: 10000` and a valid `.vmrs` path even when ModelRisk's post-simulation phase crashed silently — leaving the `.vmrs` without registered output metadata and every downstream reader unable to find anything. The "samples" number was just echoing the input parameter, not measuring actual completion. Fix: post-condition verification. Before running, the bridge captures the list of expected VoseOutput names. After the simulation returns, it opens the produced `.vmrs` and confirms at least one expected output resolves to a `var_id`. If none do, raises `SimulationFailedError` with an actionable message ("the simulation's post-sim phase failed to register outputs; run `restore_deterministic_state` to recover").
15+
- **`restore_deterministic_state` recovery tool (#21).** New MCP tool that recalculates the workbook to clear any VoseOutput cells stuck on per-iteration sample values from a previous run. Triggers `Application.CalculateFull` which re-evaluates every formula. Wired into the auto-recovery path on `run_simulation` post-condition failure — so the workbook is restored even if the user doesn't call the tool explicitly.
16+
- **MRService.dll activation error message (#8).** The "no key supplied" error now lists both activation flavours (single-int64 via `MRSERVICE_ACTIVATION_KEY`, split-int64 via `MRSERVICE_ACTIVATION_KEY1/2`), explains what `MRSERVICE_DISABLE_BUNDLED_KEY` does, and points at the activation docs URL.
17+
18+
### Already fixed (acknowledged from the running bug list)
19+
20+
- **#4 (`wrap_with_output` refuses non-Vose formulas)** — current code passes `allow_overwrite_non_vose=True` and an existing test covers the Workflow-1-Step-6 pattern. The bug was real in an earlier alpha; the current implementation is correct.
21+
- **#5 (`save_workbook_as` tool)** — registered as an MCP tool in `tools/building.py` since alpha.2.
22+
- **#6 (`set_cell_formula` / guarded write)** — exposed as `write_formula` in `tools/building.py` since alpha.2.
23+
- **#7 (`get_active_workbook` OneDrive)** — fallback path in `excel.py::get_active_workbook` already returns an empty path when xlwings' OneDrive resolution fails.
24+
- **#12 (unsaved-workbook path)**`_workbook_info` detects path strings missing any separator (the unsaved-workbook signature) and returns empty path.
25+
26+
### Obsoleted
27+
28+
- **#10 (`use_vba_helper_for_simulation` hangs)** and **#11 (`ensure_modelrisk_active` overfit)** — both refer to code that was removed in the v0.3 MRService.dll pivot. The new architecture doesn't have a VBA helper or an add-in-activation gate; simulations run directly via `Application.Run` on the XLL command surface and `.vmrs` reads go through MRService.
29+
30+
### New tools
31+
32+
- `restore_deterministic_state(workbook_name?)` — workbook recovery from the frozen-sample state.
33+
34+
### Tests
35+
36+
398 unit tests pass (+5: post-condition happy path, post-condition fails when no output registered, auto-restore fires on post-condition failure, restore tool with explicit workbook, restore tool defaults to active). Plus the new envelope CI guard.
37+
738
## [0.3.0-alpha.16] — 2026-05-22
839

940
Two paired bugs in the report builders, both surfaced by the same end-user testing session — charts came out blank, and the staging data leaked onto the user-visible report sheet. Both `build_executive_report` and `build_drivers_report` are affected because they share the same chart-construction helpers.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "modelrisk-mcp"
3-
version = "0.3.0a16"
3+
version = "0.3.0a17"
44
description = "Open MCP server bridging Anthropic Claude (and any MCP-compatible client) with the ModelRisk Excel add-in."
55
readme = "README.md"
66
requires-python = ">=3.11"

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"name": "io.github.vosesoftware/modelrisk-mcp",
44
"title": "ModelRisk",
55
"description": "Read, build, fit, and run Monte Carlo risk models in Excel through Vose Software's ModelRisk.",
6-
"version": "0.3.0a16",
6+
"version": "0.3.0a17",
77
"packages": [
88
{
99
"registryType": "pypi",
1010
"identifier": "modelrisk-mcp",
11-
"version": "0.3.0a16",
11+
"version": "0.3.0a17",
1212
"transport": {
1313
"type": "stdio"
1414
}

src/modelrisk_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.0a16"
1+
__version__ = "0.3.0a17"

src/modelrisk_mcp/bridge/excel.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,27 @@ def undo(self) -> None:
385385
except Exception as exc:
386386
raise CellReferenceError(f"Excel.Undo() failed: {exc}") from exc
387387

388+
def recalculate_workbook(self, workbook: str) -> None:
389+
"""Force a full recalculation of `workbook`.
390+
391+
Used by `restore_deterministic_state` to recover a workbook
392+
left with VoseOutput cells holding the last simulation sample
393+
(the "frozen sample" symptom of bug #20). A full recalc
394+
re-runs every formula in the book, which re-evaluates each
395+
VoseOutput's underlying expression to its deterministic
396+
value."""
397+
book = self._get_book(workbook)
398+
try:
399+
# `FullCalculate` rebuilds the dependency tree as well as
400+
# recomputing — more thorough than `Calculate`, and the
401+
# right tool when ModelRisk may have left dependency
402+
# tracking in an odd state.
403+
book.api.Application.CalculateFull()
404+
except Exception as exc:
405+
raise CellReferenceError(
406+
f"Excel.CalculateFull() failed on {workbook!r}: {exc}"
407+
) from exc
408+
388409
def save_workbook_as(
389410
self, workbook: str, path: str, *, overwrite: bool = False,
390411
) -> str:

src/modelrisk_mcp/bridge/modelrisk.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
SimulationRunResult,
4141
)
4242
from modelrisk_mcp.config import Settings
43-
from modelrisk_mcp.errors import CellReferenceError
43+
from modelrisk_mcp.errors import CellReferenceError, SimulationFailedError
4444
from modelrisk_mcp.safety import (
4545
WriterMutex,
4646
append_write_log,
@@ -136,6 +136,27 @@ def run_simulation(
136136
seed: int = 1,
137137
save_to: str | None = None,
138138
) -> SimulationRunResult:
139+
# Bug #20 fix: capture the list of VoseOutput names BEFORE the
140+
# simulation so we can verify at least one ended up registered
141+
# in the .vmrs afterwards. Without this, run_simulation
142+
# reports success on simulations where ModelRisk's
143+
# post-simulation phase quietly failed — leaving the .vmrs
144+
# missing output metadata and every downstream reader unable
145+
# to find anything.
146+
resolved_workbook = (
147+
workbook if workbook else self._excel.get_active_workbook().name
148+
)
149+
expected_output_names: list[str] = []
150+
try:
151+
expected_output_names = [
152+
o.name for o in self.list_outputs(resolved_workbook)
153+
]
154+
except Exception:
155+
# Scanner failure shouldn't abort the sim — but we lose
156+
# the ability to verify outputs. The .vmrs-existence
157+
# check downstream is still in force.
158+
pass
159+
139160
result = self._simulation.run_simulation(
140161
workbook_name=workbook,
141162
samples=samples,
@@ -144,8 +165,88 @@ def run_simulation(
144165
)
145166
# Pin the produced file so the existing reader tools find it.
146167
self._results.set_active_vmrs(result.vmrs_path)
168+
169+
# Post-condition verification — see _verify_simulation_post_conditions
170+
# for the full criteria. If the .vmrs doesn't contain any of the
171+
# expected output names, raise. On raise, attempt to restore the
172+
# workbook to deterministic state (bug #21) so the user isn't
173+
# left with VoseOutput cells stuck on sample values. The restore
174+
# is best-effort; if it fails too, the original SimulationFailed
175+
# propagates regardless.
176+
if expected_output_names:
177+
try:
178+
self._verify_simulation_post_conditions(
179+
result.vmrs_path, expected_output_names,
180+
)
181+
except SimulationFailedError:
182+
try:
183+
self._excel.recalculate_workbook(resolved_workbook)
184+
except Exception:
185+
pass
186+
raise
147187
return result
148188

189+
def _verify_simulation_post_conditions(
190+
self, vmrs_path: str, expected_output_names: list[str],
191+
) -> None:
192+
"""Open the produced .vmrs and confirm at least one of the
193+
workbook's VoseOutput names resolves to a var_id. That's the
194+
cheapest available proof that ModelRisk's post-simulation
195+
phase actually wrote output metadata (vs. the bug #20
196+
symptom: sim completes, .vmrs is created, but no outputs are
197+
registered)."""
198+
try:
199+
with self._mrservice.open_vmrs(vmrs_path) as handle:
200+
for name in expected_output_names:
201+
try:
202+
if handle.lookup_var_id(name) is not None:
203+
return # post-condition satisfied
204+
except SimulationFailedError:
205+
# lookup_var_id can raise on pathological names;
206+
# one bad name doesn't condemn the whole sim.
207+
continue
208+
except SimulationFailedError:
209+
raise
210+
except Exception as exc:
211+
raise SimulationFailedError(
212+
f"Simulation completed but the produced .vmrs at "
213+
f"{vmrs_path!r} could not be opened for verification: "
214+
f"{exc!s}. The file may be incomplete or corrupt."
215+
) from exc
216+
raise SimulationFailedError(
217+
f"Simulation completed but the produced .vmrs at "
218+
f"{vmrs_path!r} does not register any of the expected "
219+
f"VoseOutput names: {expected_output_names!r}. This is the "
220+
f"signature of ModelRisk's post-simulation phase crashing "
221+
f"silently (the sim ran but the output metadata didn't "
222+
f"persist). The workbook may also be left with VoseOutput "
223+
f"cells stuck on sample values rather than deterministic "
224+
f"ones — run `restore_deterministic_state` to recover, then "
225+
f"retry the simulation."
226+
)
227+
228+
def restore_deterministic_state(
229+
self, workbook: str | None = None,
230+
) -> dict[str, Any]:
231+
"""Recalculate the workbook to clear any VoseOutput cells that
232+
are stuck on sample values from a previous run.
233+
234+
Recovery path for bug #21: when `run_simulation` fails post-
235+
condition (#20), the workbook's VoseOutput cells often retain
236+
the final per-iteration sample instead of the deterministic
237+
value. Subsequent reads (`list_modelrisk_outputs`,
238+
`find_hard_coded_inputs`) would then return misleading
239+
`current_value`s. A full recalculation restores the
240+
deterministic state by re-evaluating every formula."""
241+
resolved_workbook = (
242+
workbook if workbook else self._excel.get_active_workbook().name
243+
)
244+
self._excel.recalculate_workbook(resolved_workbook)
245+
return {
246+
"workbook_name": resolved_workbook,
247+
"recalculated": True,
248+
}
249+
149250
# ------------------------------------------------------------------
150251
# Environment checks
151252
# ------------------------------------------------------------------
@@ -419,7 +520,6 @@ def build_executive_report(
419520
stats = self._results.get_simulation_results(wb_path, all_outputs)
420521
results_by_name = {r.output_name: r for r in stats}
421522
if primary_output not in results_by_name:
422-
from modelrisk_mcp.errors import SimulationFailedError
423523
raise SimulationFailedError(
424524
f"Primary output {primary_output!r} not found in the "
425525
"active simulation results. Run a simulation first or "

src/modelrisk_mcp/bridge/mrservice.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,20 @@ def _activate(self) -> None:
331331
"report this to the modelrisk-mcp maintainers."
332332
)
333333
raise SimulationFailedError(
334-
"MRService.dll requires activation. Set MRSERVICE_ACTIVATION_KEY "
335-
"(a single int64) or both MRSERVICE_ACTIVATION_KEY1 and "
336-
"MRSERVICE_ACTIVATION_KEY2 (two int64s). MRSERVICE_DISABLE_"
337-
"BUNDLED_KEY is set, so the bundled key was not tried."
334+
"MRService.dll requires activation, but no key was supplied.\n\n"
335+
"Two activation flavours exist:\n"
336+
" * Single-int64 key — set MRSERVICE_ACTIVATION_KEY to the\n"
337+
" integer Vose issued you. This is the common case.\n"
338+
" * Split-int64 key — set both MRSERVICE_ACTIVATION_KEY1 and\n"
339+
" MRSERVICE_ACTIVATION_KEY2 to the two halves. Used by the\n"
340+
" Ex2 activation API.\n\n"
341+
"The bundled key normally covers the default install, but "
342+
"MRSERVICE_DISABLE_BUNDLED_KEY=1 is currently set in your "
343+
"environment, so it was skipped. Unset that variable to fall "
344+
"back to the bundled key, or supply one of the env vars "
345+
"above.\n\n"
346+
"Docs: "
347+
"https://github.com/vosesoftware/modelrisk-mcp/blob/main/docs/activation.md"
338348
)
339349

340350
# ----- vmrs read API -------------------------------------------------

src/modelrisk_mcp/tools/reading.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,9 @@
2121
from modelrisk_mcp.schemas.results import (
2222
CorrelationMatrix,
2323
SensitivityRanking,
24-
SimulationResult,
2524
)
2625
from modelrisk_mcp.schemas.workbook import (
2726
CellInfo,
28-
DistributionCell,
29-
ModelRiskInput,
30-
ModelRiskOutput,
3127
RangeInfo,
3228
WorkbookInfo,
3329
WorkbookSummary,
@@ -59,8 +55,13 @@ def set_bridge_for_testing(bridge: ModelRiskBridge | None) -> None:
5955

6056

6157
@mcp.tool(description="ModelRisk: List all Excel workbooks currently open.")
62-
def list_open_workbooks() -> list[WorkbookInfo]:
63-
return get_bridge().excel.list_workbooks()
58+
def list_open_workbooks() -> dict[str, Any]:
59+
# Envelope (vs bare `list[WorkbookInfo]`) avoids FastMCP's
60+
# list-element expansion which serialises each element as its own
61+
# MCP content block — see the module docstring for the full
62+
# backstory (alpha.17 sweep).
63+
workbooks = get_bridge().excel.list_workbooks()
64+
return {"workbooks": workbooks, "count": len(workbooks)}
6465

6566

6667
@mcp.tool(description="ModelRisk: Get the name and path of the active workbook.")
@@ -91,8 +92,9 @@ def get_workbook_summary(
9192
)
9293
def list_modelrisk_inputs(
9394
workbook_name: str,
94-
) -> list[ModelRiskInput]:
95-
return get_bridge().list_inputs(workbook_name)
95+
) -> dict[str, Any]:
96+
inputs = get_bridge().list_inputs(workbook_name)
97+
return {"inputs": inputs, "count": len(inputs)}
9698

9799

98100
@mcp.tool(
@@ -102,8 +104,9 @@ def list_modelrisk_inputs(
102104
)
103105
def list_modelrisk_outputs(
104106
workbook_name: str,
105-
) -> list[ModelRiskOutput]:
106-
return get_bridge().list_outputs(workbook_name)
107+
) -> dict[str, Any]:
108+
outputs = get_bridge().list_outputs(workbook_name)
109+
return {"outputs": outputs, "count": len(outputs)}
107110

108111

109112
@mcp.tool(
@@ -117,8 +120,9 @@ def list_modelrisk_outputs(
117120
def list_distributions(
118121
workbook_name: str,
119122
sheet: str | None = None,
120-
) -> list[DistributionCell]:
121-
return get_bridge().list_distributions(workbook_name, sheet=sheet)
123+
) -> dict[str, Any]:
124+
distributions = get_bridge().list_distributions(workbook_name, sheet=sheet)
125+
return {"distributions": distributions, "count": len(distributions)}
122126

123127

124128
@mcp.tool(
@@ -160,8 +164,9 @@ def read_range(
160164
def get_simulation_results(
161165
workbook_name: str,
162166
output_names: list[str] | None = None,
163-
) -> list[SimulationResult]:
164-
return get_bridge().get_simulation_results(workbook_name, output_names)
167+
) -> dict[str, Any]:
168+
results = get_bridge().get_simulation_results(workbook_name, output_names)
169+
return {"results": results, "count": len(results)}
165170

166171

167172
@mcp.tool(
@@ -204,12 +209,12 @@ def get_sensitivity_ranking(
204209
)
205210
def find_hard_coded_inputs(
206211
workbook_name: str,
207-
) -> list[dict[str, str]]:
212+
) -> dict[str, Any]:
208213
refs = get_bridge().find_hard_coded_inputs(workbook_name)
209-
# Return as plain dicts so the MCP JSON serialization is obvious.
210-
return [
214+
candidates = [
211215
{"workbook": r.workbook, "sheet": r.sheet, "cell": r.cell} for r in refs
212216
]
217+
return {"candidates": candidates, "count": len(candidates)}
213218

214219

215220
@mcp.tool(
@@ -226,8 +231,9 @@ def list_vmrs_variables(
226231
str | None,
227232
Field(description="Workbook name. Omit for the active workbook."),
228233
] = None,
229-
) -> list[dict[str, str | int]]:
230-
return get_bridge().list_vmrs_variables(workbook_name)
234+
) -> dict[str, Any]:
235+
variables = get_bridge().list_vmrs_variables(workbook_name)
236+
return {"variables": variables, "count": len(variables)}
231237

232238

233239
@mcp.tool(
@@ -303,10 +309,11 @@ def read_vmrs(
303309
str, Field(description="Absolute path to a .vmrs file.")
304310
],
305311
output_names: list[str] | None = None,
306-
) -> list[SimulationResult]:
312+
) -> dict[str, Any]:
307313
reader = get_bridge().results
308314
reader.set_active_vmrs(path)
309-
return reader.get_simulation_results(None, output_names)
315+
results = reader.get_simulation_results(None, output_names)
316+
return {"results": results, "count": len(results)}
310317

311318

312319
__all__ = [

0 commit comments

Comments
 (0)