modelchoice-mcp 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """ModelChoice MCP — an open Model Context Protocol server for Vose
2
+ Software's ModelChoice decision-tree add-in for Excel."""
3
+
4
+ __version__ = "0.0.1"
@@ -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()
@@ -0,0 +1,135 @@
1
+ """Excel bridge for ModelChoice — read decision trees from a workbook.
2
+
3
+ Phase 0 is read-only. ModelChoice exposes no COM object model, but the
4
+ full tree model is persisted as JSON in the very-hidden ``_MC_Store``
5
+ worksheet (see :mod:`modelchoice_mcp.store`). We read that sheet's row-1
6
+ content (chunked across columns) via xlwings, parse each tree, and roll
7
+ it back in pure Python — so the add-in need not even be loaded.
8
+
9
+ The bridge deliberately does NOT unhide or modify ``_MC_Store``; it
10
+ reads cell values only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from modelchoice_mcp.store import STORE_SHEET_NAME, parse_store, reassemble_chunks
18
+ from modelchoice_mcp.tree import DecisionTree, RollupResult, parse_model, rollup
19
+
20
+
21
+ class ModelChoiceNotFoundError(RuntimeError):
22
+ """The workbook contains no ModelChoice tree store (`_MC_Store`)."""
23
+
24
+
25
+ class ExcelNotRunningError(RuntimeError):
26
+ """No running Excel instance could be attached."""
27
+
28
+
29
+ class ModelChoiceBridge:
30
+ """Attach to a running Excel and read ModelChoice trees from a
31
+ workbook's very-hidden ``_MC_Store`` sheet."""
32
+
33
+ def __init__(self) -> None:
34
+ self._xw: Any = None
35
+
36
+ def _load_xw(self) -> Any:
37
+ if self._xw is None:
38
+ import xlwings # imported lazily so the package is importable without Excel
39
+
40
+ self._xw = xlwings
41
+ return self._xw
42
+
43
+ def _book(self, workbook: str | None) -> Any:
44
+ xw = self._load_xw()
45
+ app = xw.apps.active
46
+ if app is None:
47
+ raise ExcelNotRunningError(
48
+ "No running Excel instance found. Open the workbook in Excel first."
49
+ )
50
+ if workbook is None:
51
+ return app.books.active
52
+ for b in app.books:
53
+ if b.name == workbook:
54
+ return b
55
+ raise ModelChoiceNotFoundError(f"Workbook {workbook!r} is not open.")
56
+
57
+ def read_store_raw(self, workbook: str | None = None) -> str:
58
+ """Return the reassembled ``_MC_Store`` A1 payload, or '' if the
59
+ sheet is absent."""
60
+ book = self._book(workbook)
61
+ try:
62
+ sheet = book.sheets[STORE_SHEET_NAME]
63
+ except Exception:
64
+ return ""
65
+ # Read row 1 across enough columns to cover the chunk limit.
66
+ row = sheet.range((1, 1), (1, 100)).value
67
+ if not isinstance(row, list):
68
+ row = [row]
69
+ cells = [None if v is None else str(v) for v in row]
70
+ return reassemble_chunks(cells)
71
+
72
+ def list_trees(self, workbook: str | None = None) -> dict[str, str]:
73
+ """Return a mapping of tree-sheet-name → model JSON string."""
74
+ raw = self.read_store_raw(workbook)
75
+ if not raw:
76
+ raise ModelChoiceNotFoundError(
77
+ "Workbook has no ModelChoice tree store (_MC_Store sheet)."
78
+ )
79
+ return parse_store(raw)
80
+
81
+ def get_tree(self, sheet_name: str | None = None, workbook: str | None = None) -> DecisionTree:
82
+ """Parse one tree (by its sheet name, or the first/only tree)."""
83
+ trees = self.list_trees(workbook)
84
+ if not trees:
85
+ raise ModelChoiceNotFoundError("No trees stored in this workbook.")
86
+ if sheet_name is None:
87
+ sheet_name = next(iter(trees))
88
+ if sheet_name not in trees:
89
+ raise ModelChoiceNotFoundError(
90
+ f"Tree {sheet_name!r} not found. Available: {', '.join(trees)}."
91
+ )
92
+ return parse_model(trees[sheet_name])
93
+
94
+ def roll_up(self, sheet_name: str | None = None, workbook: str | None = None) -> RollupResult:
95
+ """Read a tree and return its rolled-back EV + optimal policy."""
96
+ return rollup(self.get_tree(sheet_name, workbook))
97
+
98
+ def read_node_values(self, workbook: str | None = None) -> dict[str, float]:
99
+ """Read the rolled-back expected values ModelChoice itself wrote
100
+ into the ``MC_V_<nodeId>`` named ranges on the rendered tree
101
+ sheet(s). Returns ``{nodeId: value}``. These are the add-in's own
102
+ rollback numbers — comparing them to our Python rollback proves
103
+ the two agree. Returns an empty dict if the tree hasn't been
104
+ rendered (named ranges only exist after a render)."""
105
+ book = self._book(workbook)
106
+ prefix = "MC_V_"
107
+ out: dict[str, float] = {}
108
+
109
+ name_objs: list[Any] = []
110
+ try:
111
+ name_objs.extend(list(book.names))
112
+ except Exception:
113
+ pass
114
+ for sht in book.sheets:
115
+ try:
116
+ name_objs.extend(list(sht.names))
117
+ except Exception:
118
+ pass
119
+
120
+ for nm in name_objs:
121
+ try:
122
+ full = str(nm.name) # may be "MC_V_D" or "Sheet!MC_V_D"
123
+ except Exception:
124
+ continue
125
+ idx = full.find(prefix)
126
+ if idx < 0:
127
+ continue
128
+ node_id = full[idx + len(prefix):]
129
+ try:
130
+ val = nm.refers_to_range.value
131
+ except Exception:
132
+ continue
133
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
134
+ out[node_id] = float(val)
135
+ return out
@@ -0,0 +1,95 @@
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]
71
+
72
+
73
+ class NodeDiff(BaseModel):
74
+ node_id: str
75
+ name: str
76
+ computed: float = Field(description="Our Python rollback EV for this node.")
77
+ cell: float = Field(description="The MC_V_<nodeId> value ModelChoice wrote.")
78
+ diff: float = Field(description="computed - cell.")
79
+
80
+
81
+ class RollbackVerification(BaseModel):
82
+ """Cross-check of our Python rollback against the EVs ModelChoice
83
+ itself wrote into the MC_V_<nodeId> named ranges."""
84
+
85
+ name: str
86
+ rendered: bool = Field(
87
+ description="True if the tree has MC_V_ cells (i.e. it has been rendered by the add-in)."
88
+ )
89
+ compared_count: int = Field(description="Nodes present in both our rollback and the cells.")
90
+ matches: int
91
+ max_abs_diff: float = Field(description="Largest |computed - cell| across compared nodes.")
92
+ mismatches: list[NodeDiff] = Field(
93
+ default_factory=list, description="Nodes whose values differ beyond the tolerance."
94
+ )
95
+ verdict: str
@@ -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"]
@@ -0,0 +1,69 @@
1
+ """Read ModelChoice's per-workbook tree storage.
2
+
3
+ ModelChoice persists every decision tree as JSON in a *very hidden*
4
+ worksheet named ``_MC_Store`` (see ``TreeJsonStore.cs`` in the add-in).
5
+ The payload lives in row 1, chunked across columns A1, B1, C1, … when it
6
+ exceeds Excel's ~32k-character cell limit. The reassembled string is a
7
+ v2 envelope::
8
+
9
+ {"_v": 2, "trees": {"<sheetName>": "<model-json-string>", ...}}
10
+
11
+ where each *value* is itself a JSON string (the serialized
12
+ ``DecisionTreeModel``). A v1 legacy form stored the raw model JSON
13
+ directly (detected by a top-level ``RootId``), keyed as ``MC_TreeView``.
14
+
15
+ This module is pure: it turns the raw cell string(s) into a mapping of
16
+ sheet-name → model-JSON-string. The Excel/COM reading lives in the
17
+ bridge so this stays unit-testable without Excel.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+
24
+ STORE_SHEET_NAME = "_MC_Store"
25
+ _LEGACY_TREE_KEY = "MC_TreeView"
26
+
27
+
28
+ def parse_store(raw: str) -> dict[str, str]:
29
+ """Parse the reassembled ``_MC_Store`` A1 payload into a mapping of
30
+ sheet name → model JSON string. Mirrors ``TreeJsonStore.ParseStorageFormat``.
31
+
32
+ Returns an empty dict for empty input.
33
+ """
34
+ if not raw or not raw.strip():
35
+ return {}
36
+
37
+ try:
38
+ root = json.loads(raw)
39
+ except json.JSONDecodeError:
40
+ # Not valid JSON at all — treat the whole thing as a single v1 model.
41
+ return {_LEGACY_TREE_KEY: raw}
42
+
43
+ if isinstance(root, dict):
44
+ # v2 envelope: {"_v": >=2, "trees": {...}}
45
+ v = root.get("_v")
46
+ trees = root.get("trees")
47
+ if isinstance(v, int) and v >= 2 and isinstance(trees, dict):
48
+ return {
49
+ k: (val if isinstance(val, str) else json.dumps(val))
50
+ for k, val in trees.items()
51
+ }
52
+
53
+ # v1: raw model JSON (has a RootId / rootId but no envelope).
54
+ if "RootId" in root or "rootId" in root:
55
+ return {_LEGACY_TREE_KEY: raw}
56
+
57
+ # Fallback: opaque content, treat as a single model.
58
+ return {_LEGACY_TREE_KEY: raw}
59
+
60
+
61
+ def reassemble_chunks(cells: list[str | None]) -> str:
62
+ """Reassemble the chunked A1, B1, C1, … payload. Stops at the first
63
+ empty cell. Mirrors ``TreeJsonStore.ReadChunkedContent``."""
64
+ parts: list[str] = []
65
+ for c in cells:
66
+ if c is None or c == "":
67
+ break
68
+ parts.append(c)
69
+ return "".join(parts)
@@ -0,0 +1,255 @@
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
+ NodeDiff,
18
+ NodeResultView,
19
+ NodeView,
20
+ RollbackVerification,
21
+ RollupResponse,
22
+ TreeList,
23
+ TreeStructure,
24
+ TreeSummary,
25
+ )
26
+ from modelchoice_mcp.server import mcp
27
+ from modelchoice_mcp.tree import DecisionTree, parse_model, rollup
28
+
29
+ _bridge: ModelChoiceBridge | None = None
30
+
31
+
32
+ def get_bridge() -> ModelChoiceBridge:
33
+ global _bridge
34
+ if _bridge is None:
35
+ _bridge = ModelChoiceBridge()
36
+ return _bridge
37
+
38
+
39
+ def set_bridge_for_testing(bridge: ModelChoiceBridge | None) -> None:
40
+ global _bridge
41
+ _bridge = bridge
42
+
43
+
44
+ def _counts(tree: DecisionTree) -> tuple[int, int, int]:
45
+ d = sum(1 for n in tree.nodes.values() if n.kind == "decision")
46
+ c = sum(1 for n in tree.nodes.values() if n.kind == "chance")
47
+ t = sum(1 for n in tree.nodes.values() if n.kind == "terminal")
48
+ return d, c, t
49
+
50
+
51
+ @mcp.tool(
52
+ description=(
53
+ "ModelChoice: List the decision trees stored in a workbook, with a "
54
+ "summary of each (model name, root node, and node-type counts). Trees "
55
+ "live in the workbook's hidden ModelChoice store; the add-in does not "
56
+ "need to be loaded. Omit workbook_name for the active workbook."
57
+ )
58
+ )
59
+ def list_trees(workbook_name: str | None = None) -> TreeList:
60
+ trees = get_bridge().list_trees(workbook_name)
61
+ summaries: list[TreeSummary] = []
62
+ for name, model_json in trees.items():
63
+ t = parse_model(model_json)
64
+ d, c, term = _counts(t)
65
+ root = t.nodes[t.root_id]
66
+ summaries.append(
67
+ TreeSummary(
68
+ name=name,
69
+ model_name=t.model_name,
70
+ root_id=t.root_id,
71
+ root_name=root.name,
72
+ node_count=len(t.nodes),
73
+ decision_count=d,
74
+ chance_count=c,
75
+ terminal_count=term,
76
+ )
77
+ )
78
+ return TreeList(workbook=workbook_name, count=len(summaries), trees=summaries)
79
+
80
+
81
+ @mcp.tool(
82
+ description=(
83
+ "ModelChoice: Read the full structure of one decision tree — every "
84
+ "node (decision / chance / terminal) with its branches, probabilities, "
85
+ "and branch values. Pass tree_name to pick a specific tree, else the "
86
+ "first/active one. Read-only."
87
+ )
88
+ )
89
+ def get_tree(
90
+ tree_name: Annotated[
91
+ str | None, Field(description="Tree sheet name, e.g. 'MC_Tree_1'. Omit for the first tree.")
92
+ ] = None,
93
+ workbook_name: str | None = None,
94
+ ) -> TreeStructure:
95
+ bridge = get_bridge()
96
+ trees = bridge.list_trees(workbook_name)
97
+ if tree_name is None:
98
+ tree_name = next(iter(trees))
99
+ t = parse_model(trees[tree_name])
100
+ nodes = [
101
+ NodeView(
102
+ id=n.id,
103
+ name=n.name,
104
+ kind=n.kind,
105
+ value=n.value,
106
+ branches=[
107
+ BranchView(
108
+ name=b.name, child_id=b.child_id, value=b.value, probability=b.probability
109
+ )
110
+ for b in n.branches
111
+ ],
112
+ )
113
+ for n in t.nodes.values()
114
+ ]
115
+ return TreeStructure(
116
+ name=tree_name,
117
+ model_name=t.model_name,
118
+ root_id=t.root_id,
119
+ maximize=t.maximize,
120
+ node_count=len(t.nodes),
121
+ nodes=nodes,
122
+ )
123
+
124
+
125
+ @mcp.tool(
126
+ description=(
127
+ "ModelChoice: Roll a decision tree back to its expected values and "
128
+ "OPTIMAL POLICY — the decision recommendation. Returns the root "
129
+ "expected value, the optimal sequence of decisions, a plain-English "
130
+ "recommendation, and the per-node expected values. Computed directly "
131
+ "from the stored model (terminal payoff = accumulated branch values; "
132
+ "chance = probability-weighted EV; decision = best EV by maximize/"
133
+ "minimize) — reproduces ModelChoice's rollback without the add-in."
134
+ )
135
+ )
136
+ def roll_up(
137
+ tree_name: Annotated[
138
+ str | None, Field(description="Tree sheet name. Omit for the first tree.")
139
+ ] = None,
140
+ workbook_name: str | None = None,
141
+ ) -> RollupResponse:
142
+ bridge = get_bridge()
143
+ trees = bridge.list_trees(workbook_name)
144
+ if tree_name is None:
145
+ tree_name = next(iter(trees))
146
+ t = parse_model(trees[tree_name])
147
+ r = rollup(t)
148
+
149
+ direction = "maximize" if t.maximize else "minimize"
150
+ if r.optimal_path:
151
+ choices = " → ".join(r.optimal_path)
152
+ rec = (
153
+ f"Optimal decision ({direction} EV): take {choices}. "
154
+ f"Expected value {r.expected_value:,.2f}."
155
+ )
156
+ else:
157
+ rec = (
158
+ f"No decision nodes to optimize; expected value {r.expected_value:,.2f} "
159
+ f"({direction})."
160
+ )
161
+
162
+ nodes = [
163
+ NodeResultView(
164
+ id=nr.id,
165
+ name=nr.name,
166
+ kind=nr.kind,
167
+ expected_value=nr.expected_value,
168
+ optimal_branch_name=nr.optimal_branch_name,
169
+ )
170
+ for nr in r.node_results.values()
171
+ ]
172
+ return RollupResponse(
173
+ name=tree_name,
174
+ model_name=t.model_name,
175
+ maximize=t.maximize,
176
+ expected_value=r.expected_value,
177
+ optimal_path=r.optimal_path,
178
+ recommendation=rec,
179
+ nodes=nodes,
180
+ )
181
+
182
+
183
+ @mcp.tool(
184
+ description=(
185
+ "ModelChoice: Cross-check our rollback against ModelChoice's own. The "
186
+ "add-in writes each node's rolled-back EV into an MC_V_<nodeId> named "
187
+ "range when it renders a tree; this compares those cells to our Python "
188
+ "rollback and reports any node that differs beyond `tolerance`. A clean "
189
+ "result is strong evidence the read+rollback is faithful. If the tree "
190
+ "hasn't been rendered (no MC_V_ cells), `rendered` is false — open the "
191
+ "tree in ModelChoice so it renders, then re-run."
192
+ )
193
+ )
194
+ def verify_rollback(
195
+ tree_name: Annotated[
196
+ str | None, Field(description="Tree sheet name. Omit for the first tree.")
197
+ ] = None,
198
+ workbook_name: str | None = None,
199
+ tolerance: Annotated[
200
+ float, Field(gt=0, description="Max allowed |computed - cell| difference. Default 0.01.")
201
+ ] = 0.01,
202
+ ) -> RollbackVerification:
203
+ bridge = get_bridge()
204
+ trees = bridge.list_trees(workbook_name)
205
+ if tree_name is None:
206
+ tree_name = next(iter(trees))
207
+ r = rollup(parse_model(trees[tree_name]))
208
+ cells = bridge.read_node_values(workbook_name)
209
+
210
+ common = [nid for nid in r.node_results if nid in cells]
211
+ mismatches: list[NodeDiff] = []
212
+ max_abs = 0.0
213
+ for nid in common:
214
+ computed = r.node_results[nid].expected_value
215
+ cell = cells[nid]
216
+ diff = computed - cell
217
+ max_abs = max(max_abs, abs(diff))
218
+ if abs(diff) > tolerance:
219
+ mismatches.append(
220
+ NodeDiff(
221
+ node_id=nid, name=r.node_results[nid].name,
222
+ computed=computed, cell=cell, diff=diff,
223
+ )
224
+ )
225
+
226
+ rendered = len(cells) > 0
227
+ matches = len(common) - len(mismatches)
228
+ if not rendered:
229
+ verdict = "Tree not rendered — no MC_V_ cells found. Open it in ModelChoice first."
230
+ elif not common:
231
+ verdict = "No overlapping node IDs between the model and the rendered cells."
232
+ elif not mismatches:
233
+ verdict = f"Rollback verified: all {matches} compared nodes match (max diff {max_abs:.2g})."
234
+ else:
235
+ verdict = f"{len(mismatches)} of {len(common)} nodes differ beyond {tolerance}."
236
+
237
+ return RollbackVerification(
238
+ name=tree_name,
239
+ rendered=rendered,
240
+ compared_count=len(common),
241
+ matches=matches,
242
+ max_abs_diff=max_abs,
243
+ mismatches=mismatches,
244
+ verdict=verdict,
245
+ )
246
+
247
+
248
+ __all__ = [
249
+ "get_bridge",
250
+ "get_tree",
251
+ "list_trees",
252
+ "roll_up",
253
+ "set_bridge_for_testing",
254
+ "verify_rollback",
255
+ ]
@@ -0,0 +1,227 @@
1
+ """Parse and roll back a ModelChoice decision tree from its model JSON.
2
+
3
+ The JSON schema is defined by ``DecisionTreeModel`` / the polymorphic
4
+ ``NodeDefinitionJsonConverter`` in ModelChoice.Core:
5
+
6
+ - Top level: ``{"RootId": str, "Nodes": {id: nodeObj}, "Settings": {...}}``
7
+ - Each node has a ``"type"`` discriminator: ``"terminal" | "chance" | "decision"``.
8
+ - terminal: ``{"value": float}``
9
+ - chance: ``{"branches": [{"name", "probability", "childId", "value"?}]}``
10
+ - decision: ``{"options": [{"name", "childId", "value"?}]}``
11
+ - ``Settings``: ``Maximize`` (true→max EV), ``ChanceProbabilities`` (AutoNormalize
12
+ by default), ``CalculationMethod`` (Cumulative by default).
13
+
14
+ Rollback mirrors ``DecisionTreeModel.Compile`` + ``TreeNode.Rollback``:
15
+ terminal payoff = accumulated branch values along the path (+ the
16
+ terminal's own value in classic/Cumulative mode); chance EV = the
17
+ (optionally normalized) probability-weighted child EVs; decision EV =
18
+ the max (or min) child EV, and the winning option is the optimal policy.
19
+ This reproduces ModelChoice's `MC_V_<id>` rollback values in pure Python
20
+ — no add-in required.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+
30
+ class TreeParseError(ValueError):
31
+ """The model JSON could not be parsed into a valid tree."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Branch:
36
+ name: str
37
+ child_id: str
38
+ value: float = 0.0
39
+ probability: float | None = None # None for decision options
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Node:
44
+ id: str
45
+ name: str
46
+ kind: str # "terminal" | "chance" | "decision"
47
+ value: float = 0.0 # terminal's own value
48
+ branches: list[Branch] = field(default_factory=list)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class DecisionTree:
53
+ root_id: str
54
+ nodes: dict[str, Node]
55
+ maximize: bool = True
56
+ auto_normalize: bool = True
57
+ cumulative: bool = True
58
+ model_name: str = "Untitled"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class NodeResult:
63
+ id: str
64
+ name: str
65
+ kind: str
66
+ expected_value: float
67
+ optimal_child_id: str | None = None # for decision nodes: the chosen option's child
68
+ optimal_branch_name: str | None = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class RollupResult:
73
+ root_id: str
74
+ expected_value: float
75
+ optimal_path: list[str] # branch/option names from root along the optimal policy
76
+ node_results: dict[str, NodeResult]
77
+
78
+
79
+ def _as_float(v: Any, default: float = 0.0) -> float:
80
+ try:
81
+ return float(v)
82
+ except (TypeError, ValueError):
83
+ return default
84
+
85
+
86
+ def parse_model(model_json: str) -> DecisionTree:
87
+ """Parse a serialized ``DecisionTreeModel`` JSON string into a
88
+ :class:`DecisionTree`. Property lookups are case-insensitive on the
89
+ top-level keys (C# serializes PascalCase; we tolerate either)."""
90
+ try:
91
+ raw = json.loads(model_json)
92
+ except json.JSONDecodeError as exc:
93
+ raise TreeParseError(f"model JSON is not valid JSON: {exc}") from exc
94
+ if not isinstance(raw, dict):
95
+ raise TreeParseError("model JSON root must be an object")
96
+
97
+ def get(d: dict[str, Any], *names: str, default: Any = None) -> Any:
98
+ lower = {k.lower(): v for k, v in d.items()}
99
+ for n in names:
100
+ if n.lower() in lower:
101
+ return lower[n.lower()]
102
+ return default
103
+
104
+ root_id = get(raw, "RootId")
105
+ nodes_raw = get(raw, "Nodes")
106
+ if not isinstance(root_id, str) or not isinstance(nodes_raw, dict):
107
+ raise TreeParseError("model JSON must have a string RootId and a Nodes object")
108
+
109
+ settings = get(raw, "Settings", default={}) or {}
110
+ maximize = bool(get(settings, "Maximize", default=True))
111
+ chance_mode = str(get(settings, "ChanceProbabilities", default="AutoNormalize"))
112
+ # enum may serialize as the name or its int (0=MustTotal100, 1=AutoNormalize)
113
+ auto_normalize = chance_mode.lower() in ("autonormalize", "1")
114
+ calc = str(get(settings, "CalculationMethod", default="Cumulative"))
115
+ cumulative = calc.lower() != "mcda"
116
+ model_name = str(get(settings, "ModelName", default="Untitled"))
117
+
118
+ nodes: dict[str, Node] = {}
119
+ for nid, n in nodes_raw.items():
120
+ if not isinstance(n, dict):
121
+ raise TreeParseError(f"node {nid!r} is not an object")
122
+ kind = str(n.get("type", "")).lower()
123
+ name = str(n.get("name", nid))
124
+ if kind == "terminal":
125
+ nodes[nid] = Node(id=nid, name=name, kind="terminal", value=_as_float(n.get("value")))
126
+ elif kind == "chance":
127
+ branches = [
128
+ Branch(
129
+ name=str(b.get("name", "")),
130
+ child_id=str(b.get("childId", "")),
131
+ value=_as_float(b.get("value")),
132
+ probability=_as_float(b.get("probability")),
133
+ )
134
+ for b in n.get("branches", [])
135
+ ]
136
+ nodes[nid] = Node(id=nid, name=name, kind="chance", branches=branches)
137
+ elif kind == "decision":
138
+ options = [
139
+ Branch(
140
+ name=str(o.get("name", "")),
141
+ child_id=str(o.get("childId", "")),
142
+ value=_as_float(o.get("value")),
143
+ probability=None,
144
+ )
145
+ for o in n.get("options", [])
146
+ ]
147
+ nodes[nid] = Node(id=nid, name=name, kind="decision", branches=options)
148
+ else:
149
+ raise TreeParseError(f"node {nid!r} has unsupported type {kind!r}")
150
+
151
+ if root_id not in nodes:
152
+ raise TreeParseError(f"RootId {root_id!r} is not present in Nodes")
153
+ return DecisionTree(
154
+ root_id=root_id,
155
+ nodes=nodes,
156
+ maximize=maximize,
157
+ auto_normalize=auto_normalize,
158
+ cumulative=cumulative,
159
+ model_name=model_name,
160
+ )
161
+
162
+
163
+ def rollup(tree: DecisionTree) -> RollupResult:
164
+ """Roll the tree back to expected values and the optimal policy.
165
+
166
+ Returns the root EV, the optimal path (the sequence of branch/option
167
+ names taken under the optimal policy, following the chosen option at
168
+ each decision and *all* branches at chance nodes is not a single
169
+ path — so the path lists decisions and, at the first chance node,
170
+ stops descending decisions only), and a per-node EV map.
171
+ """
172
+ node_results: dict[str, NodeResult] = {}
173
+
174
+ def ev(node_id: str, accumulated: float) -> float:
175
+ node = tree.nodes.get(node_id)
176
+ if node is None:
177
+ raise TreeParseError(f"missing child node {node_id!r}")
178
+
179
+ if node.kind == "terminal":
180
+ payoff = accumulated + (node.value if tree.cumulative else 0.0)
181
+ node_results[node_id] = NodeResult(node_id, node.name, "terminal", payoff)
182
+ return payoff
183
+
184
+ if node.kind == "chance":
185
+ total_p = sum((b.probability or 0.0) for b in node.branches)
186
+ normalize = tree.auto_normalize or abs(total_p - 1.0) > 1e-12
187
+ value = 0.0
188
+ for b in node.branches:
189
+ p = (b.probability or 0.0)
190
+ p = (p / total_p) if (normalize and total_p > 0) else p
191
+ value += p * ev(b.child_id, accumulated + b.value)
192
+ node_results[node_id] = NodeResult(node_id, node.name, "chance", value)
193
+ return value
194
+
195
+ # decision: pick the optimal option
196
+ best_ev: float | None = None
197
+ best: Branch | None = None
198
+ for o in node.branches:
199
+ child_ev = ev(o.child_id, accumulated + o.value)
200
+ if (
201
+ best_ev is None
202
+ or (tree.maximize and child_ev > best_ev)
203
+ or (not tree.maximize and child_ev < best_ev)
204
+ ):
205
+ best_ev, best = child_ev, o
206
+ if best is None or best_ev is None:
207
+ raise TreeParseError(f"decision node {node_id!r} has no options")
208
+ node_results[node_id] = NodeResult(
209
+ node_id, node.name, "decision", best_ev,
210
+ optimal_child_id=best.child_id, optimal_branch_name=best.name,
211
+ )
212
+ return best_ev
213
+
214
+ root_ev = ev(tree.root_id, 0.0)
215
+
216
+ # Build the optimal path: follow chosen options at decisions until a
217
+ # chance/terminal node ends the deterministic prefix.
218
+ path: list[str] = []
219
+ cur = tree.root_id
220
+ while True:
221
+ res = node_results.get(cur)
222
+ if res is None or res.kind != "decision" or res.optimal_child_id is None:
223
+ break
224
+ path.append(res.optimal_branch_name or "")
225
+ cur = res.optimal_child_id
226
+
227
+ return RollupResult(tree.root_id, root_ev, path, node_results)
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: modelchoice-mcp
3
+ Version: 0.0.1
4
+ Summary: An open Model Context Protocol server for Vose Software's ModelChoice decision-tree add-in for Excel.
5
+ Author: Vose Software
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: mcp>=1.2.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: xlwings>=0.30
11
+ Provides-Extra: dev
12
+ Requires-Dist: mypy>=1.10; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.6; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # ModelChoice MCP
19
+
20
+ **An open Model Context Protocol server for [Vose Software's ModelChoice](https://www.vosesoftware.com) — the decision-tree add-in for Excel.**
21
+
22
+ 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.
23
+
24
+ > **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.
25
+
26
+ ## Tools
27
+
28
+ | Tool | What it does |
29
+ |---|---|
30
+ | `list_trees` | List the decision trees in a workbook with node-type counts. |
31
+ | `get_tree` | Full structure of one tree — decision / chance / terminal nodes, branches, probabilities, values. |
32
+ | `roll_up` | Roll the tree back to its expected values and **optimal policy** — the decision recommendation, in plain English. |
33
+ | `verify_rollback` | Cross-check our rollback against the `MC_V_<id>` cells ModelChoice itself wrote — a correctness guarantee when the tree has been rendered. |
34
+
35
+ Run it: `uv run python -m modelchoice_mcp` (stdio), or wire `modelchoice-mcp` into Claude Desktop like any MCP server.
36
+
37
+ ## How it works
38
+
39
+ ModelChoice is a C# .NET (Excel-DNA) add-in. Unlike ModelRisk it exposes **no COM object model and no compute worksheet functions** — but it persists the *entire* decision tree as JSON in a very-hidden worksheet (`_MC_Store`), and the rollback logic is fully captured in that model. So this server can:
40
+
41
+ - **Read** every tree in a workbook by reassembling the `_MC_Store` payload (chunked across columns, v2-envelope or v1-legacy format) and parsing the model JSON.
42
+ - **Roll back** each tree to its expected values and **optimal policy** in pure Python — reproducing ModelChoice's own rollback (terminal payoff = accumulated branch values; chance = auto-normalized probability-weighted EV; decision = max/min by `Maximize`). **No add-in needs to be loaded** — it reads the saved model directly.
43
+
44
+ The Python roller is validated against ModelChoice's own authoritative C# test (`Model_Validate_Compile_Rollback_Works`): a decision/chance tree that rolls back to EV 50 with the optimal option selected — and matches exactly.
45
+
46
+ ## Roadmap
47
+
48
+ - **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.
49
+ - **Phase 1 (done):** the MCP server — `list_trees`, `get_tree`, `roll_up`, proven live against a real workbook.
50
+ - **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.)*
51
+ - **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`.
52
+ - **Phase 3:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
53
+
54
+ ## Architecture
55
+
56
+ A separate server that reuses the patterns proven in `modelrisk-mcp` (xlwings bridge, dry-run-by-default safety on any future writes, packaging, MCP-registry flow). Decision analysis and Monte Carlo are distinct domains, so they ship as distinct servers.
57
+
58
+ ## Development
59
+
60
+ ```powershell
61
+ uv sync --extra dev
62
+ uv run pytest # parser/roller + store-format tests (no Excel needed)
63
+ ```
64
+
65
+ MIT licensed.
@@ -0,0 +1,12 @@
1
+ modelchoice_mcp/__init__.py,sha256=jwiYZp-EYnQNMn9unwEzyjS_XWB_UzRAWlTYcH3lDVQ,151
2
+ modelchoice_mcp/__main__.py,sha256=LwnQdGyoVrH4YDA5VZt1sTDNROjBZm2yg1pWlRU7HEc,1125
3
+ modelchoice_mcp/bridge.py,sha256=01rbSiOqlglGU8DLd4hTX97aYn6o23YisYRS_6EzyVs,5155
4
+ modelchoice_mcp/schemas.py,sha256=VKkw5WzoNFShNW6frPOh6EaMIYlcAxz58aBAdfpM68U,2896
5
+ modelchoice_mcp/server.py,sha256=iA_UWtLpatxGtmHwWXDkT-YXG2u8W895FQ9ddEcamWs,995
6
+ modelchoice_mcp/store.py,sha256=9vjHLo2fxzTWN-TJLs6N08dXghei7Hk6CG3k7PXdUwQ,2391
7
+ modelchoice_mcp/tools.py,sha256=hLoqq31Ei9B5qlEoWvEJ9o6m1vljOjjCnVo53OmFXf8,8197
8
+ modelchoice_mcp/tree.py,sha256=x4aAPo__fLAb63JgjOrR__piDMpMFHxu-8ngmfkmOgw,8526
9
+ modelchoice_mcp-0.0.1.dist-info/METADATA,sha256=dXKlSLvIUOI7KVt-KgkK4gpBFP_Rsqfl_0gIKvhNM_E,4333
10
+ modelchoice_mcp-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ modelchoice_mcp-0.0.1.dist-info/entry_points.txt,sha256=e4Xtf5NiIewfKAfcPBoU9F6_h2ZgABDVgBVf5JWlzKY,66
12
+ modelchoice_mcp-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ modelchoice-mcp = modelchoice_mcp.__main__:main