diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e79ad..7db0cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 01179ad..8a33ab4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. | @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 311c3c5..b1a539b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/scripts/build_mcpb.py b/scripts/build_mcpb.py index 7f6324f..a1972b9 100644 --- a/scripts/build_mcpb.py +++ b/scripts/build_mcpb.py @@ -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 - -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/ 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 diff --git a/src/modelchoice_mcp/__init__.py b/src/modelchoice_mcp/__init__.py index 66d1631..dc4945e 100644 --- a/src/modelchoice_mcp/__init__.py +++ b/src/modelchoice_mcp/__init__.py @@ -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" diff --git a/src/modelchoice_mcp/bridge.py b/src/modelchoice_mcp/bridge.py index d61ded2..c5d327f 100644 --- a/src/modelchoice_mcp/bridge.py +++ b/src/modelchoice_mcp/bridge.py @@ -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.""" @@ -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.""" @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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: @@ -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() @@ -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 {} @@ -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() @@ -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.") diff --git a/src/modelchoice_mcp/schemas.py b/src/modelchoice_mcp/schemas.py index a4c7691..4f7ac7f 100644 --- a/src/modelchoice_mcp/schemas.py +++ b/src/modelchoice_mcp/schemas.py @@ -314,6 +314,26 @@ class OpenWorkbookResult(BaseModel): note: str +class LicenseStatus(BaseModel): + """ModelChoice licence state read from the add-in (MC_LicenseStatus_Auto). + Building / analysis actions require a FULL licence (`is_complete`); + reading trees works regardless.""" + + available: bool = Field( + description="True if the add-in reported a status (loaded + has the command)." + ) + is_complete: bool = Field(description="Fully licensed — actions are allowed.") + is_trial: bool = False + is_expired: bool = False + is_not_activated: bool = False + days_left: int | None = None + status_text: str | None = None + actions_allowed: bool = Field( + description="Whether licence-gated actions (build/analysis) are permitted." + ) + note: str + + class CloseWorkbookResult(BaseModel): """Outcome of closing an open workbook.""" @@ -322,6 +342,21 @@ class CloseWorkbookResult(BaseModel): note: str +class Generator(BaseModel): + """What produced an export, so provenance survives the round trip. + + An exported tree usually ends up in version control, a ticket, or another + person's hands, where the fact that ModelChoice produced it is otherwise + lost. The product and company names are fixed brand tokens and are never + localised or abbreviated. + """ + + product: str = Field(description="Product name, always 'ModelChoice by Vose Software'.") + version: str = Field(description="Version of the exporting MCP server.") + exported_at: str = Field(description="UTC export timestamp, ISO 8601.") + url: str = Field(description="Product home page.") + + class TreeExport(BaseModel): """A tree's stored ModelChoice model JSON, for saving or sharing.""" @@ -331,6 +366,9 @@ class TreeExport(BaseModel): model_json: str = Field( description="The raw ModelChoice model JSON (round-trips via import_tree_json)." ) + generator: Generator = Field( + description="What produced this export. Not part of the model; import_tree_json ignores it." + ) class ImportResult(BaseModel): diff --git a/src/modelchoice_mcp/tools.py b/src/modelchoice_mcp/tools.py index 458360b..a0d4ba1 100644 --- a/src/modelchoice_mcp/tools.py +++ b/src/modelchoice_mcp/tools.py @@ -9,10 +9,12 @@ import dataclasses import json +from datetime import UTC, datetime from typing import Annotated, Any from pydantic import Field +from modelchoice_mcp import __version__ from modelchoice_mcp.bridge import ModelChoiceBridge from modelchoice_mcp.schemas import ( AnalysisRun, @@ -24,9 +26,11 @@ EditOp, EviiResult, EvpiResult, + Generator, ImportResult, InputDistributionResult, KeyValue, + LicenseStatus, McdaBuildResult, McdaSpec, NodeDiff, @@ -75,6 +79,21 @@ _bridge: ModelChoiceBridge | None = None +# Fixed brand tokens for the export provenance stamp. Never abbreviated to a bare +# "ModelChoice" and never localised - the compound form is the whole point. +_GENERATOR_PRODUCT = "ModelChoice by Vose Software" +_GENERATOR_URL = "vosesoftware.com/modelchoice" + + +def _generator() -> Generator: + """Provenance stamp for an export, so it survives the round trip through git.""" + return Generator( + product=_GENERATOR_PRODUCT, + version=__version__, + exported_at=datetime.now(UTC).isoformat(timespec="seconds"), + url=_GENERATOR_URL, + ) + def get_bridge() -> ModelChoiceBridge: global _bridge @@ -568,6 +587,51 @@ def open_workbook( ) +@mcp.tool( + description=( + "ModelChoice: Report the ModelChoice add-in's licence state (fully " + "licensed / trial / expired / not activated). Building and analysis " + "ACTIONS require a FULL licence; reading trees works regardless. Use " + "this to explain why an action was refused. Read-only." + ) +) +def license_status(workbook_name: str | None = None) -> LicenseStatus: + s = get_bridge().license_status(workbook_name) + if not s: + return LicenseStatus( + available=False, + is_complete=False, + actions_allowed=False, + note=( + "Could not read the licence status — the ModelChoice add-in isn't " + "loaded, or is older than the MC_LicenseStatus_Auto command. " + "Building/analysis actions are blocked until a licensed add-in reports in." + ), + ) + is_complete = bool(s.get("isComplete")) + days = s.get("daysLeft") + days_left = int(days) if isinstance(days, (int, float)) and not isinstance(days, bool) else None + note = ( + "Fully licensed — all actions allowed." + if is_complete + else ( + f"Not fully licensed ({s.get('statusText') or 'see flags'}). Reading trees " + "works; building and analysis actions are blocked until activation." + ) + ) + return LicenseStatus( + available=True, + is_complete=is_complete, + is_trial=bool(s.get("isTrial")), + is_expired=bool(s.get("isExpired")), + is_not_activated=bool(s.get("isNotActivated")), + days_left=days_left, + status_text=s.get("statusText"), + actions_allowed=is_complete, + note=note, + ) + + @mcp.tool( description=( "ModelChoice: Close an open workbook by file name. By DEFAULT unsaved " @@ -673,7 +737,10 @@ def get_tree( "ModelChoice: Export a tree's raw ModelChoice model JSON — for saving " "to a file, sharing, version control, or re-importing elsewhere with " "import_tree_json. Read-only. Pass tree_name to pick a tree, else the " - "first/active one." + "first/active one. The result carries a 'generator' field (product, " + "version, export timestamp) alongside the model JSON, so provenance " + "survives the round trip; it sits outside model_json and is ignored on " + "re-import." ) ) def export_tree_json( @@ -695,6 +762,7 @@ def export_tree_json( model_name=t.model_name, node_count=len(t.nodes), model_json=raw, + generator=_generator(), ) @@ -1649,6 +1717,7 @@ def read_sheet( "get_tree", "import_precisiontree", "import_tree_json", + "license_status", "list_trees", "open_workbook", "read_sheet", diff --git a/tests/test_server.py b/tests/test_server.py index dced0d6..67bea54 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,7 +7,7 @@ def test_version_set() -> None: - assert __version__ == "0.0.28" + assert __version__ == "0.0.30" def test_server_name() -> None: diff --git a/tests/test_tools.py b/tests/test_tools.py index 9d0d436..3cc539d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -153,6 +153,12 @@ def close_workbook(self, workbook: str, save: bool = False) -> dict[str, object] self.closed = (workbook, save) return {"closed": workbook, "saved": save} + def license_status(self, workbook: str | None = None) -> dict[str, object]: + return getattr(self, "_license", { + "isComplete": True, "isTrial": False, "isExpired": False, + "isNotActivated": False, "daysLeft": 365, "statusText": "Licensed", + }) + def write_tree(self, model_json: str, sheet_name: str | None = None, workbook: str | None = None) -> str: self.written_json = model_json @@ -294,6 +300,40 @@ def test_build_control_panel_parses_inputs(bridge: _FakeBridge) -> None: assert "persist across re-renders" in out.note +def test_license_status_tool_licensed(bridge: _FakeBridge) -> None: + from modelchoice_mcp.schemas import LicenseStatus + + out = tools.license_status() + assert isinstance(out, LicenseStatus) + assert out.available is True and out.is_complete is True + assert out.actions_allowed is True + assert out.days_left == 365 + + +def test_license_status_tool_unavailable(bridge: _FakeBridge) -> None: + bridge._license = {} # type: ignore[attr-defined] + out = tools.license_status() + assert out.available is False and out.actions_allowed is False + assert "blocked" in out.note.lower() + + +def test_require_license_gate() -> None: + from modelchoice_mcp.bridge import LicenseRequiredError, ModelChoiceBridge + + b = ModelChoiceBridge() + # Fully licensed -> passes. + b.license_status = lambda workbook=None: {"isComplete": True} # type: ignore[method-assign] + b._require_license() # no raise + # Trial -> blocked with a clear state. + b.license_status = lambda workbook=None: {"isComplete": False, "isTrial": True} # type: ignore[method-assign] + with pytest.raises(LicenseRequiredError, match="trial"): + b._require_license() + # Unreadable status -> fail-closed. + b.license_status = lambda workbook=None: {} # type: ignore[method-assign] + with pytest.raises(LicenseRequiredError, match="Could not verify"): + b._require_license() + + def test_open_workbook(bridge: _FakeBridge) -> None: from modelchoice_mcp.schemas import OpenWorkbookResult @@ -548,6 +588,33 @@ def test_export_tree_json(bridge: _FakeBridge) -> None: assert out.model_json == _MODEL +def test_export_tree_json_carries_generator_provenance(bridge: _FakeBridge) -> None: + """The export stamp is what survives the round trip through version control.""" + from datetime import datetime + + from modelchoice_mcp import __version__ + + gen = tools.export_tree_json().generator + + # Compound brand form, never a bare "ModelChoice". + assert gen.product == "ModelChoice by Vose Software" + assert gen.version == __version__ + assert gen.url == "vosesoftware.com/modelchoice" + + # Parseable ISO 8601 with an explicit offset — a naive timestamp in someone + # else's repository is not provenance. + stamped = datetime.fromisoformat(gen.exported_at) + assert stamped.tzinfo is not None + + +def test_export_generator_is_not_smuggled_into_the_model(bridge: _FakeBridge) -> None: + """model_json must round-trip byte-identically: the stamp sits beside it, not in it.""" + out = tools.export_tree_json() + assert out.model_json == _MODEL + assert "generator" not in out.model_json + assert tools.import_tree_json(out.model_json, dry_run=True).node_count == out.node_count + + def test_import_tree_json_roundtrip() -> None: fake = _FakeBridge({"MC_Tree_1": _MODEL}) tools.set_bridge_for_testing(fake) # type: ignore[arg-type]