# DecisionMatrix MCP A transparent, **100% deterministic** [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that gives LLM agents a reliable **multi-criteria decision analysis (MCDA)** engine. Agents are great at gathering options but unreliable at *weighing* them: they lose precision, apply inconsistent weights, and can't show their work. DecisionMatrix offloads the scoring to an exact, explainable engine. You provide **options** and **weighted criteria** (plus a score matrix); it returns a fully **scored, ranked, and explained** result β€” with per-criterion breakdowns, the methodology used, the weights applied, and a plain-language explanation. Every number flows through [`decimal.js`](https://github.com/MikeMcl/decimal.js) at 40-digit precision (**never floats**), so identical inputs always produce **byte-identical output**. The server is **stateless** β€” no database, no sessions. ## 🌐 Live hosted server (free, no install) A public remote MCP server runs on Cloudflare's edge β€” point any Streamable-HTTP MCP client at it: ``` https://decisionmatrix-mcp.pages.dev/mcp ``` ```json { "mcpServers": { "decisionmatrix": { "type": "http", "url": "https://decisionmatrix-mcp.pages.dev/mcp" } } } ``` It runs in **open mode** on the free tier (no key, 15 calls/day per IP). Paid plans (**Starter $12/mo Β· 5,000/day**, **Pro $39/mo Β· 50,000/day**) are live via Stripe Checkout β€” buy a plan, get an API key instantly, and send it as `X-API-Key`. Self-host for unlimited calls with no keys. Landing page + pricing: . --- ## What it does Six tools, all returning a uniform, agent-parseable envelope: | Tool | Purpose | |------|---------| | `create_decision` | **Main tool.** Rank options against weighted criteria β†’ winner, full ranking, per-criterion breakdowns, methodology, weights, and a plain-language explanation. | | `score_options` | Return the full normalized scored matrix when scores are supplied separately. | | `sensitivity_analysis` | Sweep each criterion's weight Β±X% and report how robust the winner is (and where it flips). | | `compare_two` | Head-to-head comparison of exactly two options with per-criterion win counts. | | `list_methods` | Discovery: available scoring methods and when to use each. | | `health_check` | Version, status, and capabilities. | ### Scoring methods | method | model | normalization | notes | |--------|-------|---------------|-------| | `weighted_sum` *(default)* | Simple Additive Weighting (SAW) | min-max per criterion | Most transparent; additive contributions. Handles negatives. | | `weighted_product` | Weighted Product Model (WPM) | ratio (x/max, min/x) | Punishes any single weak criterion; **requires scores > 0**. | | `topsis` | Closeness to ideal solution | vector (Euclidean) | 0–1 closeness coefficient; robust with many criteria. | Each criterion has a **direction**: `benefit` (higher is better β€” quality, speed) or `cost` (lower is better β€” price, latency, risk). Weights are **relative**; they are normalized to sum to 1 internally. ### Consistent response envelope Every **successful** response contains: `status`, `method`, `winner`, `ranking` (with per-criterion `breakdown`), `methodology`, `weights_used`, `inputs_used`, `notes`, and a natural-language `explanation`. ```json { "status": "success", "method": "weighted_sum", "winner": { "option": "Gamma", "score": 0.666667, "score_exact": "0.666667", "rank": 1, "tie": false, "tied_with": [] }, "ranking": [ { "rank": 1, "option": "Gamma", "score": 0.666667, "score_exact": "0.666667", "breakdown": [ { "criterion": "Price", "direction": "cost", "weight": 0.5, "weight_raw": "3", "raw_score": "900", "normalized_score": 1, "weighted_contribution": 0.5 } ] } ], "methodology": { "method": "weighted_sum", "name": "Weighted Sum Model (Simple Additive Weighting)", "normalization": "min-max per criterion (best value -> 1, worst -> 0)", "score_range": "0 to 1 (higher is better)", "weighting": "Criteria weights are normalized to sum to 1; only their relative sizes matter.", "deterministic": true }, "weights_used": [ { "criterion": "Price", "direction": "cost", "weight_input": "3", "weight_normalized": 0.5 } ], "inputs_used": { "options": ["Alpha","Beta","Gamma"], "method": "weighted_sum", "option_count": 3, "criterion_count": 3 }, "notes": [ "Scores are normalized within this option set; they express relative standing, not an absolute grade." ], "explanation": "Using the Weighted Sum Model, 'Gamma' ranks #1 with a score of 0.666667, ahead of 'Alpha' (0.527778) by 26.32% ..." } ``` **Errors never cross the tool boundary as exceptions** β€” they come back as a structured, actionable envelope: ```json { "status": "error", "error": { "type": "incomplete_scores", "message": "Missing 1 score(s) in the options x criteria matrix.", "hint": "Provide a score for every option and criterion. Missing: Beta / Weight." } } ``` > **Design note β€” exact numbers:** `score` is a deterministically-rounded number (6 dp) > for easy consumption; `score_exact` / `raw_score` are full-precision **strings** so no > precision is lost in JSON. Rankings are computed on the exact values, with input order > as a stable tie-break. --- ## Project structure ``` decisionmatrix-mcp/ β”œβ”€β”€ worker-src/ β”‚ β”œβ”€β”€ index.mjs # Cloudflare Pages Function (_worker.js): MCP over Streamable HTTP + billing routes β”‚ β”œβ”€β”€ engine.mjs # The deterministic MCDA engine: 3 methods + 6 tools + validation β”‚ └── billing.mjs # Stripe Checkout + KV-backed API keys, quota metering, webhook β”œβ”€β”€ site/ β”‚ β”œβ”€β”€ index.html # Static landing / pricing / docs page β”‚ └── _worker.js # Built bundle (esbuild output; git-ignored) β”œβ”€β”€ tests/ β”‚ └── engine.test.mjs # 21 core scoring-logic tests (node --test) β”œβ”€β”€ examples/ β”‚ └── agent_example.mjs # End-to-end MCP client demo over HTTP β”œβ”€β”€ package.json # build / deploy / dev / test scripts β”œβ”€β”€ wrangler.toml # Cloudflare Pages config β”œβ”€β”€ .env.example # Optional auth/rate-limit env reference β”œβ”€β”€ LICENSE # MIT └── README.md ``` **Separation of concerns:** `engine.mjs` is pure and transport-agnostic (import it directly in tests or any Node/Deno/edge runtime); `index.mjs` only handles the MCP JSON-RPC wiring, HTTP, CORS, and the auth/metering seam. --- ## Requirements * Node **18+** (for the build, tests, and local dev). Only two dev/runtime deps: `decimal.js` (math) and `esbuild` (bundler). * A Cloudflare account (free tier is fine) to deploy the hosted version. --- ## Run it locally ```bash git clone decisionmatrix-mcp && cd decisionmatrix-mcp npm install # Run the test suite (no server needed) npm test # Serve the MCP endpoint locally via Wrangler (builds + runs Pages dev) npm run dev # -> http://127.0.0.1:8788/mcp # Try the end-to-end client demo (hosted by default, or pass a local URL) node examples/agent_example.mjs node examples/agent_example.mjs http://127.0.0.1:8788 ``` Quick manual call: ```bash curl -s http://127.0.0.1:8788/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ "name":"list_methods","arguments":{}}}' ``` --- ## Install via npm (stdio, no hosting) Run the server locally over stdio with a single command β€” nothing to deploy: ```bash npx -y decisionmatrix-mcp ``` Claude Desktop / any stdio MCP client (`claude_desktop_config.json`): ```json { "mcpServers": { "decisionmatrix": { "command": "npx", "args": ["-y", "decisionmatrix-mcp"] } } } ``` This is the same deterministic engine as the hosted server, running on your machine. ## Client configuration ### Cursor β€” `~/.cursor/mcp.json` ```json { "mcpServers": { "decisionmatrix": { "url": "https://decisionmatrix-mcp.pages.dev/mcp" } } } ``` ### Claude Desktop β€” `claude_desktop_config.json` Claude Desktop launches stdio servers, so bridge to the HTTP endpoint with `mcp-remote`: ```json { "mcpServers": { "decisionmatrix": { "command": "npx", "args": ["-y", "mcp-remote", "https://decisionmatrix-mcp.pages.dev/mcp"] } } } ``` ### VS Code β€” `.vscode/mcp.json` ```json { "servers": { "decisionmatrix": { "type": "http", "url": "https://decisionmatrix-mcp.pages.dev/mcp" } } } ``` ### Any Streamable-HTTP MCP client Point it at `https://decisionmatrix-mcp.pages.dev/mcp` (or your self-hosted URL). If you enable auth, add `X-API-Key` (or `Authorization: Bearer `) in the client's `headers`. --- ## Tools & parameters ### `create_decision(options, criteria, scores, method="weighted_sum")` - **options** β€” array of names (`["Vendor A","Vendor B"]`) or objects (`[{"name":"Vendor A","scores":{...}}]`). Minimum 2, names unique. - **criteria** β€” array of `{ "name", "weight" (>=0), "direction": "benefit"|"cost" }`. At least one weight must be > 0. - **scores** β€” the optionΓ—criterion matrix. Accepted shapes: - object map: `{ "Vendor A": { "Price": 100, "Quality": 8 }, ... }` - array: `[ { "option": "Vendor A", "scores": { ... } }, ... ]` - inline on each option object. - **method** β€” `weighted_sum` (default) Β· `weighted_product` Β· `topsis` (aliases like `saw`, `wpm`, `ideal` also resolve). ### `score_options(options, criteria, scores, method)` Same inputs as `create_decision`; returns the full **scored matrix** (per-option, per-criterion normalized scores + totals) without the winner narrative. ### `sensitivity_analysis(options, criteria, scores, method, variation=0.2, steps=10)` Sweeps each criterion's weight from `-variation` to `+variation` (fractional, e.g. `0.2` = Β±20%) in `steps` increments (2–100), renormalizing the others, and recomputes the winner each time. Returns a `robustness_score` (share of scenarios the baseline winner stays #1), the `fragile_criteria`, and per-criterion flip points. ### `compare_two(option_a, option_b, criteria, scores, method)` Head-to-head between exactly two options (pass `option_a`/`option_b` names, or a 2-element `options` array). Returns the winner, score `margin`, `criteria_wins`, and a `per_criterion` breakdown showing which option each criterion `favours`. ### `list_methods()` / `health_check()` Discovery + status. No parameters. --- ## Example tool-call payloads Choose a laptop (price & weight are **cost** criteria): ```json { "name": "create_decision", "arguments": { "options": ["Alpha", "Beta", "Gamma"], "criteria": [ { "name": "Price", "weight": 3, "direction": "cost" }, { "name": "Battery", "weight": 2, "direction": "benefit" }, { "name": "Weight", "weight": 1, "direction": "cost" } ], "scores": { "Alpha": { "Price": 1000, "Battery": 8, "Weight": 1.5 }, "Beta": { "Price": 1200, "Battery": 12, "Weight": 1.8 }, "Gamma": { "Price": 900, "Battery": 6, "Weight": 1.2 } } } } ``` Test how robust the winner is: ```json { "name": "sensitivity_analysis", "arguments": { "options": ["Alpha", "Beta", "Gamma"], "criteria": [ { "name": "Price", "weight": 3, "direction": "cost" }, { "name": "Battery", "weight": 2 } ], "scores": { "Alpha": {"Price":1000,"Battery":8}, "Beta": {"Price":1200,"Battery":12}, "Gamma": {"Price":900,"Battery":6} }, "variation": 0.3, "steps": 8 } } ``` Head-to-head: ```json { "name": "compare_two", "arguments": { "option_a": "Alpha", "option_b": "Beta", "criteria": [ { "name": "Price", "weight": 3, "direction": "cost" }, { "name": "Battery", "weight": 2 } ], "scores": { "Alpha": {"Price":1000,"Battery":8}, "Beta": {"Price":1200,"Battery":12} } } } ``` --- ## Deploy on Cloudflare Pages Same pattern as PrecisionCalc β€” one build step bundles `worker-src/` into `site/_worker.js` (Pages "advanced mode" Function), then Wrangler deploys the `site/` directory. ```bash npm install npx wrangler login # once # Build + deploy in one shot npm run deploy # esbuild -> site/_worker.js, then wrangler pages deploy ``` Or wire it to Git: create a Pages project, set the **build command** to `npm run build` and the **output directory** to `site`. Every push deploys automatically. The `compatibility_date` and project name live in `wrangler.toml`. To run **fully free / private**, you need **no bindings, secrets, or env vars** β€” the scoring engine is stateless and the server fails open (free tier, quota disabled). ### Enabling billing (already live on the hosted server) The hosted server uses these β€” replicate them for your own paid deployment: 1. **KV namespace** for API keys + daily usage counters, bound as `DECISIONMATRIX_KV` in `wrangler.toml`. 2. **Stripe products/prices** (subscription) β€” put the price IDs in `[vars]` (`PRICE_STARTER`, `PRICE_PRO`) and the daily limits (`FREE_DAILY`, `STARTER_DAILY`, `PRO_DAILY`). 3. **Stripe secrets** (never in the repo): ```bash wrangler pages secret put STRIPE_SECRET_KEY --project-name decisionmatrix-mcp wrangler pages secret put STRIPE_WEBHOOK_SECRET --project-name decisionmatrix-mcp ``` 4. **Webhook** β†’ create a Stripe webhook endpoint at `https:///webhook` for `customer.subscription.updated` + `customer.subscription.deleted`. Routes wired up: `/checkout?plan=starter|pro` β†’ Stripe Checkout, `/success` provisions and shows the API key (idempotent), `/portal` opens the Stripe billing portal, `/webhook` handles subscription lifecycle (revoke/restore), `/metrics` reports usage. --- ## Auth & rate limiting The hosted server enforces tiered quotas in `worker-src/billing.mjs`: * **Identity** β€” `identify()` reads `X-API-Key` / `Authorization: Bearer`, looks the key up in KV, and falls back to per-IP free tier. * **Quota** β€” `consumeQuota()` is a KV daily counter (resets 00:00 UTC); the single gating point in `handleRpc` where `method === "tools/call"`. * **Paywall response** β€” over-quota / invalid / revoked keys get a structured `upsell` envelope with pricing + checkout URLs (agents can read and act on it). * **Usage metering** β€” in-memory counters at `/metrics`. DecisionMatrix has **no paid-only tools** β€” every tool works on every tier; paid plans only raise the daily quota. To make a tool paid-only, add its name to `PAID_ONLY_TOOLS` in `index.mjs`. Because the engine is pure and stateless, none of this touches the scoring logic. --- ## Design decisions & assumptions * **Deterministic by construction.** 40-digit decimal math, `ROUND_HALF_UP` everywhere, and stable input-order tie-breaking. No floats, no randomness, no clocks in the result. * **Normalization is per-criterion and direction-aware.** `weighted_sum` uses min-max (bestβ†’1, worstβ†’0); if a criterion is identical across all options it's treated as neutral (normalized to 1) and noted. `weighted_product` uses ratio normalization and requires strictly positive scores (clear error otherwise). `topsis` uses vector normalization and ranks by closeness to the ideal/anti-ideal. * **Weights are relative** β€” normalized to sum to 1, so `[3,2,1]` and `[30,20,10]` give identical results. * **Scores are relative to the option set** β€” they measure standing *within the provided alternatives*, not an absolute grade. This is stated in `notes`. * **Errors are data, not exceptions** β€” every tool returns `status:"error"` with a machine `type` and an actionable `hint`. Validation covers duplicate names, missing cells (listing exactly which), non-numeric scores, bad weights/directions, and unknown methods. * **Stateless & side-effect-free** β€” trivially cacheable, horizontally scalable, and safe to run anywhere (Cloudflare, Node, Deno, Bun). --- ## Testing ```bash npm test # node --test tests/*.test.mjs (21 tests, no network) ``` The suite pins the hand-verifiable `weighted_sum` arithmetic, checks determinism, weight-relativity, direction handling, ties, all three methods, `compare_two`, `sensitivity_analysis`, the multiple score-input shapes, and every error path. --- ## Roadmap (post-MVP) 1. More methods: AHP (pairwise weight elicitation), ELECTRE, PROMETHEE, Borda count. 2. Group decisions: aggregate multiple stakeholders' weight/score sets. 3. Monte-Carlo sensitivity (perturb all weights jointly) alongside one-at-a-time. 4. Per-key usage dashboard + Redis/Durable-Object quotas for stronger consistency. 5. Published npm package + a hosted multi-tenant tier. ## License MIT β€” see [LICENSE](./LICENSE).