marketmirror 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ src/marketmirror/_server/
2
+ dist/
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.5
2
+ Name: marketmirror
3
+ Version: 0.4.2
4
+ Summary: MCP server that tests ads, prices, emails and sales pitches on simulated buyers and returns a go / iterate / no-go report.
5
+ Project-URL: Homepage, https://github.com/DarkWorldCoder/market-mirror
6
+ Project-URL: Demo, https://darkworldcoder.github.io/market-mirror/demo/
7
+ Project-URL: Issues, https://github.com/DarkWorldCoder/market-mirror/issues
8
+ Author: Ayush Niroula
9
+ License-Expression: AGPL-3.0-only
10
+ Keywords: ad-testing,agents,llm,market-research,marketing,mcp,mcp-server,simulation,synthetic-users
11
+ Classifier: Operating System :: POSIX
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Office/Business
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # MarketMirror MCP server
19
+
20
+ <!-- mcp-name: io.github.DarkWorldCoder/market-mirror -->
21
+
22
+ MarketMirror tests marketing and sales decisions on simulated buyers before you spend money on real ones. It builds
23
+ interviewed buyer personas, shows them your ads in simulated social feeds, runs a buyer survey and a sales-pitch
24
+ rehearsal, and writes a go / iterate / no-go report with an offline HTML dashboard. Results are simulated and labelled
25
+ as such; `backtest` and `calibrate` compare them with your real ad and survey results.
26
+
27
+ This package is the MCP server (Python standard library only). Add it to any MCP client:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "market-mirror": { "command": "uvx", "args": ["marketmirror"] }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Then ask your agent to run `doctor`, and `setup` to install the MarketMirror engine (Git, Bash and
38
+ [uv](https://docs.astral.sh/uv/) required; Linux, macOS or WSL). Choose a model provider in your own terminal with the
39
+ engine's `./mm configure`; never paste API keys into a chat.
40
+
41
+ - Repository, docs and full install guide: https://github.com/DarkWorldCoder/market-mirror
42
+ - Live demo (a 2,999-person simulated run, no install): https://darkworldcoder.github.io/market-mirror/demo/
43
+ - License: AGPL-3.0
@@ -0,0 +1,26 @@
1
+ # MarketMirror MCP server
2
+
3
+ <!-- mcp-name: io.github.DarkWorldCoder/market-mirror -->
4
+
5
+ MarketMirror tests marketing and sales decisions on simulated buyers before you spend money on real ones. It builds
6
+ interviewed buyer personas, shows them your ads in simulated social feeds, runs a buyer survey and a sales-pitch
7
+ rehearsal, and writes a go / iterate / no-go report with an offline HTML dashboard. Results are simulated and labelled
8
+ as such; `backtest` and `calibrate` compare them with your real ad and survey results.
9
+
10
+ This package is the MCP server (Python standard library only). Add it to any MCP client:
11
+
12
+ ```json
13
+ {
14
+ "mcpServers": {
15
+ "market-mirror": { "command": "uvx", "args": ["marketmirror"] }
16
+ }
17
+ }
18
+ ```
19
+
20
+ Then ask your agent to run `doctor`, and `setup` to install the MarketMirror engine (Git, Bash and
21
+ [uv](https://docs.astral.sh/uv/) required; Linux, macOS or WSL). Choose a model provider in your own terminal with the
22
+ engine's `./mm configure`; never paste API keys into a chat.
23
+
24
+ - Repository, docs and full install guide: https://github.com/DarkWorldCoder/market-mirror
25
+ - Live demo (a 2,999-person simulated run, no install): https://darkworldcoder.github.io/market-mirror/demo/
26
+ - License: AGPL-3.0
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "marketmirror"
7
+ version = "0.4.2"
8
+ description = "MCP server that tests ads, prices, emails and sales pitches on simulated buyers and returns a go / iterate / no-go report."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "AGPL-3.0-only"
12
+ authors = [{ name = "Ayush Niroula" }]
13
+ keywords = ["mcp", "mcp-server", "marketing", "market-research", "synthetic-users", "simulation", "ad-testing", "llm", "agents"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: POSIX",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ "Topic :: Office/Business",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/DarkWorldCoder/market-mirror"
24
+ Demo = "https://darkworldcoder.github.io/market-mirror/demo/"
25
+ Issues = "https://github.com/DarkWorldCoder/market-mirror/issues"
26
+
27
+ [project.scripts]
28
+ marketmirror = "marketmirror.cli:main"
29
+ marketmirror-mcp = "marketmirror.cli:main"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/marketmirror"]
33
+
34
+ [tool.hatch.build.targets.wheel.force-include]
35
+ "src/marketmirror/_server" = "marketmirror/_server"
36
+
37
+ [tool.hatch.build.targets.sdist.force-include]
38
+ "src/marketmirror/_server" = "src/marketmirror/_server"
@@ -0,0 +1 @@
1
+ """MarketMirror MCP server, packaged for uvx/pip. The server itself lives in plugin/skills/market-mirror/scripts."""
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env python3
2
+ """Detached MCP job worker that persists a terminal status after server restarts."""
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+
10
+
11
+ def write(path, data):
12
+ temp = path + ".tmp"
13
+ with open(temp, "w", encoding="utf-8") as f:
14
+ json.dump(data, f, ensure_ascii=False)
15
+ os.replace(temp, path)
16
+
17
+
18
+ def main():
19
+ mm, meta_path, out_path, log_path, *args = sys.argv[1:]
20
+ with open(meta_path, encoding="utf-8") as f:
21
+ meta = json.load(f)
22
+ try:
23
+ with open(out_path, "w", encoding="utf-8") as out, open(log_path, "w", encoding="utf-8") as log:
24
+ code = subprocess.run(["bash", mm, *args], stdout=out, stderr=log).returncode
25
+ except Exception as e:
26
+ with open(log_path, "a", encoding="utf-8") as log:
27
+ log.write(f"job worker failed: {type(e).__name__}: {e}\n")
28
+ code = 1
29
+ meta.update({"status": "done" if code == 0 else "failed", "exit_code": code,
30
+ "finished": time.time(), "elapsed_s": round(time.time() - meta["started"], 2)})
31
+ write(meta_path, meta)
32
+ return code
33
+
34
+
35
+ if __name__ == "__main__":
36
+ raise SystemExit(main())
@@ -0,0 +1,716 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MarketMirror MCP server (stdio or authenticated loopback HTTP, stdlib only).
4
+
5
+ Exposes MarketMirror as tools for any MCP client. It wraps mm.sh,
6
+ so it works before the engine is installed: call `setup` first. Long work (setup, market tests,
7
+ surveys, feed tests) runs as background jobs; poll `job_status`.
8
+
9
+ Register (Claude Code): claude mcp add market-mirror -- python3 /path/to/mcp_server.py
10
+ The plugin registers it automatically.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import argparse
17
+ import hmac
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ import uuid
24
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ HERE = os.path.dirname(os.path.abspath(__file__))
28
+ MM = os.path.join(HERE, "mm.sh")
29
+ PROTOCOL = "2025-03-26"
30
+ SUPPORTED_PROTOCOLS = {"2024-11-05", "2025-03-26", "2025-06-18"}
31
+ JOBS: Dict[str, Dict[str, Any]] = {}
32
+ LIVE: Dict[str, Any] = {}
33
+
34
+
35
+ def sh(args: List[str], timeout: float = 120) -> Dict[str, Any]:
36
+ try:
37
+ p = subprocess.run(["bash", MM, *args], capture_output=True, text=True, timeout=timeout)
38
+ return {"ok": p.returncode == 0, "stdout": p.stdout[-20000:], "stderr": p.stderr[-6000:]}
39
+ except subprocess.TimeoutExpired:
40
+ return {"ok": False, "stdout": "", "stderr": f"timed out after {timeout}s"}
41
+
42
+
43
+ def _cli_result(result: Dict[str, Any]) -> Dict[str, Any]:
44
+ try:
45
+ data = json.loads(result.get("stdout") or "{}")
46
+ except ValueError:
47
+ data = {"message": (result.get("stdout") or "")[-4000:]}
48
+ if not result["ok"]:
49
+ raise ValueError(data.get("error") or result.get("stderr") or "CLI command failed")
50
+ return data
51
+
52
+
53
+ def home() -> str:
54
+ return sh(["home"])["stdout"].strip()
55
+
56
+
57
+ def job(args: List[str], label: str) -> Dict[str, Any]:
58
+ jid = uuid.uuid4().hex[:10]
59
+ logdir = os.path.join(os.path.expanduser("~"), ".market-mirror", "jobs")
60
+ os.makedirs(logdir, exist_ok=True)
61
+ log_path = os.path.join(logdir, f"{jid}.log")
62
+ out_path = os.path.join(logdir, f"{jid}.out")
63
+ meta_path = os.path.join(logdir, f"{jid}.json")
64
+ started = time.time()
65
+ with open(meta_path, "w", encoding="utf-8") as f:
66
+ json.dump({"id": jid, "label": label, "args": args, "log": log_path, "out": out_path,
67
+ "started": started, "status": "running"}, f)
68
+ proc = subprocess.Popen([sys.executable, os.path.join(HERE, "job_worker.py"), MM, meta_path, out_path, log_path, *args],
69
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
70
+ JOBS[jid] = {"id": jid, "label": label, "args": args, "proc": proc, "log": log_path, "out": out_path,
71
+ "started": started, "meta": meta_path}
72
+ return {"job_id": jid, "label": label, "status": "running",
73
+ "log_path": log_path, "result_path": out_path,
74
+ "hint": "Poll job_status with this job_id. Progress lines appear in the log tail."}
75
+
76
+
77
+ def job_status(job_id: str, tail: int = 25) -> Dict[str, Any]:
78
+ j = JOBS.get(job_id)
79
+ if not j:
80
+ meta = os.path.join(os.path.expanduser("~"), ".market-mirror", "jobs", f"{job_id}.json")
81
+ try:
82
+ with open(meta, encoding="utf-8") as f:
83
+ j = json.load(f)
84
+ except (OSError, ValueError):
85
+ return {"error": f"unknown job {job_id}"}
86
+ else:
87
+ try:
88
+ with open(j["meta"], encoding="utf-8") as f:
89
+ j = {**j, **json.load(f)}
90
+ except (OSError, ValueError):
91
+ pass
92
+ status = j.get("status", "running")
93
+ if status == "running" and j.get("proc") is not None and j["proc"].poll() is not None:
94
+ status = "done" if j["proc"].returncode == 0 else "failed"
95
+ try:
96
+ with open(j["log"], encoding="utf-8", errors="replace") as f:
97
+ log = f.read().splitlines()[-tail:]
98
+ except OSError:
99
+ log = []
100
+ res: Dict[str, Any] = {"job_id": job_id, "label": j["label"], "elapsed_s": j.get("elapsed_s", round(time.time() - j["started"])),
101
+ "status": status, "log_tail": log, "log_path": j["log"], "result_path": j["out"]}
102
+ if status != "running":
103
+ try:
104
+ with open(j["out"], encoding="utf-8", errors="replace") as f:
105
+ out = f.read().strip()
106
+ except OSError:
107
+ out = ""
108
+ try:
109
+ res["result"] = json.loads(out.splitlines()[-1]) if out else None
110
+ except (ValueError, IndexError):
111
+ res["result"] = out[-4000:]
112
+ return res
113
+
114
+
115
+ def results(run: str) -> Dict[str, Any]:
116
+ run_dir = run if os.path.isabs(run) else os.path.join(home(), "mirofish_runs", run)
117
+ website = os.path.join(run_dir, "website_test.json")
118
+ if os.path.exists(website) and not os.path.exists(os.path.join(run_dir, "summary.json")):
119
+ with open(website, encoding="utf-8") as source:
120
+ data = json.load(source)
121
+ return {k: data.get(k) for k in ("status", "url", "goal", "summary", "milestones", "files", "interpretation", "limits")}
122
+ path = os.path.join(run_dir, "summary.json")
123
+ if not os.path.exists(path):
124
+ return {"error": f"no summary.json in {run_dir} yet"}
125
+ s = json.load(open(path, encoding="utf-8"))
126
+ mk = s.get("marketing") or {}
127
+ out = {"dashboard": os.path.join(run_dir, "dashboard.html"), "decision": s.get("decision"),
128
+ "report_md": os.path.join(run_dir, "market_report.md") if os.path.exists(os.path.join(run_dir, "market_report.md")) else None,
129
+ "action_plan": (json.load(open(os.path.join(run_dir, "market_report.json"), encoding="utf-8")).get("action_plan")
130
+ if os.path.exists(os.path.join(run_dir, "market_report.json")) else None),
131
+ "verdicts": mk.get("verdicts"), "overall": mk.get("overall"), "playbook": mk.get("playbook"),
132
+ "by_segment": mk.get("by_segment"), "objections": mk.get("objections"),
133
+ "messages": mk.get("messages_ranked"), "pricing": mk.get("pricing"),
134
+ "files": sorted(f for f in os.listdir(run_dir) if f.endswith((".csv", ".json", ".md", ".html")))}
135
+ if os.path.exists(website):
136
+ with open(website, encoding="utf-8") as source:
137
+ data = json.load(source)
138
+ out["website_test"] = {k: data.get(k) for k in ("status", "summary", "milestones", "files", "interpretation")}
139
+ ft = os.path.join(run_dir, "feed_test.json")
140
+ if os.path.exists(ft):
141
+ ftj = json.load(open(ft, encoding="utf-8"))
142
+ summ = ftj.get("summary") or {}
143
+ ms = ftj.get("multi_seed") or {}
144
+ if ms:
145
+ out["feed_test_seeds"] = {"n": ms.get("n"), "winner_agreement": ms.get("winner_agreement"),
146
+ "usage": ms.get("usage"), "spread": ms.get("spread")}
147
+ out["feed_test"] = {cid: {k: v for k, v in m.items() if k in (
148
+ "impressions", "noticed", "thumb_stop", "ctr", "expected_ctr", "engagement_rate", "hide_rate", "landing", "projection")}
149
+ for cid, m in (summ.get("per_creative") or {}).items()}
150
+ out["feed_test_ranked"] = summ.get("ranked")
151
+ pj = os.path.join(run_dir, "pitch.json")
152
+ if os.path.exists(pj):
153
+ pt = json.load(open(pj, encoding="utf-8"))
154
+ out["pitch"] = {k: pt.get(k) for k in ("answered", "pitch_score", "verdict_before", "verdict_after", "fixes", "hot_leads")}
155
+ out["pitch"]["objections"] = [{k: o.get(k) for k in ("type", "share", "rebuttal_success")} for o in pt.get("objections") or []]
156
+ ij = os.path.join(run_dir, "ideas.json")
157
+ if os.path.exists(ij):
158
+ it = json.load(open(ij, encoding="utf-8"))
159
+ out["ideas"] = [{k: i.get(k) for k in ("title", "type", "priority", "hypothesis", "metric", "how_to_test")} for i in it.get("ideas") or []]
160
+ out["ideas_md"] = os.path.join(run_dir, "ideas.md")
161
+ return out
162
+
163
+
164
+ def list_runs() -> List[Dict[str, Any]]:
165
+ root = os.path.join(home(), "mirofish_runs")
166
+ out = []
167
+ for name in sorted(os.listdir(root)) if os.path.isdir(root) else []:
168
+ p = os.path.join(root, name, "summary.json")
169
+ if os.path.exists(p):
170
+ s = json.load(open(p, encoding="utf-8"))
171
+ out.append({"run": name, "title": (s.get("project_name") or name), "decision": (s.get("decision") or {}).get("decision"),
172
+ "agents": s.get("agents"), "generated_at": s.get("generated_at")})
173
+ return out
174
+
175
+
176
+ def start_live(run: Optional[str]) -> Dict[str, Any]:
177
+ p = LIVE.get("proc")
178
+ if p and p.poll() is None:
179
+ return {"url": "http://127.0.0.1:5050/", "status": "already running"}
180
+ args = ["live"] + (["--run-dir", os.path.join("mirofish_runs", run)] if run else [])
181
+ LIVE["proc"] = subprocess.Popen(["bash", MM, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
182
+ time.sleep(1.5)
183
+ return {"url": "http://127.0.0.1:5050/", "status": "started",
184
+ "hint": "Open the URL in a browser (in the Claude desktop app, the browser pane)."}
185
+
186
+
187
+ def write_config(cfg: Dict[str, Any]) -> str:
188
+ name = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in str(cfg.get("name") or "market_test"))
189
+ d = os.path.join(home(), "scenarios", name)
190
+ os.makedirs(d, exist_ok=True)
191
+ path = os.path.join(d, "market_test.json")
192
+ json.dump(cfg, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
193
+ return path
194
+
195
+
196
+ TOOLS = [
197
+ {"name": "website_test", "description": "Run prepared synthetic visitors on a real website URL. Actions and screenshots are observed; only supplied DOM checks verify goals. Cart changes are enabled by cart/checkout goals; signup submission requires explicit test data and opt-in. No payments/orders. Background job: poll job_status.",
198
+ "inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"},
199
+ "spec": {"type": "object", "properties": {"url": {"type": "string"}, "goal": {"type": "string"},
200
+ "task": {"type": "string"}, "checks": {"type": "array", "items": {"type": "object"}},
201
+ "goal_check": {"type": "string"}, "max_personas": {"type": "integer", "minimum": 1, "maximum": 20},
202
+ "max_steps": {"type": "integer", "minimum": 1, "maximum": 30},
203
+ "device": {"type": "string", "enum": ["mobile", "desktop"]},
204
+ "headless": {"type": "boolean"}, "record_video": {"type": "boolean"},
205
+ "record_trace": {"type": "boolean", "description": "Opt-in raw trace may contain test credentials and network data"},
206
+ "max_recoveries": {"type": "integer", "minimum": 0, "maximum": 5},
207
+ "observation_mode": {"type": "string", "enum": ["compact", "full"]},
208
+ "observation_text_chars": {"type": "integer", "minimum": 1000, "maximum": 10000},
209
+ "max_model_calls": {"type": "integer", "minimum": 1, "maximum": 600},
210
+ "max_input_tokens": {"type": "integer", "minimum": 1, "maximum": 100000000},
211
+ "max_elapsed_s": {"type": "number", "exclusiveMinimum": 0, "maximum": 86400},
212
+ "loop_limit": {"type": "integer", "minimum": 2, "maximum": 10},
213
+ "allow_cart": {"type": "boolean"}, "allow_signup_submit": {"type": "boolean"},
214
+ "test_data": {"type": "object", "description": "Owned test identity only. Multiple signup visitors need an email template containing {visitor}. Never provider API keys."}}, "required": ["url"]},
215
+ "out": {"type": "string"}}, "required": ["simulation_id", "spec"]}},
216
+ {"name": "provider_setup", "description": "List provider choices and official key guides, inspect masked local settings, configure a local model or an existing key environment variable, or discover available models. Never paste an API key into tool arguments. ChatGPT/CLI login choices return local login commands.",
217
+ "inputSchema": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "status", "configure", "check"]},
218
+ "provider": {"type": "string", "enum": ["ollama", "lmstudio", "anthropic", "openai", "compatible", "siwc", "codex", "claude"]},
219
+ "model": {"type": "string"}, "base_url": {"type": "string"}, "key_env": {"type": "string", "description": "Environment variable NAME only; never the key itself"}}, "required": ["action"]}},
220
+ {"name": "doctor", "description": "Check MarketMirror: engine installed, Python deps, API key configured (never shown), backend running.",
221
+ "inputSchema": {"type": "object", "properties": {}}},
222
+ {"name": "setup", "description": "Install or update the MarketMirror engine (git clone + uv sync). Background job; poll job_status. Needs git and uv.",
223
+ "inputSchema": {"type": "object", "properties": {}}},
224
+ {"name": "start_backend", "description": "Start the MarketMirror backend (port 5001). Configure an API-key, eligible ChatGPT sign-in, local CLI gateway, or local-model provider first.",
225
+ "inputSchema": {"type": "object", "properties": {}}},
226
+ {"name": "market_test", "description": (
227
+ "All-in-one market test. Config keys: name, title, seeds (absolute paths to documents), requirement (text), "
228
+ "personas (persona spec object or absolute path) or auto_personas (int), creatives (feed-test object with creatives, "
229
+ "landing, benchmarks), seeds_n (int, repeat the feed test with N seeds and report ranges), rounds (int), survey {offer, goal, messages (list), split, pricing}, panel {analysts, brief}, "
230
+ "report (bool), simulation_id (reuse), approve_spend (bool). Without approve_spend it stops after creating the "
231
+ "personas so you can show them to the user; then call again with simulation_id and approve_spend true. Background job."),
232
+ "inputSchema": {"type": "object", "properties": {"config": {"type": "object"}}, "required": ["config"]}},
233
+ {"name": "job_status", "description": "Status, log tail and final result of a background job.",
234
+ "inputSchema": {"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]}},
235
+ {"name": "results", "description": "Key results of a run: decision, verdicts, segments, objections, messages, pricing, feed-test metrics, ROI projections, files.",
236
+ "inputSchema": {"type": "object", "properties": {"run": {"type": "string", "description": "run name under mirofish_runs or absolute path"}}, "required": ["run"]}},
237
+ {"name": "list_runs", "description": "List previous runs with their decision.", "inputSchema": {"type": "object", "properties": {}}},
238
+ {"name": "start_live", "description": "Serve the live dashboard at http://127.0.0.1:5050/ (newest run, or a given run).",
239
+ "inputSchema": {"type": "object", "properties": {"run": {"type": "string"}}}},
240
+ {"name": "ads_import", "description": "Turn a Meta/Google Ads export (CSV or JSON, absolute path) into a seed Markdown file plus benchmarks JSON.",
241
+ "inputSchema": {"type": "object", "properties": {"file": {"type": "string"}, "out": {"type": "string", "description": "absolute path of the seed .md to write"}}, "required": ["file", "out"]}},
242
+ {"name": "campaign_plan", "description": "Save an evidence-led campaign plan and runnable test specs from a profile or existing project. No campaign is sent.",
243
+ "inputSchema": {"type": "object", "properties": {"profile": {"type": "object"}, "profile_file": {"type": "string"},
244
+ "project": {"type": "string"}, "research": {"type": "object"}, "research_file": {"type": "string"},
245
+ "goal": {"type": "string"}, "budget": {"type": "object"}, "channels": {"type": "array", "items": {"type": "string"}},
246
+ "out": {"type": "string"}}, "required": ["goal", "budget"]}},
247
+ {"name": "actuals_import", "description": "Normalize a local ads, email, website, CRM or survey CSV/JSON export, retaining provenance and row-level validation errors.",
248
+ "inputSchema": {"type": "object", "properties": {"file": {"type": "string"}, "source_type": {"type": "string", "enum": ["ads", "email", "website", "crm", "survey"]},
249
+ "mapping": {"type": "object", "additionalProperties": {"type": "string"}}, "source": {"type": "string"},
250
+ "market": {"type": "string"}, "date": {"type": "string"}, "quality": {"type": "string"},
251
+ "out": {"type": "string"}}, "required": ["file", "source_type"]}},
252
+ {"name": "holdout_backtest", "description": "Evaluate independent held-out real experiments against a training-mean no-persona baseline.",
253
+ "inputSchema": {"type": "object", "properties": {"pairs": {"type": "array", "items": {"type": "object"}},
254
+ "out": {"type": "string"}}, "required": ["pairs"]}},
255
+ {"name": "backtest", "description": "Score a run's feed test against real ad results (from ads_import): rank correlation, winner match, calibration.",
256
+ "inputSchema": {"type": "object", "properties": {"run": {"type": "string"}, "actuals": {"type": "string"}, "map": {"type": "array", "items": {"type": "string"}}}, "required": ["run", "actuals"]}},
257
+ {"name": "understand", "description": (
258
+ "Step one for any product: read the user's documents (absolute paths to .md/.txt), a landing page URL and/or an ads "
259
+ "export into one product profile (what it is, for whom, pricing, offer, proofs, differentiators, competitors, channels, "
260
+ "likely objections, brand voice, KPIs, unknowns). Every later step uses it. Synchronous, one Claude call. Writes "
261
+ "product_profile.json; pass `project` instead of docs to profile an existing project (also seeds the market map before "
262
+ "the graph build)."),
263
+ "inputSchema": {"type": "object", "properties": {"docs": {"type": "array", "items": {"type": "string"}}, "url": {"type": "string"},
264
+ "ads_file": {"type": "string"}, "project": {"type": "string"}, "name": {"type": "string"},
265
+ "out": {"type": "string"}}}},
266
+ {"name": "quickstart", "description": (
267
+ "Easiest start. Answer a few questions and get a ready scenario folder (brief, personas, two template captions per "
268
+ "platform, messages, launch calendar, pitch) plus the simulated people (stops before any paid rounds). Keys: brand, "
269
+ "product (what, for whom, price), buyers (who and where), platforms (comma list), tests (ads, messages, price, launch, "
270
+ "pitch), budget ('300 USD'), size (small|medium|large), past_ads (absolute path, optional), docs (paths, optional). "
271
+ "Background job; poll job_status. The result names the files to edit and the exact next command."),
272
+ "inputSchema": {"type": "object", "properties": {"answers": {"type": "object"}, "no_run": {"type": "boolean"},
273
+ "from_docs": {"type": "array", "items": {"type": "string"}, "description": "documents to understand first; prefills the answers"},
274
+ "url": {"type": "string"}}, "required": ["answers"]}},
275
+ {"name": "pitch", "description": (
276
+ "Sales rehearsal: pitch a script to every simulated buyer of a simulation (its environment must be alive: run market_test "
277
+ "with rounds first, or start it), collect objections and the questions they ask back, then answer with rebuttals "
278
+ "({objection_type: text, '*': generic}). Scores the pitch, each rebuttal, and every person (lead score). Background job."),
279
+ "inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "run": {"type": "string"},
280
+ "script": {"type": "string"}, "rebuttals": {"type": "object"},
281
+ "product": {"type": "string"}, "channel": {"type": "string"},
282
+ "segment": {"type": "string"}}, "required": ["simulation_id", "script"]}},
283
+ {"name": "ideas", "description": (
284
+ "Next test ideas from real data: pass ad rows you fetched from a connected ads tool (Meta/Google/TikTok: name, campaign, "
285
+ "spend, impressions, clicks, conversions, country...) and/or an export file path, plus a previous run (its objections, "
286
+ "audiences and decision). Returns prioritised experiments, each citing its evidence, with the MarketMirror config to "
287
+ "simulate it first. Writes ideas.json/.md into the run folder and adds a 'Test ideas' section to the dashboard."),
288
+ "inputSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object"}}, "ads_file": {"type": "string"},
289
+ "run": {"type": "string"}, "brand": {"type": "string"}, "brief": {"type": "string"},
290
+ "n": {"type": "integer"}}}},
291
+ {"name": "push_context", "description": (
292
+ "While a simulation is running (or before it starts), push what people are saying today into every persona card from the next "
293
+ "round on: a news item, a competitor's move, a real thread you just read with your browser, a price change. Each update is "
294
+ "one or two sentences; the runner re-reads the file every round. Use it to ground the rounds in live reality."),
295
+ "inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "text": {"type": "string"}, "round": {"type": "integer"},
296
+ "source": {"type": "string"}}, "required": ["simulation_id", "text"]}},
297
+ {"name": "research", "description": (
298
+ "What real people say about a product, category or competitor, from public keyless sources: Hacker News stories and "
299
+ "comments, App Store listings and recent reviews for a country storefront (in, ph, ke, us...), Reddit public search when "
300
+ "it answers (it often blocks non-browser clients; the result says so, then use your own browser tools and add what you find "
301
+ "as seed files), and any page URLs. Writes research.md (themes with quotes and links), research.json and voices.json (short "
302
+ "real quotes the personas and feeds draw on) under scenarios/<query>/research. Run it before planning personas."),
303
+ "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "sources": {"type": "string"}, "country": {"type": "string"},
304
+ "subreddits": {"type": "array", "items": {"type": "string"}}, "urls": {"type": "array", "items": {"type": "string"}},
305
+ "out": {"type": "string"}}, "required": ["query"]}},
306
+ {"name": "predict", "description": (
307
+ "Predictions with intervals for a run: CTR per ad, cost per lead, would-convert overall and per audience, email click rates, "
308
+ "each scaled by the real-vs-simulated results stored so far (from backtest and calibrate) and labelled with its basis "
309
+ "('calibrated on N real results' or 'uncalibrated prior'); optional targets (e.g. {ctr: 2, cpl: 10}) give a probability of "
310
+ "success. Also a 30/60/90-day adoption forecast from the best ad, its landing conversion and the word of mouth seen in the rounds."),
311
+ "inputSchema": {"type": "object", "properties": {"run": {"type": "string"}, "brand": {"type": "string"}, "targets": {"type": "object"},
312
+ "spend": {"type": "number"}, "days": {"type": "integer"}}, "required": ["run"]}},
313
+ {"name": "study", "description": (
314
+ "Several scenarios x several seeds on the same simulated people, one defended conclusion. spec: {name, base (a market_test "
315
+ "config object: creatives, survey, pitch, emails, rounds...), simulation_id (the people to reuse), scenarios: [{name, overrides}], "
316
+ "seeds: [1,2,3], steps: [feed_test, rounds, survey, pitch, email_test]}. Writes study_summary.json (per-scenario mean, spread and "
317
+ "95% interval of every metric, deltas, winner stability, decision agreement matrix, conclusion with confidence), study_report.md "
318
+ "and study.html under mirofish_runs/<name>/. Finished cells are reused on rerun. Background job."),
319
+ "inputSchema": {"type": "object", "properties": {"spec": {"type": "object"}, "out": {"type": "string"}}, "required": ["spec"]}},
320
+ {"name": "run_test", "description": (
321
+ "Test anything from one spec on a prepared simulation: type = ad | message | price | offer | positioning | naming | landing | "
322
+ "launch | pitch | email | creator_post | channel_mix; variants = the things to compare (ad creatives, message lines, prices, "
323
+ "offer wordings, names, landing pages, launch posts with rounds, pitch scripts, emails); audience {segments, countries}; "
324
+ "metric (default per type); budget {amount, cpm, currency}; rounds (launch). Returns one result shape for every type: each "
325
+ "variant's metric with a 95% margin, the winner, the lift, whether it is clear or a tie, and how to read it. Background job."),
326
+ "inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "spec": {"type": "object"}, "brand": {"type": "string"},
327
+ "run": {"type": "string"}}, "required": ["simulation_id", "spec"]}},
328
+ {"name": "email_test", "description": (
329
+ "Email campaign test on the simulated people's inboxes: pass emails [{id, from_name, subject, preheader, body, cta, "
330
+ "send_time (morning|midday|evening|night), segments?, sequence?: [{delay_days, subject, body}]}] plus optional list_size and "
331
+ "benchmarks {cost_per_send, lead_value, close_rate, deal_value}. Each person gets a realistic inbox for the day; an "
332
+ "attention gate decides opens, Claude decides skim/read/click/reply/unsubscribe/spam with honest odds. Returns open, click, "
333
+ "click-to-open, unsubscribe and spam rates per variant and audience, subject-line ranking, sequence funnel, projection from "
334
+ "list size to leads and revenue, spam-word warnings. Needs a prepared simulation (simulation_id). Background job."),
335
+ "inputSchema": {"type": "object", "properties": {"simulation_id": {"type": "string"}, "run": {"type": "string"},
336
+ "emails": {"type": "array", "items": {"type": "object"}}, "list_size": {"type": "integer"},
337
+ "benchmarks": {"type": "object"}}, "required": ["simulation_id", "emails"]}},
338
+ {"name": "calibrate", "description": (
339
+ "Check a run's simulated survey against real answers (absolute path to a CSV with segment,country,gender,age,y where y is "
340
+ "yes/maybe/no or 0-1; 50-300 rows is enough): covariate overlap, per-audience calibration, a trust / correct / walk-away "
341
+ "verdict and an AIPW-corrected rate. Writes calibration.json into the run folder."),
342
+ "inputSchema": {"type": "object", "properties": {"run": {"type": "string"}, "real_csv": {"type": "string"},
343
+ "field": {"type": "string"}, "by": {"type": "string"},
344
+ "map": {"type": "array", "items": {"type": "string"}}}, "required": ["run", "real_csv"]}},
345
+ {"name": "cli", "description": "Advanced: run any MarketMirror CLI command as a background job, e.g. ['survey','--simulation','sim_x','--offer','...']. See CLAUDE.md in the engine.",
346
+ "inputSchema": {"type": "object", "properties": {"args": {"type": "array", "items": {"type": "string"}}}, "required": ["args"]}},
347
+ ]
348
+
349
+
350
+ def call(name: str, a: Dict[str, Any]) -> Any:
351
+ if name == "website_test":
352
+ directory = os.path.join(home(), "scenarios", "_website_tests")
353
+ os.makedirs(directory, exist_ok=True)
354
+ path = os.path.join(directory, uuid.uuid4().hex[:12] + ".json")
355
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
356
+ with os.fdopen(fd, "w", encoding="utf-8") as output:
357
+ json.dump(a["spec"], output, ensure_ascii=False)
358
+ args = ["cli", "website-test", "--simulation", a["simulation_id"], "--spec", "@" + path]
359
+ if a.get("out"):
360
+ args += ["--out", str(a["out"])]
361
+ return job(args, "website-test")
362
+ if name == "provider_setup":
363
+ args = ["provider", a["action"]]
364
+ for key in ("provider", "model", "base_url", "key_env"):
365
+ if a.get(key):
366
+ args += ["--" + key.replace("_", "-"), str(a[key])]
367
+ return _cli_result(sh(args, timeout=30))
368
+ if name == "doctor":
369
+ r = sh(["doctor"])
370
+ return {"ok": r["ok"], "diagnostics": (r["stderr"] + r["stdout"]).strip()}
371
+ if name == "setup":
372
+ return job(["setup"], "setup")
373
+ if name == "start_backend":
374
+ r = sh(["start"], timeout=300)
375
+ return _cli_result(r)
376
+ if name == "market_test":
377
+ path = write_config(a["config"])
378
+ return {**job(["cli", "market-test", path], "market-test"), "config": path}
379
+ if name == "job_status":
380
+ return job_status(a["job_id"])
381
+ if name == "results":
382
+ return results(a["run"])
383
+ if name == "list_runs":
384
+ return list_runs()
385
+ if name == "start_live":
386
+ return start_live(a.get("run"))
387
+ if name == "ads_import":
388
+ r = sh(["cli", "ads-import", a["file"], "--out", a["out"]], timeout=300)
389
+ return _cli_result(r)
390
+ if name == "campaign_plan":
391
+ if not (a.get("profile") or a.get("profile_file") or a.get("project")):
392
+ raise ValueError("profile, profile_file, or project is required")
393
+ d = os.path.join(home(), "scenarios", "_plans")
394
+ os.makedirs(d, exist_ok=True)
395
+ args = ["cli", "campaign-plan", "--goal", str(a["goal"]), "--budget", str(a["budget"]["amount"]),
396
+ "--currency", str(a["budget"].get("currency") or "USD")]
397
+ if a.get("profile"):
398
+ p = os.path.join(d, f"profile_{uuid.uuid4().hex[:8]}.json")
399
+ with open(p, "w", encoding="utf-8") as f:
400
+ json.dump(a["profile"], f, ensure_ascii=False)
401
+ args += ["--profile", p]
402
+ elif a.get("profile_file"):
403
+ args += ["--profile", str(a["profile_file"])]
404
+ else:
405
+ args += ["--project", str(a["project"])]
406
+ if a.get("research"):
407
+ p = os.path.join(d, f"research_{uuid.uuid4().hex[:8]}.json")
408
+ with open(p, "w", encoding="utf-8") as f:
409
+ json.dump(a["research"], f, ensure_ascii=False)
410
+ args += ["--research", p]
411
+ elif a.get("research_file"):
412
+ args += ["--research", str(a["research_file"])]
413
+ if a.get("channels"):
414
+ args += ["--channels", *[str(c) for c in a["channels"]]]
415
+ if a["budget"].get("period"):
416
+ args += ["--period", str(a["budget"]["period"])]
417
+ args += ["--out", str(a.get("out") or os.path.join(d, "campaign_plan.json"))]
418
+ r = sh(args, timeout=300)
419
+ return _cli_result(r)
420
+ if name == "actuals_import":
421
+ args = ["cli", "actuals-import", str(a["file"]), "--source-type", str(a["source_type"])]
422
+ if a.get("mapping"):
423
+ args += ["--map", *[f"{k}={v}" for k, v in a["mapping"].items()]]
424
+ for key in ("source", "market", "date", "quality", "out"):
425
+ if a.get(key):
426
+ args += ["--" + key, str(a[key])]
427
+ return _cli_result(sh(args, timeout=300))
428
+ if name == "holdout_backtest":
429
+ d = os.path.join(home(), "scenarios", "_backtests")
430
+ os.makedirs(d, exist_ok=True)
431
+ path = os.path.join(d, f"pairs_{uuid.uuid4().hex[:8]}.json")
432
+ with open(path, "w", encoding="utf-8") as f:
433
+ json.dump(a["pairs"], f, ensure_ascii=False)
434
+ args = ["cli", "holdout-backtest", "--pairs", path]
435
+ if a.get("out"):
436
+ args += ["--out", str(a["out"])]
437
+ return _cli_result(sh(args, timeout=60))
438
+ if name == "backtest":
439
+ run_dir = a["run"] if os.path.isabs(a["run"]) else os.path.join("mirofish_runs", a["run"])
440
+ r = sh(["cli", "backtest", "--run-dir", run_dir, "--actuals", a["actuals"], *(["--map", *a["map"]] if a.get("map") else [])], timeout=300)
441
+ return _cli_result(r)
442
+ if name == "understand":
443
+ d = os.path.join(home(), "scenarios", "_profiles")
444
+ os.makedirs(d, exist_ok=True)
445
+ out = a.get("out") or os.path.join(d, f"profile_{uuid.uuid4().hex[:6]}.json")
446
+ args = ["cli", "understand", "--out", out]
447
+ if a.get("project"):
448
+ args += ["--project", a["project"]]
449
+ if a.get("docs"):
450
+ args += ["--docs", *[str(x) for x in a["docs"]]]
451
+ for k in ("url", "name"):
452
+ if a.get(k):
453
+ args += ["--" + k, str(a[k])]
454
+ if a.get("ads_file"):
455
+ args += ["--ads", a["ads_file"]]
456
+ r = sh(args, timeout=400)
457
+ return _cli_result(r)
458
+ if name == "quickstart":
459
+ d = os.path.join(home(), "scenarios", "_quickstart")
460
+ os.makedirs(d, exist_ok=True)
461
+ path = os.path.join(d, f"answers_{uuid.uuid4().hex[:6]}.json")
462
+ json.dump(a["answers"], open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
463
+ args = ["cli", "quickstart", "--answers", "@" + path] + (["--no-run"] if a.get("no_run") else [])
464
+ if a.get("from_docs"):
465
+ args += ["--from-docs", *[str(x) for x in a["from_docs"]]]
466
+ if a.get("url"):
467
+ args += ["--url", str(a["url"])]
468
+ return {**job(args, "quickstart"), "answers": path}
469
+ if name == "pitch":
470
+ run_dir = a.get("run")
471
+ run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
472
+ d = os.path.join(home(), "scenarios", "_pitch")
473
+ os.makedirs(d, exist_ok=True)
474
+ sp = os.path.join(d, f"pitch_{uuid.uuid4().hex[:6]}.md")
475
+ open(sp, "w", encoding="utf-8").write(a["script"])
476
+ args = ["cli", "pitch", "--simulation", a["simulation_id"], "--script", "@" + sp]
477
+ if a.get("rebuttals"):
478
+ rp = sp.replace("pitch_", "rebuttals_").replace(".md", ".json")
479
+ json.dump(a["rebuttals"], open(rp, "w", encoding="utf-8"), ensure_ascii=False)
480
+ args += ["--rebuttals", "@" + rp]
481
+ for k in ("product", "channel", "segment"):
482
+ if a.get(k):
483
+ args += ["--" + k, str(a[k])]
484
+ if run_dir:
485
+ args += ["--run-dir", run_dir]
486
+ return job(args, "pitch")
487
+ if name == "ideas":
488
+ run_dir = a.get("run")
489
+ run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
490
+ args = ["cli", "ideas"]
491
+ if a.get("rows"):
492
+ d = os.path.join(home(), "scenarios", "_ideas")
493
+ os.makedirs(d, exist_ok=True)
494
+ rp = os.path.join(d, f"rows_{uuid.uuid4().hex[:6]}.json")
495
+ json.dump(a["rows"], open(rp, "w", encoding="utf-8"), ensure_ascii=False)
496
+ args += ["--rows", "@" + rp]
497
+ if a.get("ads_file"):
498
+ args += ["--ads", a["ads_file"]]
499
+ if run_dir:
500
+ args += ["--run-dir", run_dir]
501
+ for k in ("brand", "brief"):
502
+ if a.get(k):
503
+ args += ["--" + k, str(a[k])]
504
+ if a.get("n"):
505
+ args += ["--n", str(int(a["n"]))]
506
+ r = sh(args, timeout=400)
507
+ return _cli_result(r)
508
+ if name == "push_context":
509
+ args = ["cli", "context", "--simulation", a["simulation_id"], "--text", a["text"]]
510
+ if a.get("round") is not None:
511
+ args += ["--round", str(int(a["round"]))]
512
+ if a.get("source"):
513
+ args += ["--source", str(a["source"])]
514
+ r = sh(args, timeout=60)
515
+ return _cli_result(r)
516
+ if name == "research":
517
+ args = ["cli", "research", "--query", a["query"]]
518
+ if a.get("sources"):
519
+ args += ["--sources", str(a["sources"])]
520
+ if a.get("country"):
521
+ args += ["--country", str(a["country"])]
522
+ if a.get("subreddits"):
523
+ args += ["--subreddit", *[str(x) for x in a["subreddits"]]]
524
+ if a.get("urls"):
525
+ args += ["--url", *[str(x) for x in a["urls"]]]
526
+ if a.get("out"):
527
+ args += ["--out", str(a["out"])]
528
+ r = sh(args, timeout=400)
529
+ return _cli_result(r)
530
+ if name == "predict":
531
+ run_dir = a["run"] if os.path.isabs(a["run"]) else os.path.join("mirofish_runs", a["run"])
532
+ args = ["cli", "predict", "--run-dir", run_dir]
533
+ if a.get("brand"):
534
+ args += ["--brand", str(a["brand"])]
535
+ if a.get("targets"):
536
+ args += ["--target", *[f"{k}={v}" for k, v in a["targets"].items()]]
537
+ if a.get("spend"):
538
+ args += ["--spend", str(a["spend"])]
539
+ if a.get("days"):
540
+ args += ["--days", str(int(a["days"]))]
541
+ r = sh(args, timeout=300)
542
+ return _cli_result(r)
543
+ if name == "study":
544
+ d = os.path.join(home(), "scenarios", "_studies")
545
+ os.makedirs(d, exist_ok=True)
546
+ sp = os.path.join(d, f"study_{uuid.uuid4().hex[:6]}.json")
547
+ json.dump(a["spec"], open(sp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
548
+ args = ["cli", "study", "--spec", sp]
549
+ if a.get("out"):
550
+ args += ["--out", a["out"]]
551
+ return job(args, "study")
552
+ if name == "run_test":
553
+ run_dir = a.get("run")
554
+ run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
555
+ d = os.path.join(home(), "scenarios", "_tests")
556
+ os.makedirs(d, exist_ok=True)
557
+ sp = os.path.join(d, f"test_{uuid.uuid4().hex[:6]}.json")
558
+ json.dump(a["spec"], open(sp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
559
+ args = ["cli", "test", "--simulation", a["simulation_id"], "--spec", "@" + sp]
560
+ if a.get("brand"):
561
+ args += ["--brand", str(a["brand"])]
562
+ if run_dir:
563
+ args += ["--run-dir", run_dir]
564
+ return job(args, f"test {a['spec'].get('type', '')}")
565
+ if name == "email_test":
566
+ run_dir = a.get("run")
567
+ run_dir = (run_dir if os.path.isabs(run_dir) else os.path.join("mirofish_runs", run_dir)) if run_dir else None
568
+ d = os.path.join(home(), "scenarios", "_emails")
569
+ os.makedirs(d, exist_ok=True)
570
+ ep = os.path.join(d, f"emails_{uuid.uuid4().hex[:6]}.json")
571
+ json.dump({"emails": a["emails"], "list_size": a.get("list_size"), "benchmarks": a.get("benchmarks")}, open(ep, "w", encoding="utf-8"), ensure_ascii=False)
572
+ args = ["cli", "emailtest", "--simulation", a["simulation_id"], "--emails", "@" + ep]
573
+ if a.get("list_size"):
574
+ args += ["--list-size", str(int(a["list_size"]))]
575
+ if run_dir:
576
+ args += ["--run-dir", run_dir]
577
+ return job(args, "email-test")
578
+ if name == "calibrate":
579
+ run_dir = a["run"] if os.path.isabs(a["run"]) else os.path.join("mirofish_runs", a["run"])
580
+ args = ["cli", "calibrate", "--run-dir", run_dir, "--real", a["real_csv"]]
581
+ for k in ("field", "by"):
582
+ if a.get(k):
583
+ args += ["--" + k, str(a[k])]
584
+ if a.get("map"):
585
+ args += ["--map", *[str(x) for x in a["map"]]]
586
+ r = sh(args, timeout=300)
587
+ return _cli_result(r)
588
+ if name == "cli":
589
+ return job(["cli", *[str(x) for x in a["args"]]], "cli " + " ".join(map(str, a["args"][:2])))
590
+ raise ValueError(f"unknown tool {name}")
591
+
592
+
593
+ def reply(msg_id: Any, result: Any = None, error: Optional[Dict[str, Any]] = None) -> None:
594
+ out = {"jsonrpc": "2.0", "id": msg_id, **({"error": error} if error else {"result": result})}
595
+ sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
596
+ sys.stdout.flush()
597
+
598
+
599
+ def dispatch(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
600
+ method, mid = msg.get("method"), msg.get("id")
601
+ if mid is None:
602
+ return None # notification
603
+ if method == "initialize":
604
+ requested = (msg.get("params") or {}).get("protocolVersion")
605
+ result = {"protocolVersion": requested if requested in SUPPORTED_PROTOCOLS else PROTOCOL,
606
+ "capabilities": {"tools": {}},
607
+ "serverInfo": {"name": "market-mirror", "version": "0.4.0"},
608
+ "instructions": "MarketMirror tests marketing and sales decisions on simulated buyers. Start with doctor; "
609
+ "run setup if the engine is missing; use market_test for everything; show the user the "
610
+ "personas before approving spend; report results as simulated directions with their margins."}
611
+ elif method == "ping":
612
+ result = {}
613
+ elif method == "tools/list":
614
+ result = {"tools": TOOLS}
615
+ elif method == "tools/call":
616
+ p = msg.get("params") or {}
617
+ try:
618
+ res = call(p.get("name"), p.get("arguments") or {})
619
+ text = res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, indent=2, default=str)
620
+ result = {"content": [{"type": "text", "text": text}], "isError": False}
621
+ if isinstance(res, (dict, list)):
622
+ result["structuredContent"] = res
623
+ except Exception as e: # noqa: BLE001 - surfaced to the client as a tool error
624
+ result = {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}], "isError": True,
625
+ "structuredContent": {"error": str(e), "error_type": type(e).__name__}}
626
+ else:
627
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}}
628
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
629
+
630
+
631
+ def handle(msg: Dict[str, Any]) -> None:
632
+ out = dispatch(msg)
633
+ if out is not None:
634
+ sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
635
+ sys.stdout.flush()
636
+
637
+
638
+ def serve_http(port: int, token: str) -> None:
639
+ """Stateless MCP Streamable HTTP on authenticated loopback only."""
640
+ if not token or len(token) < 24:
641
+ raise ValueError("MCP bearer token must contain at least 24 characters")
642
+
643
+ class Handler(BaseHTTPRequestHandler):
644
+ def do_POST(self):
645
+ if self.path != "/mcp":
646
+ self.send_error(404)
647
+ return
648
+ given = self.headers.get("Authorization", "")
649
+ if not hmac.compare_digest(given, "Bearer " + token):
650
+ self.send_response(401)
651
+ self.send_header("WWW-Authenticate", "Bearer")
652
+ self.end_headers()
653
+ return
654
+ origin = self.headers.get("Origin", "")
655
+ if origin and not (origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:")):
656
+ self.send_error(403)
657
+ return
658
+ try:
659
+ size = int(self.headers.get("Content-Length", "0"))
660
+ if size <= 0 or size > 1_000_000:
661
+ raise ValueError("invalid request size")
662
+ body = json.loads(self.rfile.read(size))
663
+ if not isinstance(body, dict):
664
+ raise ValueError("JSON-RPC request must be an object")
665
+ out = dispatch(body)
666
+ except (ValueError, UnicodeDecodeError) as e:
667
+ self.send_error(400, str(e))
668
+ return
669
+ if out is None:
670
+ self.send_response(202)
671
+ self.end_headers()
672
+ return
673
+ payload = json.dumps(out, ensure_ascii=False).encode("utf-8")
674
+ self.send_response(200)
675
+ self.send_header("Content-Type", "application/json")
676
+ self.send_header("Content-Length", str(len(payload)))
677
+ self.end_headers()
678
+ self.wfile.write(payload)
679
+
680
+ def do_GET(self):
681
+ self.send_error(405)
682
+
683
+ def log_message(self, fmt, *args):
684
+ print(fmt % args, file=sys.stderr)
685
+
686
+ ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
687
+
688
+
689
+ def main() -> None:
690
+ parser = argparse.ArgumentParser(description="MarketMirror MCP server")
691
+ parser.add_argument("--http", action="store_true", help="serve authenticated MCP over loopback HTTP")
692
+ parser.add_argument("--port", type=int, default=5061)
693
+ parser.add_argument("--token-file", help="file containing a bearer token (required for --http)")
694
+ opts = parser.parse_args()
695
+ if opts.http:
696
+ if not opts.token_file:
697
+ parser.error("--http requires --token-file")
698
+ with open(opts.token_file, encoding="utf-8") as f:
699
+ token = f.read().strip()
700
+ serve_http(opts.port, token)
701
+ return
702
+ lock = threading.Lock()
703
+ for line in sys.stdin:
704
+ line = line.strip()
705
+ if not line:
706
+ continue
707
+ try:
708
+ msg = json.loads(line)
709
+ except ValueError:
710
+ continue
711
+ with lock:
712
+ handle(msg)
713
+
714
+
715
+ if __name__ == "__main__":
716
+ main()
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # mm.sh - MarketMirror helper for agents: simulated buyers, ad/message/price tests (MiroFish engine).
3
+ #
4
+ # mm.sh setup clone or update the engine, install Python deps, create .env
5
+ # mm.sh doctor check repo, deps, API key (never printed) and backend
6
+ # mm.sh configure choose local models, hosted API, or a login gateway (hidden key input)
7
+ # mm.sh provider ACTION list/status/check/configure provider settings; use --key-env for agent setup
8
+ # mm.sh agent-config print portable MCP config (--client codex|claude|cursor|gemini|generic)
9
+ # mm.sh start | stop [--port N] [--env-file F] start or stop a backend (default port 5001; other ports keep
10
+ # their data in backend/uploads-<port>, so several backends run side by side)
11
+ # mm.sh scenario NAME FILE... copy documents into <home>/scenarios/NAME/ and print that folder
12
+ # mm.sh cli ARGS... run the MiroFish CLI from the engine folder (see its --help)
13
+ # mm.sh live [ARGS...] serve the live dashboard on http://127.0.0.1:5050/ (foreground)
14
+ # mm.sh gateway login|start|stop|status [claude|codex|gemini|antigravity|chatgpt|siwc|multi] [--port N] [--model M]
15
+ # local login gateway (default port 5055, writes .env); --port N runs another one
16
+ # side by side and writes .env.<backend> for a backend started with --env-file
17
+ # mm.sh home print the engine folder
18
+ # mm.sh mcp run the MCP server (stdio) exposing all of this as agent tools
19
+ #
20
+ # Environment: MARKET_MIRROR_HOME (default ~/.market-mirror/engine; MIROFISH_HOME also accepted),
21
+ # MARKET_MIRROR_REPO (default https://github.com/DarkWorldCoder/market-mirror.git)
22
+ set -euo pipefail
23
+
24
+ REPO_URL="${MARKET_MIRROR_REPO:-${MIROFISH_REPO:-https://github.com/DarkWorldCoder/market-mirror.git}}"
25
+ HOME_DIR="${MARKET_MIRROR_HOME:-${MIROFISH_HOME:-}}"
26
+ if [ -z "$HOME_DIR" ]; then
27
+ registry="${MARKET_MIRROR_CONFIG_DIR:-$HOME/.market-mirror}/engine.json"
28
+ HOME_DIR=$(python3 - "$registry" <<'PY'
29
+ import json,sys
30
+ from pathlib import Path
31
+ try:
32
+ candidate = Path(json.loads(Path(sys.argv[1]).read_text())["path"])
33
+ if candidate.is_absolute() and (candidate / "backend/claude_cli/mirofish.py").is_file():
34
+ print(candidate)
35
+ except (OSError, ValueError, KeyError, TypeError):
36
+ pass
37
+ PY
38
+ )
39
+ HOME_DIR="${HOME_DIR:-$HOME/.market-mirror/engine}"
40
+ fi
41
+ PY="$HOME_DIR/backend/.venv/bin/python"
42
+ CLI="$HOME_DIR/backend/claude_cli/mirofish.py"
43
+
44
+ say() { printf '%s\n' "$*" >&2; }
45
+ die() { say "error: $*"; exit 1; }
46
+
47
+ key_status() {
48
+ # Reports whether a key is configured without ever reading it into a variable or printing it.
49
+ local env="$HOME_DIR/.env"
50
+ # hosted OpenAI-compatible providers (API_PROVIDERS in backend/app/utils/model_pool.py)
51
+ local hosted="DEEPSEEK MOONSHOT KIMI MISTRAL XAI GROK GROQ TOGETHER FIREWORKS OPENROUTER DASHSCOPE QWEN ZAI ZHIPU CEREBRAS MINIMAX"
52
+ if [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GOOGLE_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${LLM_API_KEY:-}" ]; then echo "set (environment)"; return; fi
53
+ local name; for name in $hosted; do
54
+ name="${name}_API_KEY"
55
+ if [ -n "${!name:-}" ]; then echo "set (environment)"; return; fi
56
+ done
57
+ [ -f "$env" ] || { echo "missing (.env not created)"; return; }
58
+ if grep -Eq "^(${hosted// /|})_API_KEY=[[:space:]]*[\"']?[^\"'[:space:]]" "$env"; then echo "set (.env)"; return; fi
59
+ if grep -Eq '^LLM_API_KEY=[[:space:]]*("|'"'"')?(your_api_key_here)?("|'"'"')?[[:space:]]*$' "$env" \
60
+ || ! grep -Eq '^LLM_API_KEY=' "$env"; then
61
+ echo "missing (edit LLM_API_KEY in $env)"
62
+ else
63
+ echo "set (.env)"
64
+ fi
65
+ }
66
+
67
+ backend_up() { curl -fsS -m 3 "${MIROFISH_URL:-http://localhost:5001}/health" >/dev/null 2>&1; }
68
+
69
+ doctor() {
70
+ local ok=1
71
+ [ -d "$HOME_DIR/.git" ] && say "engine: $HOME_DIR" || { say "engine: missing (run: mm.sh setup)"; ok=0; }
72
+ [ -x "$PY" ] && say "python: $("$PY" --version 2>&1)" || { say "python: deps not installed (run: mm.sh setup)"; ok=0; }
73
+ local k; k=$(key_status); say "model credential: $k"
74
+ case $k in set*) ;; *)
75
+ if grep -Eq '^LLM_BASE_URL=http://127\.0\.0\.1:5055' "$HOME_DIR/.env" 2>/dev/null; then
76
+ say "gateway: configured (LLM_GATEWAY=$(grep -E '^LLM_GATEWAY=' "$HOME_DIR/.env" | cut -d= -f2-)); $(curl -fsS -m 2 http://127.0.0.1:5055/health >/dev/null 2>&1 && echo running || echo 'stopped (run: mm.sh gateway start)')"
77
+ else
78
+ ok=0
79
+ for cli in claude codex antigravity gemini; do
80
+ if [ -x "$PY" ] && "$PY" "$HOME_DIR/backend/gateway/local_gateway.py" --backend "$cli" --check >/dev/null 2>&1; then
81
+ say "no key, but the $cli CLI is installed: 'mm.sh gateway start $cli' runs on its login (personal use; add --port N to run several side by side)"
82
+ fi
83
+ done
84
+ say "multi-agent pool: 'mm.sh gateway start multi' pools all installed local CLIs together"
85
+ fi ;;
86
+ esac
87
+ if [ -f "$HOME_DIR/.env" ]; then
88
+ say "model: $(grep -E '^LLM_MODEL_NAME=' "$HOME_DIR/.env" | cut -d= -f2- || true)"
89
+ say "memory: $(grep -E '^MEMORY_BACKEND=' "$HOME_DIR/.env" | cut -d= -f2- || true)"
90
+ fi
91
+ backend_up && say "backend: running" || say "backend: stopped (run: mm.sh start)"
92
+ [ $ok -eq 1 ] && say "ready" || { say "not ready"; return 1; }
93
+ }
94
+
95
+ cmd="${1:-help}"; shift || true
96
+ case "$cmd" in
97
+ home) echo "$HOME_DIR" ;;
98
+ setup)
99
+ command -v git >/dev/null || die "git is required"
100
+ if [ -d "$HOME_DIR/.git" ]; then
101
+ say "using existing checkout $HOME_DIR (update separately with git pull --ff-only)"
102
+ else
103
+ say "cloning $REPO_URL -> $HOME_DIR"; mkdir -p "$(dirname "$HOME_DIR")"; git clone -q --depth 1 "$REPO_URL" "$HOME_DIR"
104
+ fi
105
+ command -v uv >/dev/null || die "uv is required. Install it with one of: 'brew install uv', 'pipx install uv', 'pip install uv' (see https://docs.astral.sh/uv/), then run setup again"
106
+ say "installing Python deps (first time takes a few minutes)"
107
+ (cd "$HOME_DIR/backend" && uv sync -q --python 3.12)
108
+ [ -f "$HOME_DIR/.env" ] || { cp "$HOME_DIR/.env.example" "$HOME_DIR/.env"; say "created $HOME_DIR/.env"; }
109
+ doctor || true
110
+ ;;
111
+ doctor) doctor ;;
112
+ agent-config)
113
+ exec python3 "$HOME_DIR/backend/claude_cli/agent_install.py" --root "$HOME_DIR" "$@"
114
+ ;;
115
+ configure|provider)
116
+ action="configure"
117
+ if [ "$cmd" = provider ]; then action="${1:-list}"; [ $# -eq 0 ] || shift; fi
118
+ provider_py="$PY"; [ -x "$provider_py" ] || provider_py="$(command -v python3)"
119
+ exec "$provider_py" "$HOME_DIR/backend/claude_cli/providers.py" "$action" --root "$HOME_DIR" "$@"
120
+ ;;
121
+ start)
122
+ [ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
123
+ case " $* " in
124
+ *" --env-file"*) ;; # a per-instance settings file (written by: mm.sh gateway start BACKEND --port N)
125
+ *) case $(key_status) in set*) ;; *) die "provider not configured; run mm.sh configure (local models and login gateways are available)" ;; esac ;;
126
+ esac
127
+ cd "$HOME_DIR" && bash backend/claude_cli/start_server.sh "$@"
128
+ ;;
129
+ stop) cd "$HOME_DIR" && bash backend/claude_cli/stop_server.sh "$@" ;;
130
+ scenario)
131
+ name="${1:-}"; [ -n "$name" ] || die "usage: mm.sh scenario NAME FILE..."; shift
132
+ case "$name" in .*|*[!A-Za-z0-9._-]*) die "scenario name must start with a letter or digit and use only letters, digits, . _ -" ;; esac
133
+ dir="$HOME_DIR/scenarios/$name"; mkdir -p "$dir"
134
+ for f in "$@"; do [ -f "$f" ] || die "not a file: $f"; cp "$f" "$dir/"; done
135
+ echo "$dir"
136
+ ;;
137
+ cli)
138
+ [ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
139
+ cd "$HOME_DIR" && exec "$PY" "$CLI" "$@"
140
+ ;;
141
+ live)
142
+ [ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
143
+ cd "$HOME_DIR" && exec "$PY" "$CLI" live "$@"
144
+ ;;
145
+ gateway)
146
+ [ -x "$PY" ] || die "not set up yet (run: mm.sh setup)"
147
+ gw_usage="usage: mm.sh gateway login|start|stop|status [claude|codex|gemini|antigravity|chatgpt|siwc|multi] [--port N] [--model M] [--env-file F]"
148
+ sub="${1:-status}"; [ $# -eq 0 ] || shift
149
+ be=""; port="5055"; gw_model=""; envf=""
150
+ while [ $# -gt 0 ]; do
151
+ case "$1" in
152
+ --port) port="${2:-}"; shift ;;
153
+ --port=*) port="${1#*=}" ;;
154
+ --model) gw_model="${2:-}"; shift ;;
155
+ --model=*) gw_model="${1#*=}" ;;
156
+ --env-file) envf="${2:-}"; shift ;;
157
+ --env-file=*) envf="${1#*=}" ;;
158
+ -*) die "$gw_usage" ;;
159
+ *) [ -z "$be" ] || die "$gw_usage"; be="$1" ;;
160
+ esac
161
+ shift
162
+ done
163
+ case "$port" in ''|*[!0-9]*) die "--port must be a number" ;; esac
164
+ if [ -z "$be" ] && [ "$sub" = start ]; then
165
+ be="$(grep -E '^LLM_GATEWAY=' "$HOME_DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"; be="${be:-claude}"
166
+ fi
167
+ case "${be:-claude}" in claude|codex|gemini|antigravity|chatgpt|siwc|multi) ;; *) die "unknown gateway backend '$be'; $gw_usage" ;; esac
168
+ # one pid/token/log file per port, so several gateways (one per provider) run side by side
169
+ if [ "$port" = 5055 ]; then sfx=""; else sfx="-$port"; fi
170
+ mkdir -p "$HOME_DIR/backend/logs"
171
+ pidf="$HOME_DIR/backend/logs/gateway$sfx.pid"
172
+ tokenf="$HOME_DIR/backend/logs/gateway$sfx.token"
173
+ logf="$HOME_DIR/backend/logs/gateway$sfx.out"
174
+ url="http://127.0.0.1:$port"
175
+ case "$sub" in
176
+ login) cd "$HOME_DIR" && exec "$PY" backend/gateway/siwc_auth.py login ;;
177
+ start)
178
+ if [ "$be" = siwc ]; then
179
+ "$PY" "$HOME_DIR/backend/gateway/siwc_auth.py" status | "$PY" -c 'import json,sys; s=json.load(sys.stdin); sys.exit(0 if s.get("eligible_scope") and not s.get("needs_login") else 1)' || die "Continue with ChatGPT is not ready (run: mm.sh gateway login)"
180
+ else
181
+ # the gateway finds each CLI on PATH or in its known install locations (codex in the ChatGPT app, agy in ~/.local/bin)
182
+ "$PY" "$HOME_DIR/backend/gateway/local_gateway.py" --backend "$be" --check >/dev/null 2>&1 \
183
+ || die "'$be' is not available (install its CLI and log in first; for multi, at least one of claude, codex, antigravity, gemini)"
184
+ fi
185
+ if [ ! -s "$tokenf" ]; then (umask 077; "$PY" -c 'import secrets,sys; open(sys.argv[1],"w").write(secrets.token_urlsafe(48))' "$tokenf"); fi
186
+ if curl -fsS -m 2 "$url/health" >/dev/null 2>&1; then
187
+ curl -fsS -m 2 "$url/health" | "$PY" -c 'import json,sys; s=json.load(sys.stdin); sys.exit(0 if s.get("auth_required") and s.get("backend")==sys.argv[1] else 1)' "$be" || die "a different or unauthenticated gateway is on port $port; stop it or pick another --port"
188
+ say "gateway already running ($be on $url)"
189
+ else
190
+ margs=(); [ -n "$gw_model" ] && margs=(--model "$gw_model")
191
+ # exec, so the recorded pid is the gateway itself (stop kills it) and nothing keeps the caller's output open
192
+ (cd "$HOME_DIR" && exec nohup "$PY" backend/gateway/local_gateway.py --backend "$be" --port "$port" --auth-token-file "$tokenf" ${margs[@]+"${margs[@]}"} > "$logf" 2>&1 < /dev/null) &
193
+ echo $! > "$pidf"
194
+ for _ in $(seq 1 20); do curl -fsS -m 1 "$url/health" >/dev/null 2>&1 && break; sleep 0.5; done
195
+ curl -fsS -m 1 "$url/health" >/dev/null 2>&1 || { tail -n 20 "$logf" >&2; die "gateway did not start; see $logf"; }
196
+ say "gateway started ($be) on $url/v1 (log $logf)"
197
+ fi
198
+ model="${gw_model:-$be}"
199
+ if [ "$be" = siwc ] && [ -z "$gw_model" ]; then model=$("$PY" "$HOME_DIR/backend/gateway/siwc_auth.py" status | "$PY" -c 'import json,sys; print(json.load(sys.stdin).get("model") or "")'); fi
200
+ # settings: the default port writes .env (as before); other ports write a settings file for that provider,
201
+ # used by a backend instance started with: mm.sh start --port P --env-file F
202
+ if [ -z "$envf" ]; then
203
+ if [ "$port" = 5055 ]; then envf="$HOME_DIR/.env"; else envf="$HOME_DIR/.env.$be"; fi
204
+ fi
205
+ case "$envf" in /*) ;; *) envf="$PWD/$envf" ;; esac
206
+ base_env="$envf"; [ -f "$base_env" ] || base_env="$HOME_DIR/.env"
207
+ (umask 077
208
+ { grep -Ev '^(LLM_PROVIDER|LLM_BASE_URL|LLM_API_KEY|LLM_GATEWAY|LLM_MODEL_NAME|LLM_POOL|MARKET_MIRROR_PROVIDER)=' "$base_env" 2>/dev/null || true
209
+ printf 'LLM_PROVIDER=openai\nLLM_BASE_URL=%s/v1\nLLM_API_KEY=%s\nLLM_GATEWAY=%s\nLLM_MODEL_NAME=%s\n' "$url" "$(cat "$tokenf")" "$be" "$model"; } > "$envf.tmp")
210
+ mv "$envf.tmp" "$envf"
211
+ if [ "$envf" = "$HOME_DIR/.env" ]; then
212
+ say "wrote gateway settings to $envf (restart the backend: mm.sh stop && mm.sh start)"
213
+ else
214
+ say "wrote gateway settings to $envf; start a backend on it with:"
215
+ say " mm.sh start --port <backend port, e.g. 5011> --env-file $envf"
216
+ say " (its data goes to backend/uploads-<port>; point the CLI at it with MIROFISH_URL=http://localhost:<port>)"
217
+ fi ;;
218
+ stop)
219
+ if [ -f "$pidf" ] && kill "$(cat "$pidf")" 2>/dev/null; then rm -f "$pidf"; say "gateway on port $port stopped"; else rm -f "$pidf"; say "no gateway running on port $port"; fi ;;
220
+ status) curl -fsS -m 2 "$url/health" 2>/dev/null || say "no gateway on port $port" ;;
221
+ *) die "$gw_usage" ;;
222
+ esac ;;
223
+ mcp) exec python3 "$(dirname "$0")/mcp_server.py" "$@" ;;
224
+ help|-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' ;;
225
+ *) die "unknown command '$cmd' (try: mm.sh help)" ;;
226
+ esac
@@ -0,0 +1,22 @@
1
+ """Run the bundled MarketMirror MCP server (stdio by default; pass --help for options).
2
+
3
+ The server is the same file the Claude Code plugin and Gemini extension use. It drives the MarketMirror engine through
4
+ the bundled mm.sh helper; the engine itself is installed on first use with the `setup` tool (into
5
+ ~/.market-mirror/engine, or MARKET_MIRROR_HOME).
6
+ """
7
+
8
+ import os
9
+ import runpy
10
+ import sys
11
+
12
+
13
+ def main() -> None:
14
+ server = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_server", "mcp_server.py")
15
+ if not os.path.isfile(server):
16
+ sys.exit("marketmirror: bundled server files are missing from this install")
17
+ sys.argv[0] = server
18
+ runpy.run_path(server, run_name="__main__")
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ # Copy the MCP server files from the plugin into the package before building (they are not kept twice in git).
3
+ set -euo pipefail
4
+ here="$(cd "$(dirname "$0")" && pwd)"
5
+ src="$here/../../plugin/skills/market-mirror/scripts"
6
+ dst="$here/src/marketmirror/_server"
7
+ rm -rf "$dst"; mkdir -p "$dst"
8
+ cp "$src/mcp_server.py" "$src/job_worker.py" "$src/mm.sh" "$dst/"