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.
- modelchoice_mcp/__init__.py +4 -0
- modelchoice_mcp/__main__.py +40 -0
- modelchoice_mcp/bridge.py +135 -0
- modelchoice_mcp/schemas.py +95 -0
- modelchoice_mcp/server.py +29 -0
- modelchoice_mcp/store.py +69 -0
- modelchoice_mcp/tools.py +255 -0
- modelchoice_mcp/tree.py +227 -0
- modelchoice_mcp-0.0.1.dist-info/METADATA +65 -0
- modelchoice_mcp-0.0.1.dist-info/RECORD +12 -0
- modelchoice_mcp-0.0.1.dist-info/WHEEL +4 -0
- modelchoice_mcp-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -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"]
|
modelchoice_mcp/store.py
ADDED
|
@@ -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)
|
modelchoice_mcp/tools.py
ADDED
|
@@ -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
|
+
]
|
modelchoice_mcp/tree.py
ADDED
|
@@ -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,,
|