Skip to content

Commit 6d5092e

Browse files
vosesoftclaude
andcommitted
Add generic run_analysis + read_sheet — drive all headless ModelChoice analyses
Discovery: ModelChoice already ships headless MC_*_Auto ExcelCommands for most analyses (MC_Robustness_Auto, MC_SensitivityAnalysis_Auto, MC_ExportStrategyTable_Auto, MC_PolicySuggestion_Auto, MC_DecisionBrief_Auto, MC_McdaReport_Auto, MC_RiskProfile_Auto). The earlier "UI-only" assessment was stale. So Phase 2 is mostly an MCP-wiring exercise, not C# work — the one real C# gap was a standalone EVPI command (added: MC_EVPI_Auto). - run_analysis(analysis): maps a friendly name to the MC_*_Auto command, drives it via Application.Run, and reports the result sheets created. - read_sheet(sheet): reads a result sheet's used range back as rows. Together these cover the whole analysis surface without per-sheet parsing. - bridge.run_analysis / read_sheet; AnalysisRun + SheetData schemas. Richer per-analysis result parsing (e.g. robustness verdict/distance) and live verification (needs the add-in loaded with a tree) follow. 27 tests; ruff + mypy clean. 0.0.3 (not released — waits on MC_EVPI_Auto deploy). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7b3f408 commit 6d5092e

8 files changed

Lines changed: 199 additions & 5 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w
1414
| `get_tree` | Full structure of one tree — decision / chance / terminal nodes, branches, probabilities, values. |
1515
| `roll_up` | Roll the tree back to its expected values and **optimal policy** — the decision recommendation, in plain English. |
1616
| `verify_rollback` | Cross-check our rollback against the `MC_V_<id>` cells ModelChoice itself wrote — a correctness guarantee when the tree has been rendered. |
17-
| `run_evpi` | Expected Value of Perfect Information for the active tree — the most you'd pay for perfect information before deciding. Drives ModelChoice's headless `MC_EVPI_Auto`. *(Needs a ModelChoice build that includes that command — Phase 2.)* |
17+
| `run_evpi` | Expected Value of Perfect Information for the active tree — the most you'd pay for perfect information before deciding. Drives ModelChoice's headless `MC_EVPI_Auto`. |
18+
| `run_analysis` | Run any decision-analysis (`robustness`, `sensitivity`, `strategy_table`, `policy_suggestion`, `decision_brief`, `mcda_report`, `risk_profile`, `evpi`) and report the result sheets it produced. |
19+
| `read_sheet` | Read a result sheet's cells back (numbers / text), e.g. the robustness verdict or a sensitivity report. |
1820

1921
Run it: `uv run python -m modelchoice_mcp` (stdio), or wire `modelchoice-mcp` into Claude Desktop like any MCP server.
2022

@@ -32,7 +34,7 @@ The Python roller is validated against ModelChoice's own authoritative C# test (
3234
- **Phase 0 (done):** the read + rollback engine — parse `_MC_Store`, reconstruct each tree, roll back to EV + optimal policy. Validated against ModelChoice's own C# rollback test.
3335
- **Phase 1 (done):** the MCP server — `list_trees`, `get_tree`, `roll_up`, proven live against a real workbook.
3436
- **Phase 1b (done):** `verify_rollback` cross-checks our EVs against the `MC_V_<id>` named ranges; CI (ruff + mypy + pytest) and a tag-driven PyPI release pipeline; wheel + sdist build. *(First publish awaits a PyPI trusted-publisher; the live `MC_V_` cross-check awaits a real rendered tree.)*
35-
- **Phase 2 (in progress):** drive analyses. ModelChoice's analyses are UI/modal-dialog only; the clean path — since we own the source — is to add headless `[ExcelCommand]` entry points mirroring `MC_RiskProfile_Auto`, then invoke + read them via `Application.Run`. **EVPI** is the first vertical slice: `MC_EVPI_Auto` (ModelChoice, AB#2558) + the `run_evpi` tool here. Sensitivity, robustness, strategy table, and EVII follow.
37+
- **Phase 2 (in progress):** drive analyses. ModelChoice already ships headless `MC_*_Auto` ExcelCommands for most analyses (robustness, sensitivity, strategy table, policy, decision brief, MCDA, risk profile); `run_analysis` + `read_sheet` drive and read any of them. EVPI had no standalone command, so `MC_EVPI_Auto` was added (ModelChoice, AB#2558) with the `run_evpi` tool. Live verification + richer per-analysis result parsing are the remaining work.
3638
- **Phase 3:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
3739

3840
## Architecture

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "modelchoice-mcp"
3-
version = "0.0.2"
3+
version = "0.0.3"
44
description = "An open Model Context Protocol server for Vose Software's ModelChoice decision-tree add-in for Excel."
55
readme = "README.md"
66
requires-python = ">=3.11"

src/modelchoice_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""ModelChoice MCP — an open Model Context Protocol server for Vose
22
Software's ModelChoice decision-tree add-in for Excel."""
33

4-
__version__ = "0.0.2"
4+
__version__ = "0.0.3"

src/modelchoice_mcp/bridge.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,53 @@ def _num(cell: str) -> float | None:
174174
"evpi": _num("C7"),
175175
"value_with_perfect_info": _num("C8"),
176176
}
177+
178+
def run_analysis(self, command_name: str, workbook: str | None = None) -> dict[str, Any]:
179+
"""Drive a ModelChoice headless analysis command (an ``MC_*_Auto``
180+
ExcelCommand) via ``Application.Run`` and report which result
181+
sheets it produced. Activates the workbook first; raises
182+
``ModelChoiceNotFoundError`` if the command can't run (add-in not
183+
loaded, or no active tree)."""
184+
book = self._book(workbook)
185+
try:
186+
book.activate()
187+
except Exception:
188+
pass
189+
before = {s.name for s in book.sheets}
190+
try:
191+
book.app.api.Run(command_name)
192+
except Exception as exc:
193+
raise ModelChoiceNotFoundError(
194+
f"{command_name} could not run — is the ModelChoice add-in loaded "
195+
"in Excel, with a decision tree open?"
196+
) from exc
197+
after = [s.name for s in book.sheets]
198+
new = [n for n in after if n not in before]
199+
return {"command": command_name, "new_sheets": new, "sheets": after}
200+
201+
def read_sheet(
202+
self,
203+
sheet_name: str,
204+
workbook: str | None = None,
205+
max_rows: int = 200,
206+
max_cols: int = 20,
207+
) -> list[list[Any]]:
208+
"""Read a result sheet's used range (capped) as a list of rows."""
209+
book = self._book(workbook)
210+
try:
211+
sheet = book.sheets[sheet_name]
212+
except Exception as exc:
213+
raise ModelChoiceNotFoundError(
214+
f"No sheet named {sheet_name!r} in the workbook."
215+
) from exc
216+
used = sheet.used_range
217+
nrows = min(int(used.rows.count), max_rows)
218+
ncols = min(int(used.columns.count), max_cols)
219+
if nrows == 0 or ncols == 0:
220+
return []
221+
raw = sheet.range((1, 1), (nrows, ncols)).value
222+
if not isinstance(raw, list):
223+
return [[raw]]
224+
if raw and not isinstance(raw[0], list):
225+
return [raw] if ncols == 1 else [[v] for v in raw]
226+
return [list(row) for row in raw]

src/modelchoice_mcp/schemas.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from typing import Any
6+
57
from pydantic import BaseModel, Field
68

79

@@ -78,6 +80,27 @@ class NodeDiff(BaseModel):
7880
diff: float = Field(description="computed - cell.")
7981

8082

83+
class AnalysisRun(BaseModel):
84+
"""Result of driving a ModelChoice headless analysis command."""
85+
86+
analysis: str = Field(description="Friendly analysis name requested.")
87+
command: str = Field(description="The MC_*_Auto ExcelCommand that was run.")
88+
new_sheets: list[str] = Field(
89+
description="Sheets the analysis created (read them with read_sheet)."
90+
)
91+
sheets: list[str] = Field(description="All sheets in the workbook afterwards.")
92+
note: str
93+
94+
95+
class SheetData(BaseModel):
96+
"""A capped block of a worksheet's used range."""
97+
98+
sheet: str
99+
row_count: int
100+
rows: list[list[Any]] = Field(description="Cell values, row-major (numbers, text, or null).")
101+
truncated: bool = Field(description="True if the sheet had more rows/cols than returned.")
102+
103+
81104
class EvpiResult(BaseModel):
82105
"""Expected Value of Perfect Information for the active tree, produced
83106
by ModelChoice's headless MC_EVPI_Auto command."""

src/modelchoice_mcp/tools.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,34 @@
1313

1414
from modelchoice_mcp.bridge import ModelChoiceBridge
1515
from modelchoice_mcp.schemas import (
16+
AnalysisRun,
1617
BranchView,
1718
EvpiResult,
1819
NodeDiff,
1920
NodeResultView,
2021
NodeView,
2122
RollbackVerification,
2223
RollupResponse,
24+
SheetData,
2325
TreeList,
2426
TreeStructure,
2527
TreeSummary,
2628
)
2729
from modelchoice_mcp.server import mcp
2830
from modelchoice_mcp.tree import DecisionTree, parse_model, rollup
2931

32+
# Friendly analysis name -> ModelChoice headless ExcelCommand.
33+
_ANALYSES: dict[str, str] = {
34+
"risk_profile": "MC_RiskProfile_Auto",
35+
"robustness": "MC_Robustness_Auto",
36+
"sensitivity": "MC_SensitivityAnalysis_Auto",
37+
"strategy_table": "MC_ExportStrategyTable_Auto",
38+
"policy_suggestion": "MC_PolicySuggestion_Auto",
39+
"decision_brief": "MC_DecisionBrief_Auto",
40+
"mcda_report": "MC_McdaReport_Auto",
41+
"evpi": "MC_EVPI_Auto",
42+
}
43+
3044
_bridge: ModelChoiceBridge | None = None
3145

3246

@@ -281,11 +295,79 @@ def run_evpi(workbook_name: str | None = None) -> EvpiResult:
281295
)
282296

283297

298+
@mcp.tool(
299+
description=(
300+
"ModelChoice: Run a decision-analysis on the active tree and report the "
301+
"result sheets it produced. `analysis` is one of: 'risk_profile', "
302+
"'robustness' (how much inputs must change to flip the decision), "
303+
"'sensitivity', 'strategy_table', 'policy_suggestion', 'decision_brief', "
304+
"'mcda_report', 'evpi'. Drives ModelChoice's headless command and lists "
305+
"the new sheets — read them with read_sheet. Requires the ModelChoice "
306+
"add-in loaded in Excel with a tree open."
307+
)
308+
)
309+
def run_analysis(
310+
analysis: Annotated[
311+
str, Field(description="One of: " + ", ".join(sorted(_ANALYSES)))
312+
],
313+
workbook_name: str | None = None,
314+
) -> AnalysisRun:
315+
key = analysis.lower()
316+
if key not in _ANALYSES:
317+
raise ValueError(
318+
f"Unknown analysis {analysis!r}. Choose from: {', '.join(sorted(_ANALYSES))}."
319+
)
320+
command = _ANALYSES[key]
321+
r = get_bridge().run_analysis(command, workbook_name)
322+
new = r.get("new_sheets", [])
323+
note = (
324+
f"{analysis} wrote: {', '.join(new)} — read with read_sheet."
325+
if new
326+
else f"{analysis} ran but added no new sheet; it may have updated an existing one."
327+
)
328+
return AnalysisRun(
329+
analysis=key,
330+
command=command,
331+
new_sheets=new,
332+
sheets=r.get("sheets", []),
333+
note=note,
334+
)
335+
336+
337+
@mcp.tool(
338+
description=(
339+
"ModelChoice: Read a worksheet's used range (capped) as rows of cell "
340+
"values — for pulling back the result sheet an analysis produced (e.g. "
341+
"'MC_EVPI', 'MC_RB_Verdict', a sensitivity report). Returns numbers, "
342+
"text, or null per cell."
343+
)
344+
)
345+
def read_sheet(
346+
sheet_name: Annotated[str, Field(description="Worksheet name to read.")],
347+
workbook_name: str | None = None,
348+
max_rows: Annotated[
349+
int, Field(ge=1, le=2000, description="Max rows to return (default 200).")
350+
] = 200,
351+
max_cols: Annotated[
352+
int, Field(ge=1, le=100, description="Max columns to return (default 20).")
353+
] = 20,
354+
) -> SheetData:
355+
rows = get_bridge().read_sheet(sheet_name, workbook_name, max_rows=max_rows, max_cols=max_cols)
356+
return SheetData(
357+
sheet=sheet_name,
358+
row_count=len(rows),
359+
rows=rows,
360+
truncated=len(rows) >= max_rows,
361+
)
362+
363+
284364
__all__ = [
285365
"get_bridge",
286366
"get_tree",
287367
"list_trees",
368+
"read_sheet",
288369
"roll_up",
370+
"run_analysis",
289371
"run_evpi",
290372
"set_bridge_for_testing",
291373
"verify_rollback",

tests/test_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
def test_version_set() -> None:
10-
assert __version__ == "0.0.2"
10+
assert __version__ == "0.0.3"
1111

1212

1313
def test_server_name() -> None:

tests/test_tools.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ def run_evpi(self, workbook: str | None = None) -> dict[str, object]:
5656
"value_with_perfect_info": 75.0,
5757
}
5858

59+
def run_analysis(self, command_name: str, workbook: str | None = None) -> dict[str, object]:
60+
self.last_command = command_name
61+
return {
62+
"command": command_name,
63+
"new_sheets": ["MC_RB_Verdict"],
64+
"sheets": ["MC_Tree_1", "MC_RB_Verdict"],
65+
}
66+
67+
def read_sheet(self, sheet_name: str, workbook: str | None = None,
68+
max_rows: int = 200, max_cols: int = 20) -> list[list[object]]:
69+
return [["Verdict", "Robust"], ["Distance", 0.42]]
70+
5971

6072
@pytest.fixture
6173
def bridge() -> Iterator[_FakeBridge]:
@@ -149,3 +161,28 @@ def test_run_evpi(bridge: _FakeBridge) -> None:
149161
assert out.evpi == 25.0 and out.optimal_ev == 50.0
150162
assert out.value_with_perfect_info == 75.0
151163
assert "25" in out.interpretation
164+
165+
166+
def test_run_analysis_maps_friendly_name(bridge: _FakeBridge) -> None:
167+
out = tools.run_analysis("robustness")
168+
assert out.command == "MC_Robustness_Auto"
169+
assert out.new_sheets == ["MC_RB_Verdict"]
170+
assert "read_sheet" in out.note
171+
assert bridge.last_command == "MC_Robustness_Auto"
172+
173+
174+
def test_run_analysis_unknown_raises(bridge: _FakeBridge) -> None:
175+
with pytest.raises(ValueError):
176+
tools.run_analysis("teleport")
177+
178+
179+
def test_run_analysis_covers_all_known(bridge: _FakeBridge) -> None:
180+
for name in ("risk_profile", "sensitivity", "strategy_table", "evpi"):
181+
assert tools.run_analysis(name).command.startswith("MC_")
182+
183+
184+
def test_read_sheet(bridge: _FakeBridge) -> None:
185+
out = tools.read_sheet("MC_RB_Verdict")
186+
assert out.sheet == "MC_RB_Verdict" and out.row_count == 2
187+
assert out.rows[1] == ["Distance", 0.42]
188+
assert out.truncated is False

0 commit comments

Comments
 (0)