crowdos-mcp 0.1.0__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,54 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # Dependencies
4
+ node_modules
5
+ .pnp
6
+ .pnp.js
7
+
8
+ # Local env files
9
+ .env
10
+ .env.local
11
+ .env.development.local
12
+ .env.test.local
13
+ .env.production.local
14
+
15
+ # Testing
16
+ coverage
17
+
18
+ # Turbo
19
+ .turbo
20
+
21
+ # Vercel
22
+ .vercel
23
+
24
+ # Build Outputs
25
+ .next/
26
+ out/
27
+ build
28
+ dist
29
+
30
+
31
+ # Debug
32
+ npm-debug.log*
33
+ yarn-debug.log*
34
+ yarn-error.log*
35
+
36
+ # Python
37
+ __pycache__/
38
+ *.pyc
39
+ .pytest_cache/
40
+
41
+ # Misc
42
+ .DS_Store
43
+ *.pem
44
+ .claude/
45
+
46
+ # ANES raw microdata — research-use license prohibits redistribution.
47
+ # The ingestion script reads these files locally; only derived,
48
+ # anonymized JSONL lands in benchmark_results/interview_seeds_pool.jsonl
49
+ # (which IS committed). See scripts/ingest_anes.py.
50
+ anes_*_raw/
51
+ anes_2020_raw/
52
+ *.dta
53
+ anes_timeseries_*.csv
54
+ anes_timeseries_*.pdf
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: crowdos-mcp
3
+ Version: 0.1.0
4
+ Summary: Model Context Protocol server for CrowdOS — synthetic focus groups as agent-callable tools.
5
+ Project-URL: Homepage, https://crowdos.ai
6
+ Project-URL: Documentation, https://crowdos.ai/developers
7
+ Project-URL: Repository, https://github.com/bjnagent/crowd
8
+ Project-URL: Issues, https://github.com/bjnagent/crowd/issues
9
+ Author-email: CrowdOS <support@crowdos.ai>
10
+ License: MIT
11
+ Keywords: ai-agents,anthropic,claude,crowdos,focus-group,mcp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.27.0
21
+ Requires-Dist: mcp>=1.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # CrowdOS MCP Server
25
+
26
+ Synthetic focus groups as agent-callable tools. Exposes the
27
+ [CrowdOS](https://crowdos.ai) developer API as
28
+ [Model Context Protocol](https://modelcontextprotocol.io) tools so AI
29
+ agents (Claude Desktop, Cursor, Cline, LangGraph, CrewAI, AutoGPT,
30
+ Devin, etc.) can run synthetic public-opinion research with a single
31
+ tool call.
32
+
33
+ ## What this gives you
34
+
35
+ Your agent can now do things like:
36
+
37
+ ```
38
+ > Run a focus group on whether companies should mandate 4-day weeks.
39
+ Use 200 agents from the us_general_population preset.
40
+
41
+ [tool: run_focus_group]
42
+ {
43
+ "id": "ad4b3736-...",
44
+ "sentiment_summary": {
45
+ "support_pct": 71.5, "oppose_pct": 18.0, "mixed_pct": 10.5
46
+ },
47
+ "sample_responses": [
48
+ {"agent_name": "Maria Chen", "stance": "supports", ... },
49
+ ...
50
+ ]
51
+ }
52
+ ```
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pip install crowdos-mcp
58
+ ```
59
+
60
+ Then mint a free sandbox API key at <https://crowdos.ai/developers> —
61
+ no credit card required for the free tier (50 agents per study,
62
+ 5 req/min).
63
+
64
+ ## Configure for Claude Desktop
65
+
66
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
67
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "crowdos": {
73
+ "command": "crowdos-mcp",
74
+ "env": {
75
+ "CROWDOS_API_KEY": "crowd_..."
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Restart Claude Desktop. The CrowdOS tools should appear in the
83
+ slash-command picker.
84
+
85
+ ## Configure for Cursor
86
+
87
+ Settings → MCP Servers → Add. Same env block as above; command =
88
+ `crowdos-mcp`.
89
+
90
+ ## Configure for Cline (VS Code)
91
+
92
+ Settings → Cline → MCP Servers → Edit JSON:
93
+
94
+ ```json
95
+ {
96
+ "mcpServers": {
97
+ "crowdos": {
98
+ "command": "crowdos-mcp",
99
+ "env": { "CROWDOS_API_KEY": "crowd_..." }
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ ## Tools exposed
106
+
107
+ | Tool | What it does | Auth |
108
+ |---|---|---|
109
+ | `run_focus_group` | Synthetic poll on a topic, returns sentiment + quotes | required |
110
+ | `run_debate` | Multi-round synthetic debate, returns convergence | required |
111
+ | `list_demographic_presets` | Discover available audience templates | required |
112
+ | `get_simulation` | Fetch full results of a previously-run study | required |
113
+ | `crowd_sample` | Browse the public CrowdOS crowd (sanitized) | none |
114
+
115
+ `run_focus_group` and `run_debate` block 5–120s depending on
116
+ population size — that's a real synthetic-research call running
117
+ behind the scenes, not a cached response. The MCP server returns a
118
+ trimmed envelope (sentiment summary + first 5 representative quotes
119
+ + billing breakdown). Use `get_simulation` to pull the full payload
120
+ when you need every agent's full reasoning.
121
+
122
+ ## Configuration
123
+
124
+ | Env var | Default | Required |
125
+ |---|---|---|
126
+ | `CROWDOS_API_KEY` | — | yes (except `crowd_sample`) |
127
+ | `CROWDOS_API_BASE_URL` | `https://api.crowdos.ai` | no |
128
+
129
+ ## Cost
130
+
131
+ CrowdOS uses a metered wallet. The MCP server returns the actual
132
+ debit on every successful call inside `billing.actual_cents`. Free
133
+ tier ships with $5 of credit; top up at <https://crowdos.ai/account/billing>
134
+ once it runs out.
135
+
136
+ Free-tier monthly quota is 120k tokens (~3 large studies). Pro tier
137
+ removes the cap.
138
+
139
+ ## Programmatic use (without an MCP host)
140
+
141
+ The server is also a regular Python module:
142
+
143
+ ```bash
144
+ python -m crowdos_mcp
145
+ # stdio MCP server, waits for messages on stdin
146
+ ```
147
+
148
+ Or import and embed:
149
+
150
+ ```python
151
+ from crowdos_mcp.server import build_server
152
+ server = build_server()
153
+ # server is a configured mcp.server.Server instance
154
+ ```
155
+
156
+ ## Versioning
157
+
158
+ Follows semver. The MCP tool surface (tool names, input schemas) is
159
+ stable; additive changes (new tools, new optional fields) ship as
160
+ minor versions. Removing or renaming a tool is a major version.
161
+
162
+ ## License
163
+
164
+ MIT.
165
+
166
+ ## Issues / questions
167
+
168
+ <https://github.com/bjnagent/crowd/issues>
@@ -0,0 +1,145 @@
1
+ # CrowdOS MCP Server
2
+
3
+ Synthetic focus groups as agent-callable tools. Exposes the
4
+ [CrowdOS](https://crowdos.ai) developer API as
5
+ [Model Context Protocol](https://modelcontextprotocol.io) tools so AI
6
+ agents (Claude Desktop, Cursor, Cline, LangGraph, CrewAI, AutoGPT,
7
+ Devin, etc.) can run synthetic public-opinion research with a single
8
+ tool call.
9
+
10
+ ## What this gives you
11
+
12
+ Your agent can now do things like:
13
+
14
+ ```
15
+ > Run a focus group on whether companies should mandate 4-day weeks.
16
+ Use 200 agents from the us_general_population preset.
17
+
18
+ [tool: run_focus_group]
19
+ {
20
+ "id": "ad4b3736-...",
21
+ "sentiment_summary": {
22
+ "support_pct": 71.5, "oppose_pct": 18.0, "mixed_pct": 10.5
23
+ },
24
+ "sample_responses": [
25
+ {"agent_name": "Maria Chen", "stance": "supports", ... },
26
+ ...
27
+ ]
28
+ }
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install crowdos-mcp
35
+ ```
36
+
37
+ Then mint a free sandbox API key at <https://crowdos.ai/developers> —
38
+ no credit card required for the free tier (50 agents per study,
39
+ 5 req/min).
40
+
41
+ ## Configure for Claude Desktop
42
+
43
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
44
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "crowdos": {
50
+ "command": "crowdos-mcp",
51
+ "env": {
52
+ "CROWDOS_API_KEY": "crowd_..."
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Restart Claude Desktop. The CrowdOS tools should appear in the
60
+ slash-command picker.
61
+
62
+ ## Configure for Cursor
63
+
64
+ Settings → MCP Servers → Add. Same env block as above; command =
65
+ `crowdos-mcp`.
66
+
67
+ ## Configure for Cline (VS Code)
68
+
69
+ Settings → Cline → MCP Servers → Edit JSON:
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "crowdos": {
75
+ "command": "crowdos-mcp",
76
+ "env": { "CROWDOS_API_KEY": "crowd_..." }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Tools exposed
83
+
84
+ | Tool | What it does | Auth |
85
+ |---|---|---|
86
+ | `run_focus_group` | Synthetic poll on a topic, returns sentiment + quotes | required |
87
+ | `run_debate` | Multi-round synthetic debate, returns convergence | required |
88
+ | `list_demographic_presets` | Discover available audience templates | required |
89
+ | `get_simulation` | Fetch full results of a previously-run study | required |
90
+ | `crowd_sample` | Browse the public CrowdOS crowd (sanitized) | none |
91
+
92
+ `run_focus_group` and `run_debate` block 5–120s depending on
93
+ population size — that's a real synthetic-research call running
94
+ behind the scenes, not a cached response. The MCP server returns a
95
+ trimmed envelope (sentiment summary + first 5 representative quotes
96
+ + billing breakdown). Use `get_simulation` to pull the full payload
97
+ when you need every agent's full reasoning.
98
+
99
+ ## Configuration
100
+
101
+ | Env var | Default | Required |
102
+ |---|---|---|
103
+ | `CROWDOS_API_KEY` | — | yes (except `crowd_sample`) |
104
+ | `CROWDOS_API_BASE_URL` | `https://api.crowdos.ai` | no |
105
+
106
+ ## Cost
107
+
108
+ CrowdOS uses a metered wallet. The MCP server returns the actual
109
+ debit on every successful call inside `billing.actual_cents`. Free
110
+ tier ships with $5 of credit; top up at <https://crowdos.ai/account/billing>
111
+ once it runs out.
112
+
113
+ Free-tier monthly quota is 120k tokens (~3 large studies). Pro tier
114
+ removes the cap.
115
+
116
+ ## Programmatic use (without an MCP host)
117
+
118
+ The server is also a regular Python module:
119
+
120
+ ```bash
121
+ python -m crowdos_mcp
122
+ # stdio MCP server, waits for messages on stdin
123
+ ```
124
+
125
+ Or import and embed:
126
+
127
+ ```python
128
+ from crowdos_mcp.server import build_server
129
+ server = build_server()
130
+ # server is a configured mcp.server.Server instance
131
+ ```
132
+
133
+ ## Versioning
134
+
135
+ Follows semver. The MCP tool surface (tool names, input schemas) is
136
+ stable; additive changes (new tools, new optional fields) ship as
137
+ minor versions. Removing or renaming a tool is a major version.
138
+
139
+ ## License
140
+
141
+ MIT.
142
+
143
+ ## Issues / questions
144
+
145
+ <https://github.com/bjnagent/crowd/issues>
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "crowdos-mcp"
3
+ version = "0.1.0"
4
+ description = "Model Context Protocol server for CrowdOS — synthetic focus groups as agent-callable tools."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "CrowdOS", email = "support@crowdos.ai" },
10
+ ]
11
+ keywords = ["mcp", "crowdos", "ai-agents", "focus-group", "claude", "anthropic"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Software Development :: Libraries",
20
+ ]
21
+ dependencies = [
22
+ "mcp>=1.0.0",
23
+ "httpx>=0.27.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://crowdos.ai"
28
+ Documentation = "https://crowdos.ai/developers"
29
+ Repository = "https://github.com/bjnagent/crowd"
30
+ Issues = "https://github.com/bjnagent/crowd/issues"
31
+
32
+ [project.scripts]
33
+ crowdos-mcp = "crowdos_mcp.__main__:main"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/crowdos_mcp"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = [
44
+ "src/",
45
+ "README.md",
46
+ "pyproject.toml",
47
+ ]
@@ -0,0 +1,8 @@
1
+ """CrowdOS MCP server — synthetic focus groups as agent-callable tools.
2
+
3
+ Exposes the CrowdOS REST API as MCP tools so AI agents (Claude Desktop,
4
+ Cursor, Cline, LangGraph, CrewAI, etc.) can run synthetic focus groups,
5
+ debates, and crowd lookups via tool calls.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,19 @@
1
+ """Entrypoint: ``python -m crowdos_mcp`` or ``crowdos-mcp``.
2
+
3
+ Reads CROWDOS_API_KEY from the environment and runs the MCP server
4
+ on stdio. Hosts that launch MCP servers (Claude Desktop, Cursor,
5
+ Cline) handle stdio routing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def main() -> None:
11
+ # Imported here so `import crowdos_mcp` doesn't drag in the mcp
12
+ # SDK + httpx unless we're actually running the server.
13
+ from crowdos_mcp.server import run
14
+
15
+ run()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
@@ -0,0 +1,409 @@
1
+ """CrowdOS MCP server.
2
+
3
+ Exposes the CrowdOS developer REST API as MCP tools. Stdio transport;
4
+ hosts that launch MCP servers (Claude Desktop, Cursor, Cline) wire
5
+ stdio to the model. Tools:
6
+
7
+ - run_focus_group : sentiment / voting study (sync, returns full results)
8
+ - run_debate : multi-round debate study (sync)
9
+ - list_demographic_presets : discover available audiences
10
+ - get_simulation : fetch results of a previously-run study
11
+ - crowd_sample : public, unauth'd crowd browse (no API key needed)
12
+
13
+ Configuration via env vars:
14
+ CROWDOS_API_KEY — required for run_focus_group, run_debate,
15
+ get_simulation. Get one at /developer.
16
+ CROWDOS_API_BASE_URL — defaults to https://api.crowdos.ai
17
+
18
+ The server NEVER returns the customer's API key in any response. All
19
+ errors are sanitized to {error_type, message} so an agent's response
20
+ log can be safely stored.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ from typing import Any, Optional
27
+
28
+ import httpx
29
+ from mcp.server import Server
30
+ from mcp.server.stdio import stdio_server
31
+ from mcp.types import TextContent, Tool
32
+
33
+
34
+ DEFAULT_API_BASE_URL = "https://api.crowdos.ai"
35
+ # Sync sims take 5–120s for typical population sizes. We cap at 5 min
36
+ # so a stuck job doesn't hang the agent's tool-calling turn forever.
37
+ DEFAULT_TIMEOUT_S = 300.0
38
+
39
+
40
+ # ── HTTP client helpers ─────────────────────────────────────────────
41
+
42
+
43
+ def _api_base_url() -> str:
44
+ return os.environ.get("CROWDOS_API_BASE_URL", DEFAULT_API_BASE_URL).rstrip("/")
45
+
46
+
47
+ def _api_key() -> Optional[str]:
48
+ """Return the configured CrowdOS API key or None.
49
+
50
+ Tools that require auth call _require_api_key() to convert None
51
+ into a clear error rather than leaking through to a 401 from the
52
+ server with an unhelpful message.
53
+ """
54
+ key = os.environ.get("CROWDOS_API_KEY", "").strip()
55
+ return key or None
56
+
57
+
58
+ def _require_api_key() -> str:
59
+ key = _api_key()
60
+ if not key:
61
+ raise ValueError(
62
+ "CROWDOS_API_KEY environment variable is not set. "
63
+ "Get a free sandbox key at https://crowdos.ai/developers "
64
+ "and configure it in your MCP server's env block."
65
+ )
66
+ return key
67
+
68
+
69
+ async def _post(path: str, body: dict, *, auth: bool = True) -> dict:
70
+ headers = {"Content-Type": "application/json"}
71
+ if auth:
72
+ headers["Authorization"] = f"Bearer {_require_api_key()}"
73
+ url = f"{_api_base_url()}{path}"
74
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_S) as client:
75
+ resp = await client.post(url, json=body, headers=headers)
76
+ return _parse_response(resp)
77
+
78
+
79
+ async def _get(path: str, *, auth: bool = True) -> dict:
80
+ headers: dict[str, str] = {}
81
+ if auth:
82
+ headers["Authorization"] = f"Bearer {_require_api_key()}"
83
+ url = f"{_api_base_url()}{path}"
84
+ async with httpx.AsyncClient(timeout=60.0) as client:
85
+ resp = await client.get(url, headers=headers)
86
+ return _parse_response(resp)
87
+
88
+
89
+ def _parse_response(resp: httpx.Response) -> dict:
90
+ """Convert an HTTP response into a dict, raising a clean ValueError
91
+ on non-2xx responses. The error message is the API's structured
92
+ detail when present, the body otherwise — never includes headers
93
+ that might leak the API key."""
94
+ if 200 <= resp.status_code < 300:
95
+ try:
96
+ return resp.json()
97
+ except Exception:
98
+ return {"raw": resp.text[:1000]}
99
+ # Try to surface the API's structured error detail.
100
+ try:
101
+ body = resp.json()
102
+ detail = body.get("detail") if isinstance(body, dict) else body
103
+ except Exception:
104
+ detail = resp.text[:500]
105
+ raise ValueError(f"CrowdOS API returned {resp.status_code}: {detail}")
106
+
107
+
108
+ # ── Result trimming ─────────────────────────────────────────────────
109
+ # Sim results can be 100KB–5MB on a 500-agent run. The full payload
110
+ # blows past most agents' context windows. The MCP server returns a
111
+ # trimmed envelope by default (sentiment summary + top quotes); the
112
+ # agent can ask for full results via get_simulation if needed.
113
+
114
+
115
+ def _trim_sim_result(result: dict) -> dict:
116
+ """Return a compact, agent-friendly subset of a sim result."""
117
+ if not isinstance(result, dict):
118
+ return {"raw": result}
119
+ out: dict[str, Any] = {
120
+ "id": result.get("id"),
121
+ "status": result.get("status"),
122
+ "topic": result.get("topic"),
123
+ "demographic_preset": result.get("demographic_preset"),
124
+ "population_size": result.get("population_size"),
125
+ }
126
+ inner = result.get("results") or {}
127
+ if isinstance(inner, dict):
128
+ out["sentiment_summary"] = inner.get("sentiment_summary")
129
+ out["billing"] = inner.get("billing")
130
+ # First 5 representative quotes
131
+ responses = inner.get("responses") or []
132
+ if isinstance(responses, list):
133
+ out["sample_responses"] = [
134
+ {
135
+ "agent_name": r.get("agent_name"),
136
+ "stance": r.get("stance"),
137
+ "sentiment": r.get("sentiment"),
138
+ "reasoning": (r.get("reasoning") or "")[:400],
139
+ }
140
+ for r in responses[:5]
141
+ ]
142
+ out["total_responses"] = len(responses)
143
+ # Debate convergence + final summary, if present
144
+ if "convergence" in inner:
145
+ out["convergence"] = inner.get("convergence")
146
+ if "final_summary" in inner:
147
+ out["final_summary"] = inner.get("final_summary")
148
+ return out
149
+
150
+
151
+ # ── Tool definitions ────────────────────────────────────────────────
152
+
153
+
154
+ TOOLS: list[Tool] = [
155
+ Tool(
156
+ name="run_focus_group",
157
+ description=(
158
+ "Run a synthetic focus group on a topic. Samples N AI agents from "
159
+ "a demographic preset (e.g. us_general_population, gen_z, etc.), "
160
+ "each with persistent Big Five personality + life context, and "
161
+ "returns sentiment breakdown plus representative quotes. "
162
+ "Synchronous — blocks 5-120s depending on population_size. Use "
163
+ "this when you want quick public-opinion signal on a question, "
164
+ "policy, product idea, or political stance."
165
+ ),
166
+ inputSchema={
167
+ "type": "object",
168
+ "properties": {
169
+ "topic": {
170
+ "type": "string",
171
+ "description": "The question or stance to test, e.g. 'Should the federal minimum wage be $20/hr?'",
172
+ "minLength": 1,
173
+ "maxLength": 4000,
174
+ },
175
+ "population_size": {
176
+ "type": "integer",
177
+ "description": "Number of agents to poll. Free tier max 50; pro 750; enterprise 2000.",
178
+ "minimum": 5,
179
+ "maximum": 2000,
180
+ "default": 50,
181
+ },
182
+ "demographic_preset": {
183
+ "type": "string",
184
+ "description": (
185
+ "Audience preset. Use list_demographic_presets to see all "
186
+ "options. Default: us_general_population."
187
+ ),
188
+ "default": "us_general_population",
189
+ },
190
+ "stance_statement": {
191
+ "type": "string",
192
+ "description": (
193
+ "Optional declarative the agents take a position FOR or "
194
+ "AGAINST. Improves clarity on comparison questions."
195
+ ),
196
+ },
197
+ "language": {
198
+ "type": "string",
199
+ "description": "ISO language code for agent responses. Default: en.",
200
+ "default": "en",
201
+ },
202
+ },
203
+ "required": ["topic"],
204
+ },
205
+ ),
206
+ Tool(
207
+ name="run_debate",
208
+ description=(
209
+ "Run a multi-round synthetic debate. N agents discuss the topic "
210
+ "across several rounds, updating their positions in response to "
211
+ "peers. Returns final convergence (or lack of), per-round "
212
+ "position shifts, and a synthesis. Use this when you want to "
213
+ "see whether a position survives scrutiny or how disagreement "
214
+ "evolves, not just a snapshot poll."
215
+ ),
216
+ inputSchema={
217
+ "type": "object",
218
+ "properties": {
219
+ "topic": {"type": "string", "minLength": 1, "maxLength": 4000},
220
+ "num_rounds": {
221
+ "type": "integer",
222
+ "description": "Debate rounds. More rounds = more convergence pressure.",
223
+ "minimum": 3,
224
+ "maximum": 15,
225
+ "default": 5,
226
+ },
227
+ "population_size": {
228
+ "type": "integer",
229
+ "minimum": 5,
230
+ "maximum": 2000,
231
+ "default": 30,
232
+ },
233
+ "demographic_preset": {
234
+ "type": "string",
235
+ "default": "us_general_population",
236
+ },
237
+ "stance_statement": {"type": "string"},
238
+ },
239
+ "required": ["topic"],
240
+ },
241
+ ),
242
+ Tool(
243
+ name="list_demographic_presets",
244
+ description=(
245
+ "List all available demographic presets (audience templates). Each "
246
+ "preset is a calibrated mix of demographics + Big Five OCEAN "
247
+ "personality distributions for a country, region, or behavioral "
248
+ "segment. Call this first if you don't know which preset to use."
249
+ ),
250
+ inputSchema={
251
+ "type": "object",
252
+ "properties": {},
253
+ },
254
+ ),
255
+ Tool(
256
+ name="get_simulation",
257
+ description=(
258
+ "Fetch the FULL results of a previously-run simulation by id. "
259
+ "Use when run_focus_group's trimmed response isn't enough and "
260
+ "you need every agent response, full reasoning text, etc."
261
+ ),
262
+ inputSchema={
263
+ "type": "object",
264
+ "properties": {
265
+ "sim_id": {
266
+ "type": "string",
267
+ "description": "The id returned by run_focus_group / run_debate.",
268
+ },
269
+ },
270
+ "required": ["sim_id"],
271
+ },
272
+ ),
273
+ Tool(
274
+ name="crowd_sample",
275
+ description=(
276
+ "Browse the persistent CrowdOS crowd — a sample of the live "
277
+ "agent population, sanitized to remove internal IDs. Useful for "
278
+ "showing 'what kind of people are in this audience' to a user. "
279
+ "No API key required — this endpoint is public."
280
+ ),
281
+ inputSchema={
282
+ "type": "object",
283
+ "properties": {
284
+ "limit": {
285
+ "type": "integer",
286
+ "minimum": 1,
287
+ "maximum": 100,
288
+ "default": 20,
289
+ },
290
+ },
291
+ },
292
+ ),
293
+ ]
294
+
295
+
296
+ # ── Tool handlers ───────────────────────────────────────────────────
297
+
298
+
299
+ async def _handle_run_focus_group(args: dict) -> dict:
300
+ body = {
301
+ "topic": args["topic"],
302
+ "mode": "voting",
303
+ "demographic_preset": args.get("demographic_preset", "us_general_population"),
304
+ "population_size": int(args.get("population_size", 50)),
305
+ "language": args.get("language", "en"),
306
+ }
307
+ if args.get("stance_statement"):
308
+ body["stance_statement"] = args["stance_statement"]
309
+ result = await _post("/api/v1/developer/simulations", body)
310
+ return _trim_sim_result(result)
311
+
312
+
313
+ async def _handle_run_debate(args: dict) -> dict:
314
+ body = {
315
+ "topic": args["topic"],
316
+ "mode": "debate",
317
+ "demographic_preset": args.get("demographic_preset", "us_general_population"),
318
+ "population_size": int(args.get("population_size", 30)),
319
+ "num_rounds": int(args.get("num_rounds", 5)),
320
+ }
321
+ if args.get("stance_statement"):
322
+ body["stance_statement"] = args["stance_statement"]
323
+ result = await _post("/api/v1/developer/simulations", body)
324
+ return _trim_sim_result(result)
325
+
326
+
327
+ async def _handle_list_demographic_presets(args: dict) -> dict:
328
+ return await _get("/api/v1/developer/presets")
329
+
330
+
331
+ async def _handle_get_simulation(args: dict) -> dict:
332
+ sim_id = args["sim_id"]
333
+ return await _get(f"/api/v1/developer/simulations/{sim_id}")
334
+
335
+
336
+ async def _handle_crowd_sample(args: dict) -> dict:
337
+ limit = int(args.get("limit", 20))
338
+ return await _get(f"/api/v1/crowd/sample?limit={limit}", auth=False)
339
+
340
+
341
+ _HANDLERS = {
342
+ "run_focus_group": _handle_run_focus_group,
343
+ "run_debate": _handle_run_debate,
344
+ "list_demographic_presets": _handle_list_demographic_presets,
345
+ "get_simulation": _handle_get_simulation,
346
+ "crowd_sample": _handle_crowd_sample,
347
+ }
348
+
349
+
350
+ # ── Server wiring ───────────────────────────────────────────────────
351
+
352
+
353
+ def build_server() -> Server:
354
+ """Build and return a configured MCP Server. Exposed for tests."""
355
+ server: Server = Server("crowdos-mcp")
356
+
357
+ @server.list_tools()
358
+ async def list_tools() -> list[Tool]:
359
+ return TOOLS
360
+
361
+ @server.call_tool()
362
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
363
+ handler = _HANDLERS.get(name)
364
+ if handler is None:
365
+ return [TextContent(type="text", text=json.dumps({
366
+ "error": "unknown_tool",
367
+ "tool": name,
368
+ "available": list(_HANDLERS.keys()),
369
+ }))]
370
+ try:
371
+ result = await handler(arguments or {})
372
+ return [TextContent(type="text", text=json.dumps(result, default=str))]
373
+ except ValueError as e:
374
+ # Expected, customer-facing errors (auth, validation, API
375
+ # 4xx/5xx). Surface verbatim — the message is already
376
+ # sanitized by _parse_response / _require_api_key.
377
+ return [TextContent(type="text", text=json.dumps({
378
+ "error": "request_failed",
379
+ "tool": name,
380
+ "message": str(e),
381
+ }))]
382
+ except Exception as e:
383
+ # Bug in the handler. Don't leak the stack to the agent;
384
+ # return the type name only.
385
+ return [TextContent(type="text", text=json.dumps({
386
+ "error": "internal_error",
387
+ "tool": name,
388
+ "type": type(e).__name__,
389
+ }))]
390
+
391
+ return server
392
+
393
+
394
+ def run() -> None:
395
+ """Run the MCP server on stdio. Called by ``crowdos-mcp`` /
396
+ ``python -m crowdos_mcp``."""
397
+ import asyncio
398
+
399
+ server = build_server()
400
+
401
+ async def _serve() -> None:
402
+ async with stdio_server() as (read_stream, write_stream):
403
+ await server.run(
404
+ read_stream,
405
+ write_stream,
406
+ server.create_initialization_options(),
407
+ )
408
+
409
+ asyncio.run(_serve())