# 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.

## 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-Ditto-Api-Key`; per-request tenant key header)
- `MCP_TENANT_ORG_ID_HEADER` (default: `X-Ditto-Organization-Id`; optional per-request org routing header)
- `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-Ditto-Api-Key: <org-specific Ditto API key>` (or your configured `MCP_TENANT_API_KEY_HEADER`)
- `X-Ditto-Organization-Id: <org id>` (or your configured `MCP_TENANT_ORG_ID_HEADER`)

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 StreamableHTTP endpoint defaults to:

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

## 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.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 `docs/api/guides/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.
- 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).
- 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`.
