Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@
All notable changes to `modelchoice-mcp`. Versions are tag-driven; pushing a
`vX.Y.Z` tag publishes to PyPI via the release workflow.

## 0.0.30
- **`export_tree_json` now carries a `generator` field (AB#3123)** — product
(`ModelChoice by Vose Software`), server version, UTC export timestamp and the
product URL, so an exported tree stays attributable once it lands in version
control, a ticket, or someone else's repository. It sits *alongside*
`model_json`, never inside it: `model_json` still round-trips byte-identically
through `import_tree_json`. Part of the ModelChoice output-branding work
(Feature AB#3118).

## 0.0.29
- **Licence gate (AB#2659)** — building and analysis ACTIONS now require a fully
licensed ModelChoice. The bridge reads the add-in's licence state via the new
headless `MC_LicenseStatus_Auto` and refuses actions (build/edit commit,
build_mcda, control panel, set_input_distribution, run_utility / evii / evpi /
risk_profile / decision_report / robustness / sensitivity / analysis, import)
unless `isComplete` (full licence). **Reading is unaffected** — list/get/roll_up/
verify/scenarios/export and open/close workbook work regardless. New read-only
**`license_status`** tool reports the state. Fail-closed: if the status can't be
read (add-in missing/old), actions are blocked with an actionable message.
(Trial/expired users can read but not drive actions.) Needs the add-in build
with `MC_LicenseStatus_Auto`.
- **`.mcpb` now built with the official `mcpb` CLI** (`@anthropic-ai/mcpb`,
validates during pack) instead of a hand-rolled zip; plain-zip fallback when
node isn't present. README install section reworked: PyPI + config is the
recommended path; the `.mcpb` one-click carries a note that the **Claude
Desktop Extensions installer silently no-ops on the latest Windows MSIX builds
(a client bug, not the bundle)** — use `pip install` until Anthropic patches it.

## 0.0.28
- **One-click install: Claude Desktop Extension (`.mcpb`)** — the release now
also builds a standalone Windows `.exe` (new PyInstaller spec) and wraps it in
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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.

> **Status: `0.0.28` — Phase 3 (build + drive).** 24 tools: build_tree / build_mcda / edit_tree (incl. add/remove options & outcomes) / set_input_distribution (put a Vose distribution on an input) / build_control_panel / export_tree_json / import_tree_json / import_precisiontree, read + roll + verify, run_scenarios (what-if comparison), plus run_evpi / run_evii / run_risk_profile / run_robustness / run_sensitivity / run_decision_report (strategy/policy/brief/mcda/force-to-outcome/two-way) / run_analysis / read_sheet over ModelChoice's headless commands.
> **Status: `0.0.30` — Phase 3 (build + drive).** 25 tools: build_tree / build_mcda / edit_tree (incl. add/remove options & outcomes) / set_input_distribution (put a Vose distribution on an input) / build_control_panel / export_tree_json / import_tree_json / import_precisiontree, read + roll + verify, run_scenarios (what-if comparison), plus run_evpi / run_evii / run_risk_profile / run_robustness / run_sensitivity / run_decision_report (strategy/policy/brief/mcda/force-to-outcome/two-way) / run_analysis / read_sheet over ModelChoice's headless commands.

## Tools

Expand All @@ -19,6 +19,7 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w
| `import_precisiontree` | Import a PrecisionTree workbook (.xls/.xlsx) into ModelChoice — converts a copy (original untouched). Drives `MC_ImportPrecisionTree_Auto`. |
| `open_workbook` | **Open a workbook (.xlsx) from disk** in the running Excel so the other tools can act on it. Reports its sheets + any ModelChoice tree sheets; reuses an already-open workbook of the same name. |
| `close_workbook` | **Close an open workbook** by file name (counterpart to `open_workbook`). Discards unsaved changes by default; pass `save=True` to save first. |
| `license_status` | **Report the ModelChoice licence state** (licensed / trial / expired / not activated). Building and analysis **actions require a full licence**; reading trees works regardless. Read-only. |
| `list_trees` | List the decision trees in a workbook with node-type counts. |
| `get_tree` | Full structure of one tree — decision / chance / terminal nodes, branches, probabilities, values. |
| `roll_up` | Roll the tree back to its expected values and **optimal policy** — the decision recommendation, in plain English. |
Expand All @@ -41,13 +42,11 @@ A sibling to [`modelrisk-mcp`](https://github.com/vosesoftware/modelrisk-mcp): w

## Install

**One-click (Claude Desktop) — recommended:**
1. Download **`modelchoice-mcp.mcpb`** from the [latest release](https://github.com/vosesoftware/modelchoice-mcp/releases/latest) and open it (or drag it onto Claude Desktop's **Settings → Extensions**).
2. **Restart Claude Desktop.**
**Recommended (works on every current Claude version):** `pip install modelchoice-mcp`, then add the server to Claude Desktop's `claude_desktop_config.json` (a `modelchoice-mcp` command entry, or `uvx modelchoice-mcp`) and **restart Claude Desktop**. Run standalone with `uv run python -m modelchoice_mcp` (stdio). (Excel + the ModelChoice add-in are still required for rendering trees.)

The bundle ships the server as a standalone executable — no Python to install, no config to edit. (Excel + the ModelChoice add-in are still required for rendering trees.)
**One-click `.mcpb` (Claude Desktop Extension):** download **`modelchoice-mcp.mcpb`** from the [latest release](https://github.com/vosesoftware/modelchoice-mcp/releases/latest), open it (Claude Desktop → **Settings → Extensions → Install Extension…**), then restart Claude. The bundle ships the server as a standalone exe — no Python, no config edit.

**From PyPI / source:** `pip install modelchoice-mcp`, then `uv run python -m modelchoice_mcp` (stdio), or wire `modelchoice-mcp` into Claude Desktop like any MCP server.
> ⚠️ **Known issue (Claude Desktop, latest Windows MSIX builds, e.g. 1.12603.x):** the Extensions installer can silently do nothing when you pick a `.mcpb` — no error, no install. This is a **Claude Desktop installer bug** (it fails before logging), not a problem with the bundle (it validates with `mcpb` and installs fine once the client is fixed). **Until Anthropic patches it, use the `pip install` + config path above.**

## How it works

Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
[project]
name = "modelchoice-mcp"
version = "0.0.28"
version = "0.0.30"
description = "An open Model Context Protocol server for Vose Software's ModelChoice decision-tree add-in for Excel."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Vose Software" }]
dependencies = [
"mcp>=1.2.0",
# Upper bound is load-bearing. uv.lock is gitignored, so CI has no pins and
# `uv sync` re-resolves these floors on every run - an upstream major release
# lands in CI the day it ships. mcp 2.0 moved `mcp.server.fastmcp`, which broke
# mypy across every @mcp.tool in the package on an unchanged main. Widen this
# deliberately, with a migration, not by accident.
"mcp>=1.2.0,<2",
"pydantic>=2.0",
"xlwings>=0.30",
]
Expand Down
102 changes: 59 additions & 43 deletions scripts/build_mcpb.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,84 @@
"""Assemble the Claude Desktop Extension (.mcpb) bundle from the built exe.
"""Build the Claude Desktop Extension (.mcpb) from the built exe.

A .mcpb is a ZIP archive with manifest.json at its root and the standalone
server binary under server/. Claude Desktop installs it in one click — no
Python, no config editing. This is CLI-free (plain zipfile) so it doesn't
depend on the external `mcpb` packer being installed in CI.
A .mcpb is a ZIP with manifest.json at the root and the standalone server
binary under server/. This stages that layout (injecting the release version
into the manifest) and packs it with the official `@anthropic-ai/mcpb` CLI via
`npx` — which validates the manifest during packing. If node/npx isn't
available (e.g. a bare local checkout), it falls back to a plain-zip pack that
produces a byte-structurally identical bundle.

Usage:
python scripts/build_mcpb.py <exe_path> <version> <out.mcpb>

The version (e.g. derived from the release tag) is injected into the bundled
manifest's `version` field so it always matches the release.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path

_MCPB_PKG = "@anthropic-ai/mcpb@latest"

def main() -> int:
if len(sys.argv) != 4:
print(__doc__)
return 2
exe_path, version, out = sys.argv[1], sys.argv[2], sys.argv[3]

def _stage(exe_path: Path, version: str, stage: Path) -> str:
"""Lay out manifest.json + server/<exe> under `stage`; return the exe name."""
root = Path(__file__).resolve().parent.parent
manifest_src = root / "packaging" / "manifest.json"
manifest = json.loads(manifest_src.read_text(encoding="utf-8"))
manifest = json.loads((root / "packaging" / "manifest.json").read_text(encoding="utf-8"))
manifest["version"] = version

# Sanity: the manifest must reference the exe we're bundling by basename.
exe_name = os.path.basename(exe_path)
exe_name = exe_path.name
entry = manifest["server"]["entry_point"]
if os.path.basename(entry) != exe_name:
raise SystemExit(
f"manifest entry_point {entry!r} does not match exe {exe_name!r}"
)

build = root / "build" / "mcpb"
if build.exists():
shutil.rmtree(build)
(build / "server").mkdir(parents=True)
shutil.copy2(exe_path, build / "server" / exe_name)
(build / "manifest.json").write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
)

out_path = Path(out)
out_path.parent.mkdir(parents=True, exist_ok=True)
if out_path.exists():
out_path.unlink()
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
for f in sorted(build.rglob("*")):
if Path(entry).name != exe_name:
raise SystemExit(f"manifest entry_point {entry!r} does not match exe {exe_name!r}")

if stage.exists():
shutil.rmtree(stage)
(stage / "server").mkdir(parents=True)
shutil.copy2(exe_path, stage / "server" / exe_name)
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return exe_name


def _pack_with_cli(npx: str, stage: Path, out: Path) -> bool:
"""Validate + pack with the official mcpb CLI. Returns True on success."""
try:
subprocess.run([npx, "-y", _MCPB_PKG, "validate", str(stage / "manifest.json")], check=True)
subprocess.run([npx, "-y", _MCPB_PKG, "pack", str(stage), str(out)], check=True)
return True
except (subprocess.CalledProcessError, OSError) as exc:
print(f"mcpb CLI pack failed ({exc}); falling back to plain-zip.")
return False


def _pack_plain_zip(stage: Path, out: Path) -> None:
"""Fallback: zip the staged dir (manifest.json at root)."""
if out.exists():
out.unlink()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
for f in sorted(stage.rglob("*")):
if f.is_file():
z.write(f, f.relative_to(build).as_posix())
z.write(f, f.relative_to(stage).as_posix())


def main() -> int:
if len(sys.argv) != 4:
print(__doc__)
return 2
exe_path, version, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])

stage = (out.parent if out.parent.name else Path(".")) / "_mcpb_stage"
_stage(exe_path, version, stage)

out.parent.mkdir(parents=True, exist_ok=True)
npx = shutil.which("npx")
if not (npx and _pack_with_cli(npx, stage, out)):
_pack_plain_zip(stage, out)

size = out_path.stat().st_size
print(f"Built {out_path} ({size:,} bytes) — manifest version {version}")
# Echo the manifest for the CI log.
print(json.dumps(manifest, indent=2))
size = out.stat().st_size
print(f"Built {out} ({size:,} bytes) — manifest version {version}")
return 0


Expand Down
2 changes: 1 addition & 1 deletion src/modelchoice_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""ModelChoice MCP — an open Model Context Protocol server for Vose
Software's ModelChoice decision-tree add-in for Excel."""

__version__ = "0.0.28"
__version__ = "0.0.30"
65 changes: 65 additions & 0 deletions src/modelchoice_mcp/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ class ExcelNotRunningError(RuntimeError):
"""No running Excel instance could be attached."""


class LicenseRequiredError(RuntimeError):
"""A licence-gated action was attempted without a fully licensed
ModelChoice. Reading trees is unaffected; building/analysis actions need
a full licence."""


class ModelChoiceBridge:
"""Attach to a running Excel and read ModelChoice trees from a
workbook's very-hidden ``_MC_Store`` sheet."""
Expand Down Expand Up @@ -162,6 +168,55 @@ def _harden_attach(app: Any) -> None:
except Exception:
pass # fall back to xlwings' default attach

def license_status(self, workbook: str | None = None) -> dict[str, Any]:
"""Read the ModelChoice add-in's licence state via the headless
``MC_LicenseStatus_Auto`` command. Returns a dict with keys like
``isComplete`` / ``isTrial`` / ``isExpired`` / ``isNotActivated`` /
``daysLeft`` / ``statusText``. Returns ``{}`` if the command isn't
available (add-in not loaded, or older than this command) or Excel
couldn't run it — the caller decides how to treat an unknown state."""
try:
book = self._book(workbook)
raw = book.app.api.Run("MC_LicenseStatus_Auto")
except Exception:
return {}
if not isinstance(raw, str) or not raw.strip():
return {}
try:
data = json.loads(raw)
except Exception:
return {}
return data if isinstance(data, dict) else {}

def _require_license(self, workbook: str | None = None) -> None:
"""Gate a licence-bound ACTION on a FULL ModelChoice licence
(``isComplete``). Pure reads never call this. Fail-closed: if the
status can't be read (add-in missing/old/blocked), the action is
refused with an actionable message — building/analysis require a
licensed ModelChoice, reading does not."""
status = self.license_status(workbook)
if status.get("isComplete") is True:
return
if not status:
raise LicenseRequiredError(
"Could not verify the ModelChoice licence (is the add-in loaded "
"and up to date — i.e. does it provide MC_LicenseStatus_Auto?). "
"Building and analysis actions require a licensed ModelChoice; "
"reading trees does not."
)
if status.get("isTrial"):
state = "trial"
elif status.get("isExpired"):
state = "expired"
elif status.get("isNotActivated"):
state = "not activated"
else:
state = "unlicensed"
raise LicenseRequiredError(
f"This action requires a fully licensed ModelChoice (current: {state}). "
"Reading trees works without a licence; building and analysis do not."
)

def read_store_raw(self, workbook: str | None = None) -> str:
"""Return the reassembled ``_MC_Store`` A1 payload, or '' if the
sheet is absent."""
Expand Down Expand Up @@ -251,6 +306,7 @@ def run_evpi(self, workbook: str | None = None) -> dict[str, Any]:
ModelChoice add-in to be loaded in Excel and a tree to be active.
Raises ``ModelChoiceNotFoundError`` if the command produced no
result sheet (add-in not loaded, or no active tree)."""
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand Down Expand Up @@ -313,6 +369,7 @@ def run_utility(
f"Unknown utility function {function!r}. Choose from: "
f"{', '.join(sorted(set(self._UTILITY_FUNCTIONS)))}."
)
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand Down Expand Up @@ -366,6 +423,7 @@ def run_evii(
ModelChoice add-in loaded with a tree open. Raises
``ModelChoiceNotFoundError`` if the command produced no result sheet
(add-in not loaded, no active tree, or the chance node wasn't found)."""
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand Down Expand Up @@ -412,6 +470,7 @@ def build_control_panel(
the sheet and links each tree cell to its panel cell — then reads the
panel block back. Requires the ModelChoice add-in loaded with a
rendered tree. Returns ``{sheet, rows}``."""
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand Down Expand Up @@ -451,6 +510,7 @@ def apply_mcda(self, spec_json: str, workbook: str | None = None) -> None:
weights, aggregation, and per-terminal scores, then re-renders. Requires
the add-in loaded with the active tree built (a build that includes the
MCDA command)."""
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand All @@ -471,6 +531,7 @@ def import_precisiontree(self, file_path: str) -> dict[str, Any]:
the active workbook — then reads the converted workbook's trees.
Requires the add-in loaded with a build that includes the import
command."""
self._require_license()
xw = self._load_xw()
app = xw.apps.active
if app is None:
Expand Down Expand Up @@ -504,6 +565,7 @@ def run_analysis(self, command_name: str, workbook: str | None = None) -> dict[s
sheets it produced. Activates the workbook first; raises
``ModelChoiceNotFoundError`` if the command can't run (add-in not
loaded, or no active tree)."""
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand All @@ -529,6 +591,7 @@ def write_tree(
columns) under a tree sheet name, creating the very-hidden store
sheet if needed. Returns the sheet name used. The add-in is not
required to store; call :meth:`render_tree` to draw it."""
self._require_license(workbook)
book = self._book(workbook)
raw = self.read_store_raw(workbook)
trees = parse_store(raw) if raw else {}
Expand Down Expand Up @@ -571,6 +634,7 @@ def write_tree(
def render_tree(self, sheet_name: str, workbook: str | None = None) -> None:
"""Draw a stored tree by name via ModelChoice's renderer
(``MC_RenderStoredTree``). Requires the add-in loaded."""
self._require_license(workbook)
book = self._book(workbook)
try:
book.activate()
Expand Down Expand Up @@ -600,6 +664,7 @@ def set_input_formula(
Pure ``_MC_Store`` edit + render — no special add-in command beyond the
existing MC_RenderStoredTree. ModelRisk (via modelrisk-mcp) then samples
the formula during simulation."""
self._require_license(workbook)
raw = self.read_store_raw(workbook)
if not raw:
raise ModelChoiceNotFoundError("Workbook has no ModelChoice tree store.")
Expand Down
Loading
Loading