modelchoice-mcp 0.0.1__tar.gz

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,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions: {}
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.11", "3.12"]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v5
21
+ with:
22
+ enable-cache: true
23
+ - name: Set up Python ${{ matrix.python-version }}
24
+ run: uv python install ${{ matrix.python-version }}
25
+ - name: Sync (with dev extras)
26
+ run: uv sync --extra dev
27
+ - name: Ruff
28
+ run: uv run ruff check src tests
29
+ - name: Mypy
30
+ run: uv run mypy src
31
+ - name: Pytest
32
+ run: uv run pytest -q
@@ -0,0 +1,59 @@
1
+ # Release pipeline. On a version tag (v*), build the wheel + sdist,
2
+ # publish to PyPI via trusted publishing, and cut a GitHub release.
3
+ #
4
+ # One-time setup before the first tag:
5
+ # 1. Create the PyPI project's trusted publisher
6
+ # (https://pypi.org/manage/account/publishing/) pointing at this
7
+ # repo + this workflow under the environment name "pypi". No tokens.
8
+ name: Release
9
+
10
+ on:
11
+ push:
12
+ tags:
13
+ - "v*"
14
+
15
+ permissions: {}
16
+
17
+ jobs:
18
+ build:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5
24
+ - name: Build sdist + wheel
25
+ run: uv build
26
+ - uses: actions/upload-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish-pypi:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ environment: pypi
35
+ permissions:
36
+ id-token: write # PyPI trusted publishing
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ - name: Publish to PyPI
43
+ uses: pypa/gh-action-pypi-publish@release/v1
44
+
45
+ github-release:
46
+ needs: publish-pypi
47
+ runs-on: ubuntu-latest
48
+ permissions:
49
+ contents: write
50
+ steps:
51
+ - uses: actions/download-artifact@v4
52
+ with:
53
+ name: dist
54
+ path: dist/
55
+ - name: Create GitHub release
56
+ uses: softprops/action-gh-release@v2
57
+ with:
58
+ files: dist/*
59
+ generate_release_notes: true
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .uv/
5
+ uv.lock
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ ~$*.xlsx
13
+ *.tmp
@@ -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,48 @@
1
+ # ModelChoice MCP
2
+
3
+ **An open Model Context Protocol server for [Vose Software's ModelChoice](https://www.vosesoftware.com) — the decision-tree add-in for Excel.**
4
+
5
+ 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.
6
+
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
+ | `verify_rollback` | Cross-check our rollback against the `MC_V_<id>` cells ModelChoice itself wrote — a correctness guarantee when the tree has been rendered. |
17
+
18
+ Run it: `uv run python -m modelchoice_mcp` (stdio), or wire `modelchoice-mcp` into Claude Desktop like any MCP server.
19
+
20
+ ## How it works
21
+
22
+ 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:
23
+
24
+ - **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.
25
+ - **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.
26
+
27
+ 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.
28
+
29
+ ## Roadmap
30
+
31
+ - **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.
32
+ - **Phase 1 (done):** the MCP server — `list_trees`, `get_tree`, `roll_up`, proven live against a real workbook.
33
+ - **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.)*
34
+ - **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`.
35
+ - **Phase 3:** build/edit trees — write the model JSON into `_MC_Store` and trigger a re-render.
36
+
37
+ ## Architecture
38
+
39
+ 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.
40
+
41
+ ## Development
42
+
43
+ ```powershell
44
+ uv sync --extra dev
45
+ uv run pytest # parser/roller + store-format tests (no Excel needed)
46
+ ```
47
+
48
+ MIT licensed.
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "modelchoice-mcp"
3
+ version = "0.0.1"
4
+ description = "An open Model Context Protocol server for Vose Software's ModelChoice decision-tree add-in for Excel."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Vose Software" }]
9
+ dependencies = [
10
+ "mcp>=1.2.0",
11
+ "pydantic>=2.0",
12
+ "xlwings>=0.30",
13
+ ]
14
+
15
+ [project.scripts]
16
+ modelchoice-mcp = "modelchoice_mcp.__main__:main"
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ "pytest-asyncio>=0.23",
22
+ "ruff>=0.6",
23
+ "mypy>=1.10",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/modelchoice_mcp"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ asyncio_mode = "auto"
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ src = ["src", "tests"]
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "UP", "B", "N", "RUF"]
43
+
44
+ [tool.mypy]
45
+ python_version = "3.11"
46
+ strict = true
47
+ files = ["src"]
@@ -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)