# MCP Adapter Quickstart (Python)

This MCP server is a thin wrapper over the existing REST API. Each MCP tool maps 1:1
to a current `/v1` endpoint and passes request bodies through unchanged.

Related guides:
[All Guides](./index.md) |
[Quickstart](./quickstart.md) |
[Recruitment Filter Expansion](./recruitment_filter_expansion.md) |
[Filters Reference](./filters.md) |
[Thesis Lab Wave 1](./thesis_lab_wave1.md)

## Recommended SDK Version

- Use `mcp==1.25.0` (pinned in `requirements.txt`).
  - It is the latest stable release on PyPI and includes StreamableHTTP support,
    DNS rebinding protections, and the current tool naming validation rules.

## Install

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

## Configure

Set environment variables before running:

- `MCP_API_BASE_URL` (default: `http://localhost:8000`)
- `MCP_API_KEY` (required; FishDog API key with `groups:read`, `groups:write`,
  `studies:read`, `studies:write`, `zeitgeist:read`, `zeitgeist:write`,
  `free:ask` as needed. Free-tier keys can also use `v1.jobs.get` for jobs
  owned by that same free-tier user account.)
- `MCP_ADMIN_API_KEY` (optional; required only when calling `/api/admin/*`)
- `MCP_TENANT_API_KEY_HEADER` (default: `X-FishDog-Api-Key`; per-request tenant key header; legacy `X-Ditto-Api-Key` is still accepted)
- `MCP_TENANT_ORG_ID_HEADER` (default: `X-FishDog-Organization-Id`; optional per-request org routing header; legacy `X-Ditto-Organization-Id` is still accepted)
- `MCP_SHARED_SECRET` (optional; if set, every MCP request must include this secret)
- `MCP_SHARED_SECRET_HEADER` (default: `X-MCP-Server-Token`; header used for `MCP_SHARED_SECRET`)
- `MCP_REQUIRE_TENANT_API_KEY` (default: `false`; when `true`, per-request tenant key is mandatory)
- `MCP_REQUIRE_TENANT_ORG_ID` (default: `false`; when `true`, per-request org header is mandatory)
- `MCP_ALLOW_STATIC_API_KEY_FALLBACK` (default: `true`; when `false`, do not fall back to `MCP_API_KEY`)
- `MCP_HOST` (default: `127.0.0.1`)
- `MCP_PORT` (default: `8100`)
- `MCP_TRANSPORT` (default: `streamable_http`; options: `streamable_http`, `stdio`, `sse`)
- `MCP_LOG_LEVEL` (default: `INFO`)
- `THESIS_LAB_MCP_GOLDEN_TOOLS_ENABLED` (default: `true`; set to `false` to
  disable Wave 1 Thesis Lab/research-group golden-path tools without disabling
  the whole MCP server)

## Run

```bash
MCP_TRANSPORT=streamable_http \
MCP_API_BASE_URL=http://localhost:8000 \
MCP_API_KEY=your_api_key_here \
python -m mcp_server
```

Multi-tenant mode (single MCP server, many organizations):

```bash
MCP_TRANSPORT=streamable_http \
MCP_API_BASE_URL=http://localhost:8000 \
MCP_SHARED_SECRET=your_mcp_server_secret \
MCP_REQUIRE_TENANT_API_KEY=true \
MCP_REQUIRE_TENANT_ORG_ID=true \
MCP_ALLOW_STATIC_API_KEY_FALLBACK=false \
python -m mcp_server
```

Then each MCP request must include:

- `X-MCP-Server-Token: <MCP_SHARED_SECRET>` (or your configured `MCP_SHARED_SECRET_HEADER`)
- `X-FishDog-Api-Key: <org-specific FishDog API key>` (or your configured `MCP_TENANT_API_KEY_HEADER`)
- `X-FishDog-Organization-Id: <integer org id>` (or your configured `MCP_TENANT_ORG_ID_HEADER`) — the numeric DB id, **not** the 32-hex public id. A non-integer value is rejected with `Organization id header must be an integer`.

The MCP server forwards the tenant key as `Authorization: Bearer <tenant key>` on each upstream API call and adds `X-MCP-Organization-Id` for audit/correlation. Access control is enforced by the API key; `organization_id` is an additional routing/audit signal.

The legacy header names `X-Ditto-Api-Key` / `X-Ditto-Organization-Id` are accepted interchangeably with the canonical `X-FishDog-*` names (the server canonicalizes both), regardless of which set `MCP_TENANT_API_KEY_HEADER` / `MCP_TENANT_ORG_ID_HEADER` pin.

The StreamableHTTP endpoint defaults to:

- `POST http://127.0.0.1:8100/mcp`

Hosted production: `POST https://cat.fish.dog/mcp` — MCP is path-routed off the same ALB as the web app (no separate `mcp.*` host). The hosted prod server runs the multi-tenant mode above (`MCP_SHARED_SECRET` set + `MCP_REQUIRE_TENANT_API_KEY=true` + `MCP_REQUIRE_TENANT_ORG_ID=true`), so **all three headers are mandatory** on every request.

## Tool Map (Current API)

Tool names mirror the `/v1` path naming with dots instead of slashes.

| MCP Tool | Method + Path |
| --- | --- |
| `v1.research-groups.list` | `GET /v1/research-groups` |
| `v1.research-groups.create` | `POST /v1/research-groups` |
| `v1.research-groups.preview` | `POST /v1/research-groups:preview` |
| `v1.research-groups.from-description` | `POST /v1/research-groups:from-description` |
| `v1.research-groups.ask` | `POST /v1/research-groups/{group_id}:ask` |
| `v1.research-groups.agents.add` | `POST /v1/research-groups/{group_id}/agents/add` |
| `v1.research-groups.agents.remove` | `POST /v1/research-groups/{group_id}/agents/remove` |
| `v1.research-groups.recruit` | `POST /v1/research-groups/recruit` |
| `v1.research-groups.interview` | `POST /v1/research-groups/interview` |
| `v1.research-group-requests.create` | `POST /v1/research-group-requests` |
| `v1.research-group-requests.get` | `GET /v1/research-group-requests/{request_id}` |
| `v1.filters.get` | `GET /v1/filters` |
| `v1.agents.find` | `GET /v1/agents/find` |
| `v1.agents.search` | `GET /v1/agents/search` |
| `v1.media-assets.create` | `POST /v1/media-assets` |
| `v1.research-agents.questions.create` | `POST /v1/research-agents/{agent_id}/questions` |
| `v1.research-agents.questions.list` | `GET /v1/research-agents/{agent_id}/questions` |
| `v1.research-groups.questions.create` | `POST /v1/research-groups/{group_id}/questions` |
| `v1.research-groups.questions.list` | `GET /v1/research-groups/{group_id}/questions` |
| `v1.direct-questions.list` | `GET /v1/direct-questions` |
| `v1.direct-questions.get` | `GET /v1/direct-questions/{question_id}` |
| `v1.recent-activity.get` | `GET /v1/recent-activity` |
| `v1.free.questions.ask` | `POST /v1/free/questions` |
| `v1.research-studies.list` | `GET /v1/research-studies` |
| `v1.research-studies.get` | `GET /v1/research-studies/{study_id}` |
| `v1.research-studies.create` | `POST /v1/research-studies` |
| `v1.research-studies.questions.create` | `POST /v1/research-studies/{study_id}/questions` |
| `v1.research-studies.questions.list` | `GET /v1/research-studies/{study_id}/questions` |
| `v1.research-studies.complete` | `POST /v1/research-studies/{study_id}/complete` |
| `v1.research-studies.share.get` | `GET /v1/research-studies/{study_id}/share` |
| `v1.research-studies.share.update` | `POST /v1/research-studies/{study_id}/share` |
| `v1.research-study-requests.create` | `POST /v1/research-study-requests` |
| `v1.research-study-requests.get` | `GET /v1/research-study-requests/{request_id}` |
| `v1.zeitgeist.surveys.create` | `POST /v1/zeitgeist/surveys` |
| `v1.zeitgeist.surveys.delete` | `DELETE /v1/zeitgeist/surveys/{survey_id}` |
| `v1.zeitgeist.surveys.results.list` | `GET /v1/zeitgeist/surveys/{survey_id}/results` |
| `v1.organization.entitlements.get` | `GET /v1/organization/entitlements` |
| `v1.thesis_lab.thesis.compose_and_recruit` | `POST /v1/thesis:compose-and-recruit` |
| `v1.jobs.get` | `GET /v1/jobs/{job_id}` |

`v1.research-studies.get` returns the study `url`, the primary
`research_group_url`, and ordered `research_groups[]` entries with `uuid`,
`name`, and `url` for every group attached to that study.

`v1.recent-activity.get` returns link-bearing items: studies and groups include
`url`, and direct questions include both `question_url` and human-facing
`web_url`.

## Thesis Lab Golden-Path Tools

For Wave 1 Thesis Lab work, agents should use these canonical tools:

- `v1.research-groups.preview`: compile/estimate a group before spending quota.
- `v1.research-groups.from-description`: create a group request from
  natural-language criteria.
- `v1.research-groups.ask`: ask one or more questions to an existing group.
- `v1.thesis_lab.thesis.compose_and_recruit`: create a thesis and launch initial
  recruitment through one idempotent async request.

There is no `v1.recruit.*` namespace. Recruitment tools live under
`v1.research-groups.*` and thesis orchestration lives under
`v1.thesis_lab.*`.

Write tools accept optional `idempotency_key`. If omitted, the MCP server
generates a deterministic key from the agent id when available, tool name,
canonical argument hash, and UTC date. Retrying the same logical call on the
same day replays the same REST operation.

Write tools also accept optional `poll_timeout_seconds`. When this value is
positive, the MCP wrapper forwards the REST request, polls the returned
`job.id` with `v1.jobs.get` until the job is terminal or the timeout expires,
and returns the original REST async envelope plus `job_status` and `mcp_poll`.
Use `0` or omit the field when the caller wants to poll manually.

## Preferred AI-Authored Recruitment Path

For normal AI-authored recruiting flows, prefer the request endpoints:

1. Call `v1.research-group-requests.create` with natural-language audience or
   research-objective text.
2. Poll `v1.research-group-requests.get` with the returned `request_id`.
   If the request returns `refused`, revise the audience/constraint ask and
   retry instead of continuing.
3. Use the completed `group` payload for follow-on study creation.

Use `v1.research-study-requests.create/get` when the user wants the system to
run recruitment and study orchestration together. In `response_mode=sync`, the
create call can immediately return `completed`, `needs_clarification`, or
`refused`; only poll `request_id` when the create call actually returned a
queued/running request. If the study request returns `refused`, revise the
audience/recruitment constraints and retry. For the detailed request contract,
see [Research Study Requests](./research_study_requests.md).

Use `v1.research-groups.recruit` only when the caller explicitly needs strict
raw-filter control, such as exact city targeting or a pre-authored filters
dict. If you are calling the raw HTTP route directly instead of going through
the MCP tool, send `X-Filter-Raw-DSL: true` to make that exception explicit.
`v1.research-groups.interview` remains available as an older direct path,
but the shared request pipeline is now the default AI-authored route.

When request-based recruiting needs hard-to-filter criteria, eligibility
screening happens inside the shared request execution layer. The normal
request flow does not call the public `v1.research-groups.interview` endpoint.

## Quick Direct Agent Q&A

Use `v1.research-agents.questions.create` when the user wants a quick one-off
ask (single question/clarification) without creating a study/group.

- Single-agent ask: call once with `agent_id` + `question`.
- Small multi-agent ask (up to 5 agents): call once per agent with the same
  question, then poll each `job_id` with `v1.jobs.get`.
- If you want a saved summary artifact, pass `requested_summary: true`.
- If files/images are provided: upload each file with `v1.media-assets.create`,
  then pass `attachments` as `[{ "media_asset_id": "<id>" }]`.
- After each job finishes, call `v1.direct-questions.get` with the returned
  `question_id` or `question_uuid` to read the canonical saved answers and
  artifacts.
- For broader workflows (multiple questions or more than 5 agents), use the
  study flow (`v1.research-studies.questions.create`).

## Quick Direct Group Q&A

Use `v1.research-groups.questions.create` when the user wants one direct
question sent to all recruitable agents in a specific group, without creating
or running a study.

- Call with `group_id` (numeric ID or UUID) + `question`.
- If you want a saved summary artifact, pass `requested_summary: true`.
- For attachments, upload first via `v1.media-assets.create`, then pass
  `attachments` as `[{ "media_asset_id": "<id>" }]`.
- Poll returned `job_ids` with `v1.jobs.get` to track completion per agent.
- After one returned job finishes, call `v1.direct-questions.get` with the
  returned `question_id` or `question_uuid` to read the canonical saved run.

## Saved Direct Question Retrieval

Use `v1.direct-questions.list` when the user wants to browse recent saved
direct questions across the organization rather than within one specific group
or agent.

- Call with optional `limit` and `offset` for pagination.
- If you already know the saved question identifier, pass `question_id`.
- Use `v1.direct-questions.get` for the selected record when you need full
  normalized answers, generated artifacts, or attachments.

## Complete Study Notes

Calling `v1.research-studies.complete` is irreversible. It finalizes the study and
triggers the analysis pipeline that generates the summary, insights, correlations,
and final takeaways. The pipeline is asynchronous and typically takes 10-15 minutes.
The tool returns `job_ids` and `queued_steps` so you can poll `v1.jobs.get` for status.

## Free-Tier MCP

Free-tier keys can call `v1.free.questions.ask` and then poll `v1.jobs.get` for the
same job ID. Jobs are only visible to the key that created them.

Example flow:

```json
{"tool": "v1.free.questions.ask", "arguments": {"question": "What do people think about oat milk?"}}
```

Then poll:

```json
{"tool": "v1.jobs.get", "arguments": {"job_id": "<job_id>"}}
```

## Payloads

- Tool arguments map directly to the JSON request bodies documented in `api_docs/openapi.yaml`.
- The MCP server does not transform field names; use the same keys as the REST API.
- `v1.agents.find` and `v1.agents.search` both support `city` filtering.
- `v1.filters.get` discovers country-scoped values and counts. Pass `fields`
  when you only need a subset, for example
  `["health_drug_classes", "health_conditions", "bmi_class"]`.
- For `v1.agents.search`, use the `filters` argument for the full filter DSL when filtering values
  that may contain commas (for example some `industry` taxonomy labels).
- `v1.agents.find`, `v1.agents.search`, and `v1.research-groups.recruit`
  accept typed shortcuts for occupation and health filters, including
  `occupation_major_group`, `labour_status`, `title_variant`, `drug_class`,
  `condition`, `bmi_class`, `phq9_severity`, and health range bounds.
- For GLP-1 cohorts, use `drug_class="glp1_agonist"` or a known alias such as
  `drug_class="Ozempic"`; the API normalizes this to the structured
  `health_drug_classes` value.
- For AI-authored free-text audience traits on `v1.agents.find` / `v1.agents.search`, prefer
  `filters.description` as a structured text object (`contains`, `any_of`, `all_of`).
  Keep top-level `description_contains` only as a legacy compatibility shorthand.
- For strict single-person lookup requests, prefer `v1.agents.find` before `v1.agents.search`.
