Skip to content

Commit 1b13280

Browse files
vosesoftclaude
andcommitted
Phase 1: wire the read-only MCP server (list_trees, get_tree, roll_up)
Builds the FastMCP server on top of the Phase 0 read/rollback engine: - server.py: FastMCP("modelchoice-mcp") + tool registration. - schemas.py: pydantic response models (TreeList/TreeStructure/RollupResponse). - tools.py: list_trees (summaries + node counts), get_tree (full structure), roll_up (EV + optimal policy + plain-English recommendation), with a get_bridge()/set_bridge_for_testing() seam. - __main__.py: stdio (default) / streamable-http / sse entrypoint; console-script `modelchoice-mcp`. Proven live end to end against a real workbook: list_trees -> get_tree -> roll_up returns "Optimal decision (maximize EV): take Sell. Expected value 50.00." 19 unit tests (incl. server boot + tool registration); ruff + mypy(strict) clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2073353 commit 1b13280

8 files changed

Lines changed: 442 additions & 4 deletions

File tree

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@
44

55
A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): where that server brings Monte Carlo risk modelling into a conversation, this one brings **decision analysis** — building, reading, rolling back, and analysing decision trees in Excel.
66

7-
> **Status: `0.0.1` — Phase 0 (read-only) spike.** The core read path is proven end to end. Building/analysis tools are on the roadmap below.
7+
> **Status: `0.0.1` — Phase 1 MCP server (read-only).** Three tools (`list_trees`, `get_tree`, `roll_up`) over a proven read path. Building/analysis tools are on the roadmap below.
8+
9+
## Tools
10+
11+
| Tool | What it does |
12+
|---|---|
13+
| `list_trees` | List the decision trees in a workbook with node-type counts. |
14+
| `get_tree` | Full structure of one tree — decision / chance / terminal nodes, branches, probabilities, values. |
15+
| `roll_up` | Roll the tree back to its expected values and **optimal policy** — the decision recommendation, in plain English. |
16+
17+
Run it: `uv run python -m modelchoice_mcp` (stdio), or wire `modelchoice-mcp` into Claude Desktop like any MCP server.
818

919
## How it works
1020

@@ -17,9 +27,11 @@ The Python roller is validated against ModelChoice's own authoritative C# test (
1727

1828
## Roadmap
1929

20-
- **Phase 0 (done):** read-only — list trees, read structure, roll back to EV + optimal policy, cross-check against the `MC_V_<id>` named ranges.
21-
- **Phase 1:** drive analyses. ModelChoice's analyses (sensitivity, EVPI, EVII, strategy table, robustness) are currently UI/modal-dialog only; the clean path — since we own the source — is to add headless `[ExcelCommand]` entry points mirroring the existing `MC_RiskProfile_Auto`, then invoke + read them via `Application.Run`.
22-
- **Phase 2:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
30+
- **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.
31+
- **Phase 1 (done):** the MCP server — `list_trees`, `get_tree`, `roll_up`, proven live against a real workbook.
32+
- **Phase 1b:** cross-check rolled-back EVs against the `MC_V_<id>` named ranges; packaging + CI + PyPI; Claude Desktop wiring docs.
33+
- **Phase 2:** drive analyses. ModelChoice's analyses (sensitivity, EVPI, EVII, strategy table, robustness) are currently UI/modal-dialog only; the clean path — since we own the source — is to add headless `[ExcelCommand]` entry points mirroring the existing `MC_RiskProfile_Auto`, then invoke + read them via `Application.Run`.
34+
- **Phase 3:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
2335

2436
## Architecture
2537

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ dependencies = [
1212
"xlwings>=0.30",
1313
]
1414

15+
[project.scripts]
16+
modelchoice-mcp = "modelchoice_mcp.__main__:main"
17+
1518
[project.optional-dependencies]
1619
dev = [
1720
"pytest>=8.0",

src/modelchoice_mcp/__main__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""ModelChoice MCP server entrypoint.
2+
3+
python -m modelchoice_mcp # stdio (default)
4+
python -m modelchoice_mcp --transport=streamable-http # HTTP, 127.0.0.1:8000
5+
python -m modelchoice_mcp --transport=sse --port=9000
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
from typing import Literal, cast
12+
13+
from modelchoice_mcp.server import mcp
14+
15+
Transport = Literal["stdio", "streamable-http", "sse"]
16+
17+
18+
def main() -> None:
19+
parser = argparse.ArgumentParser(prog="modelchoice-mcp")
20+
parser.add_argument(
21+
"--transport",
22+
choices=["stdio", "streamable-http", "sse"],
23+
default="stdio",
24+
help="MCP transport (default: stdio).",
25+
)
26+
parser.add_argument("--host", default="127.0.0.1")
27+
parser.add_argument("--port", type=int, default=8000)
28+
args = parser.parse_args()
29+
30+
transport = cast(Transport, args.transport)
31+
if transport == "stdio":
32+
mcp.run(transport="stdio")
33+
else:
34+
mcp.settings.host = args.host
35+
mcp.settings.port = args.port
36+
mcp.run(transport=transport)
37+
38+
39+
if __name__ == "__main__":
40+
main()

src/modelchoice_mcp/schemas.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Pydantic response schemas for the MCP tools."""
2+
3+
from __future__ import annotations
4+
5+
from pydantic import BaseModel, Field
6+
7+
8+
class BranchView(BaseModel):
9+
name: str
10+
child_id: str
11+
value: float = Field(description="Branch/option value added along the path.")
12+
probability: float | None = Field(
13+
default=None, description="Probability (chance branches only; null for decision options)."
14+
)
15+
16+
17+
class NodeView(BaseModel):
18+
id: str
19+
name: str
20+
kind: str = Field(description="'decision', 'chance', or 'terminal'.")
21+
value: float = Field(default=0.0, description="Terminal node's own value (0 otherwise).")
22+
branches: list[BranchView] = Field(default_factory=list)
23+
24+
25+
class TreeSummary(BaseModel):
26+
name: str = Field(description="Tree sheet name (e.g. 'MC_Tree_1').")
27+
model_name: str
28+
root_id: str
29+
root_name: str
30+
node_count: int
31+
decision_count: int
32+
chance_count: int
33+
terminal_count: int
34+
35+
36+
class TreeList(BaseModel):
37+
workbook: str | None = None
38+
count: int
39+
trees: list[TreeSummary]
40+
41+
42+
class TreeStructure(BaseModel):
43+
name: str
44+
model_name: str
45+
root_id: str
46+
maximize: bool = Field(description="True = the tree maximizes EV; False = minimizes.")
47+
node_count: int
48+
nodes: list[NodeView]
49+
50+
51+
class NodeResultView(BaseModel):
52+
id: str
53+
name: str
54+
kind: str
55+
expected_value: float
56+
optimal_branch_name: str | None = Field(
57+
default=None, description="At a decision node, the option chosen by rollback."
58+
)
59+
60+
61+
class RollupResponse(BaseModel):
62+
name: str
63+
model_name: str
64+
maximize: bool
65+
expected_value: float = Field(description="Rolled-back expected value at the root.")
66+
optimal_path: list[str] = Field(
67+
description="The decisions taken under the optimal policy, from the root."
68+
)
69+
recommendation: str = Field(description="Plain-English decision recommendation.")
70+
nodes: list[NodeResultView]

src/modelchoice_mcp/server.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""FastMCP server entrypoint.
2+
3+
Constructs the ``mcp`` instance and triggers tool registration by
4+
importing ``modelchoice_mcp.tools`` (the tools attach via the
5+
``@mcp.tool`` decorator side-effect). Read-only in Phase 0/1.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from mcp.server.fastmcp import FastMCP
11+
12+
from modelchoice_mcp import __version__
13+
14+
mcp = FastMCP(
15+
name="modelchoice-mcp",
16+
instructions=(
17+
"ModelChoice MCP exposes Vose Software's ModelChoice decision-tree "
18+
"add-in for Excel. It reads decision trees stored in a workbook and "
19+
"rolls them back to expected values and the optimal policy — the "
20+
"decision recommendation — without needing the add-in loaded. Use "
21+
"list_trees to discover trees, get_tree for structure, and roll_up "
22+
"for the recommendation."
23+
),
24+
)
25+
26+
# Side-effect import registers every @mcp.tool. Must come after `mcp`.
27+
from modelchoice_mcp import tools # noqa: E402, F401
28+
29+
__all__ = ["__version__", "mcp"]

src/modelchoice_mcp/tools.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""MCP tools (Phase 1, read-only) over the ModelChoice bridge.
2+
3+
Each tool obtains a process-global :class:`ModelChoiceBridge` via
4+
``get_bridge()`` (lazy — the first call attaches to Excel). Tests inject
5+
a fake with ``set_bridge_for_testing`` to avoid touching COM.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Annotated
11+
12+
from pydantic import Field
13+
14+
from modelchoice_mcp.bridge import ModelChoiceBridge
15+
from modelchoice_mcp.schemas import (
16+
BranchView,
17+
NodeResultView,
18+
NodeView,
19+
RollupResponse,
20+
TreeList,
21+
TreeStructure,
22+
TreeSummary,
23+
)
24+
from modelchoice_mcp.server import mcp
25+
from modelchoice_mcp.tree import DecisionTree, parse_model, rollup
26+
27+
_bridge: ModelChoiceBridge | None = None
28+
29+
30+
def get_bridge() -> ModelChoiceBridge:
31+
global _bridge
32+
if _bridge is None:
33+
_bridge = ModelChoiceBridge()
34+
return _bridge
35+
36+
37+
def set_bridge_for_testing(bridge: ModelChoiceBridge | None) -> None:
38+
global _bridge
39+
_bridge = bridge
40+
41+
42+
def _counts(tree: DecisionTree) -> tuple[int, int, int]:
43+
d = sum(1 for n in tree.nodes.values() if n.kind == "decision")
44+
c = sum(1 for n in tree.nodes.values() if n.kind == "chance")
45+
t = sum(1 for n in tree.nodes.values() if n.kind == "terminal")
46+
return d, c, t
47+
48+
49+
@mcp.tool(
50+
description=(
51+
"ModelChoice: List the decision trees stored in a workbook, with a "
52+
"summary of each (model name, root node, and node-type counts). Trees "
53+
"live in the workbook's hidden ModelChoice store; the add-in does not "
54+
"need to be loaded. Omit workbook_name for the active workbook."
55+
)
56+
)
57+
def list_trees(workbook_name: str | None = None) -> TreeList:
58+
trees = get_bridge().list_trees(workbook_name)
59+
summaries: list[TreeSummary] = []
60+
for name, model_json in trees.items():
61+
t = parse_model(model_json)
62+
d, c, term = _counts(t)
63+
root = t.nodes[t.root_id]
64+
summaries.append(
65+
TreeSummary(
66+
name=name,
67+
model_name=t.model_name,
68+
root_id=t.root_id,
69+
root_name=root.name,
70+
node_count=len(t.nodes),
71+
decision_count=d,
72+
chance_count=c,
73+
terminal_count=term,
74+
)
75+
)
76+
return TreeList(workbook=workbook_name, count=len(summaries), trees=summaries)
77+
78+
79+
@mcp.tool(
80+
description=(
81+
"ModelChoice: Read the full structure of one decision tree — every "
82+
"node (decision / chance / terminal) with its branches, probabilities, "
83+
"and branch values. Pass tree_name to pick a specific tree, else the "
84+
"first/active one. Read-only."
85+
)
86+
)
87+
def get_tree(
88+
tree_name: Annotated[
89+
str | None, Field(description="Tree sheet name, e.g. 'MC_Tree_1'. Omit for the first tree.")
90+
] = None,
91+
workbook_name: str | None = None,
92+
) -> TreeStructure:
93+
bridge = get_bridge()
94+
trees = bridge.list_trees(workbook_name)
95+
if tree_name is None:
96+
tree_name = next(iter(trees))
97+
t = parse_model(trees[tree_name])
98+
nodes = [
99+
NodeView(
100+
id=n.id,
101+
name=n.name,
102+
kind=n.kind,
103+
value=n.value,
104+
branches=[
105+
BranchView(
106+
name=b.name, child_id=b.child_id, value=b.value, probability=b.probability
107+
)
108+
for b in n.branches
109+
],
110+
)
111+
for n in t.nodes.values()
112+
]
113+
return TreeStructure(
114+
name=tree_name,
115+
model_name=t.model_name,
116+
root_id=t.root_id,
117+
maximize=t.maximize,
118+
node_count=len(t.nodes),
119+
nodes=nodes,
120+
)
121+
122+
123+
@mcp.tool(
124+
description=(
125+
"ModelChoice: Roll a decision tree back to its expected values and "
126+
"OPTIMAL POLICY — the decision recommendation. Returns the root "
127+
"expected value, the optimal sequence of decisions, a plain-English "
128+
"recommendation, and the per-node expected values. Computed directly "
129+
"from the stored model (terminal payoff = accumulated branch values; "
130+
"chance = probability-weighted EV; decision = best EV by maximize/"
131+
"minimize) — reproduces ModelChoice's rollback without the add-in."
132+
)
133+
)
134+
def roll_up(
135+
tree_name: Annotated[
136+
str | None, Field(description="Tree sheet name. Omit for the first tree.")
137+
] = None,
138+
workbook_name: str | None = None,
139+
) -> RollupResponse:
140+
bridge = get_bridge()
141+
trees = bridge.list_trees(workbook_name)
142+
if tree_name is None:
143+
tree_name = next(iter(trees))
144+
t = parse_model(trees[tree_name])
145+
r = rollup(t)
146+
147+
direction = "maximize" if t.maximize else "minimize"
148+
if r.optimal_path:
149+
choices = " → ".join(r.optimal_path)
150+
rec = (
151+
f"Optimal decision ({direction} EV): take {choices}. "
152+
f"Expected value {r.expected_value:,.2f}."
153+
)
154+
else:
155+
rec = (
156+
f"No decision nodes to optimize; expected value {r.expected_value:,.2f} "
157+
f"({direction})."
158+
)
159+
160+
nodes = [
161+
NodeResultView(
162+
id=nr.id,
163+
name=nr.name,
164+
kind=nr.kind,
165+
expected_value=nr.expected_value,
166+
optimal_branch_name=nr.optimal_branch_name,
167+
)
168+
for nr in r.node_results.values()
169+
]
170+
return RollupResponse(
171+
name=tree_name,
172+
model_name=t.model_name,
173+
maximize=t.maximize,
174+
expected_value=r.expected_value,
175+
optimal_path=r.optimal_path,
176+
recommendation=rec,
177+
nodes=nodes,
178+
)
179+
180+
181+
__all__ = ["get_bridge", "get_tree", "list_trees", "roll_up", "set_bridge_for_testing"]

tests/test_server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Server boot + tool-registration smoke tests."""
2+
3+
from __future__ import annotations
4+
5+
from modelchoice_mcp import __version__
6+
from modelchoice_mcp.server import mcp
7+
8+
9+
def test_version_set() -> None:
10+
assert __version__ == "0.0.1"
11+
12+
13+
def test_server_name() -> None:
14+
assert mcp.name == "modelchoice-mcp"
15+
16+
17+
async def test_read_tools_registered() -> None:
18+
names = {t.name for t in await mcp.list_tools()}
19+
assert {"list_trees", "get_tree", "roll_up"} <= names
20+
21+
22+
async def test_tool_descriptions_have_brand_prefix() -> None:
23+
for t in await mcp.list_tools():
24+
assert (t.description or "").startswith("ModelChoice:")

0 commit comments

Comments
 (0)