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.
- modelchoice_mcp-0.0.1/.github/workflows/ci.yml +32 -0
- modelchoice_mcp-0.0.1/.github/workflows/release.yml +59 -0
- modelchoice_mcp-0.0.1/.gitignore +13 -0
- modelchoice_mcp-0.0.1/PKG-INFO +65 -0
- modelchoice_mcp-0.0.1/README.md +48 -0
- modelchoice_mcp-0.0.1/pyproject.toml +47 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/__init__.py +4 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/__main__.py +40 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/bridge.py +135 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/schemas.py +95 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/server.py +29 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/store.py +69 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/tools.py +255 -0
- modelchoice_mcp-0.0.1/src/modelchoice_mcp/tree.py +227 -0
- modelchoice_mcp-0.0.1/tests/test_server.py +24 -0
- modelchoice_mcp-0.0.1/tests/test_store.py +39 -0
- modelchoice_mcp-0.0.1/tests/test_tools.py +133 -0
- modelchoice_mcp-0.0.1/tests/test_tree.py +85 -0
|
@@ -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,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,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)
|