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.
- crowdos_mcp-0.1.0/.gitignore +54 -0
- crowdos_mcp-0.1.0/PKG-INFO +168 -0
- crowdos_mcp-0.1.0/README.md +145 -0
- crowdos_mcp-0.1.0/pyproject.toml +47 -0
- crowdos_mcp-0.1.0/src/crowdos_mcp/__init__.py +8 -0
- crowdos_mcp-0.1.0/src/crowdos_mcp/__main__.py +19 -0
- crowdos_mcp-0.1.0/src/crowdos_mcp/server.py +409 -0
|
@@ -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())
|