# Connect an AI assistant (MCP)

Point Claude, ChatGPT or your own agent at the Simple Feedback MCP server to build surveys and read results, signing in from the assistant or with an API key.

Simple Feedback speaks the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). Connect an AI assistant to
it and the assistant can list your venues, build and publish surveys, create QR codes and read responses,
statistics and insights. It runs on your assistant and your model; the only call that uses your plan's AI quota is
generating insights.

The MCP server uses the same scopes and plan check as the REST API. An assistant connects either by signing in
(OAuth, nothing to copy) or with an API key; see [Authentication and API keys](https://simplefeedback.de/developer/docs/authentication.md) for keys.

## Endpoint

| Setting   | Value                                                                              |
| --------- | ---------------------------------------------------------------------------------- |
| URL       | `https://simplefeedback.de/api/mcp`                                                             |
| Transport | Streamable HTTP, stateless                                                         |
| Method    | `POST` only; `GET` and `DELETE` answer `405`                                       |
| Auth      | `Authorization: Bearer <token>`: an OAuth access token or an API key               |
| Headers   | `Content-Type: application/json` and `Accept: application/json, text/event-stream` |

There is no MCP session: every request is authenticated on its own.

## Sign in from the assistant (OAuth)

Clients that support MCP authorization, such as Claude and Claude Code, need only the URL. On the first connection
the client opens a Simple Feedback page in your browser. Sign in, pick the organization, untick any permission you
don't want to give, and allow access. An owner or admin of the organization has to do this, and the organization's
plan must include API access.

The client receives a token that lasts an hour and refreshes it on its own. The access shows up under
**Settings → API keys → Connected apps** with the app's name and who approved it. There you can change what the app
may do, which applies from its next request, or disconnect it, which signs the client out.

For your own client, the server follows the
[MCP authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization): a request
without a token answers `401` with a `WWW-Authenticate` header pointing at
`https://simplefeedback.de/api/.well-known/oauth-protected-resource/mcp`. Registration is dynamic (RFC 7591), the code flow
requires PKCE with `S256`, and refresh tokens rotate on every use. A refresh token used twice ends the grant.
Scopes are the API scopes, space-separated; a client that asks for none is offered all of them.

## Connect a client

### Claude

In Claude, add a custom connector with the URL `https://simplefeedback.de/api/mcp` and connect it. Claude opens the sign-in page.

### Claude Code

```bash
claude mcp add --transport http simplefeedback "https://simplefeedback.de/api/mcp"
```

Then run `/mcp` in Claude Code and choose to authenticate. With an API key instead, add
`--header "Authorization: Bearer $SF_KEY"` to the command.

### Any client that supports remote MCP servers

Enter the URL `https://simplefeedback.de/api/mcp`. If the client can't sign in, add the header
`Authorization: Bearer <your API key>`.

### Clients that only start local servers

Some desktop clients can only launch local (stdio) servers from a config file. Bridge them with the community
package `mcp-remote`:

```json
{
  "mcpServers": {
    "simplefeedback": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://simplefeedback.de/api/mcp", "--header", "Authorization: Bearer sfk_live_..."]
    }
  }
}
```

If your client can add a remote server directly, prefer that. It is one moving part fewer.

### Your own agent in TypeScript

```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(new URL('https://simplefeedback.de/api/mcp'), {
  requestInit: { headers: { Authorization: `Bearer ${process.env.SF_KEY}` } },
});

const client = new Client({ name: 'my-integration', version: '1.0.0' });
await client.connect(transport);

const { tools } = await client.listTools();
const me = await client.callTool({ name: 'whoami', arguments: {} });
```

### Check the connection with curl

```bash
curl -X POST "$SF_API/mcp" \
  -H "Authorization: Bearer $SF_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

The answer lists the tools your key may use.

## Tools

| Tool                    | Scope                | What it does                                                                   |
| ----------------------- | -------------------- | ------------------------------------------------------------------------------ |
| `about_simplefeedback`  | none                 | What Simple Feedback is and what your plan allows                              |
| `whoami`                | none                 | The organization, plan, scopes and venue of the key                            |
| `list_venues`           | `surveys:read`       | All venues of the organization                                                 |
| `list_surveys`          | `surveys:read`       | Survey summaries, newest first, paginated                                      |
| `get_survey`            | `surveys:read`       | One survey with its pages, questions and settings                              |
| `create_survey`         | `surveys:write`      | Creates a survey, from a full body or from a short draft                       |
| `update_survey`         | `surveys:write`      | Changes a survey; each field sent replaces the old value                       |
| `delete_survey`         | `surveys:write`      | Deletes a survey for good                                                      |
| `undo_last_change`      | `surveys:write`      | Puts a survey back the way it was before the last change made through the API  |
| `get_survey_qr`         | `surveys:read`       | The public link and a QR code image of it                                      |
| `get_plan_fit`          | `surveys:read`       | Surveys beyond the plan, the locked ones and when the grace period ends        |
| `preview_plan_copy`     | `surveys:read`       | What a copy that fits the plan would contain and lose                          |
| `create_plan_copy`      | `surveys:write`      | Replaces a survey with a copy that fits; moving QR codes also needs `qr:write` |
| `create_qr_code`        | `qr:write`           | A saved QR code with a short link and scan statistics                          |
| `list_qr_codes`         | `qr:read`            | Saved QR codes, newest first, paginated                                        |
| `get_qr_code`           | `qr:read`            | One saved QR code, optionally with its image                                   |
| `get_qr_code_stats`     | `qr:read`            | Scans per day and platform, and how many ended in a response                   |
| `get_qr_codes_stats`    | `qr:read`            | All codes compared: scans, responses, busy hours and hints                     |
| `update_qr_code`        | `qr:write`           | Renames a code, switches it off or points it at another survey                 |
| `delete_qr_code`        | `qr:write`           | Deletes a saved QR code and its scan statistics for good                       |
| `create_access_codes`   | `access-codes:write` | Creates single-use codes; they are shown only in this result                   |
| `list_access_codes`     | `access-codes:read`  | Single-use codes with their status, newest first, paginated                    |
| `revoke_access_codes`   | `access-codes:write` | Revokes the listed codes or every unused one                                   |
| `get_access_code_qr`    | `access-codes:read`  | The link and a QR code image of one code you hold                              |
| `list_responses`        | `responses:read`     | Submitted responses, newest first, paginated                                   |
| `get_survey_statistics` | `responses:read`     | Counts, distributions and averages per question                                |
| `get_insights`          | `insights:read`      | The stored AI analysis, without generating anything                            |
| `generate_insights`     | `insights:write`     | Runs a new AI analysis; the only tool that uses your AI quota                  |
| `upload_image`          | `files:write`        | Uploads a survey logo or background; returns the id a survey refers to         |

**The tool list follows the key's scopes.** A read-only key never sees `create_survey`, so the assistant only plans
with tools that will work. The scope is still checked on every call.

**Hints for the client.** Every tool carries MCP annotations. Tools that only read are marked `readOnlyHint`,
`delete_survey`, `delete_qr_code` and `revoke_access_codes` are marked `destructiveHint`, and the other writing tools are
neither. The tools behind a `POST` route (`create_survey`, `undo_last_change`, `create_qr_code`, `create_plan_copy`,
`create_access_codes`, `revoke_access_codes`, `get_access_code_qr`, `generate_insights`) are also marked
`idempotentHint`, because they take an `idempotencyKey`. Clients such as Claude use the hints to decide which calls to confirm with you
first. They are hints only: the key's scopes decide what a call may do.

**Retries.** The same tools take an optional `idempotencyKey`, which works like the REST [`Idempotency-Key` header](https://simplefeedback.de/developer/docs/overview.md): repeat a call with the same key
after a timeout, and you get the first result instead of a second survey, a second QR code, a second batch of codes or a
second AI run.

**Drafts.** For a new survey, `create_survey` takes a short `draft`: a name, a language, a welcome line and a flat
list of questions. The server builds the pages, ids and presentation defaults from it, exactly as for
[`POST /v1/surveys`](https://simplefeedback.de/developer/docs/building-a-survey.md).

**Undo.** Every change made through the API or MCP can be undone one step at a time with `undo_last_change`, or
with `POST /v1/surveys/{id}/undo` over REST. It answers `NOTHING_TO_UNDO` when there is no such change, which
includes edits made in the dashboard, and `SURVEY_CHANGED_SINCE` when someone edited the survey afterwards. It
doesn't bring back a deleted survey.

**QR codes.** `get_survey_qr` returns a throwaway image that is recorded nowhere. Pass `includeImage: false` when
the link is all you need; the PNG is tens of kilobytes of context. `create_qr_code` creates a code
that shows up in the dashboard, has a short link and counts scans; it counts against your plan's QR code limit.

**Single-use codes.** For a survey that each person may answer only once with a personal code, the assistant sets
`settings.participation.accessMode` to `code` with `update_survey` and creates the codes with
`create_access_codes`. The codes are in that result only, so it hands them to you right away. See
[Single-use codes](https://simplefeedback.de/developer/docs/single-use-codes.md).

**After a downgrade.** Surveys that use more than the new plan allows (more pages or languages, custom branding, a
Google review link) keep working until the grace period ends; then they are locked and respondents see a pause page.
`get_plan_fit` lists them with what each one goes beyond. `preview_plan_copy` shows the copy that would fit, and
`create_plan_copy` creates it, moves the QR codes you list in `qrCodeIds` to it and locks the original for good. The
copy starts without responses; the original's stay readable. List the survey's codes with `list_qr_codes` first:
a code you leave out keeps opening the locked original. Over REST the same is `GET /v1/surveys/plan-fit` and
`GET` / `POST /v1/surveys/{id}/plan-copy`.

> **Warning:** An assistant with `surveys:write` can change and delete live surveys. Give it a key with only the
> scopes it needs, and a venue key if it should only touch one location.

## Resources

The server publishes four resources. The schemas describe the full survey body that `get_survey` returns and
`update_survey` accepts; a body that matches them is a body the server accepts.

| URI                                       | Type                      | Content                                                 |
| ----------------------------------------- | ------------------------- | ------------------------------------------------------- |
| `simplefeedback://schema/survey-modules`  | `application/schema+json` | JSON Schema of a survey's pages and every question type |
| `simplefeedback://schema/survey-settings` | `application/schema+json` | JSON Schema of the survey settings                      |
| `simplefeedback://schema/survey-branding` | `application/schema+json` | JSON Schema of the custom header and footer             |
| `simplefeedback://guide/getting-started`  | `text/markdown`           | A short walkthrough written for the model               |

## Errors

A failed tool call is a normal result marked `isError`. Its text starts with the same `CODE: message` the REST API
uses, followed by `details` as JSON when there are any:

```json
{
  "isError": true,
  "content": [{ "type": "text", "text": "SURVEY_NOT_FOUND: Survey not found" }]
}
```

The codes are listed in [Errors](https://simplefeedback.de/developer/docs/errors.md). What an assistant should do with the common ones:

| Code                                                             | What to do                                           |
| ---------------------------------------------------------------- | ---------------------------------------------------- |
| `INSUFFICIENT_SCOPE`                                             | Don't retry; tell the user which scope the key needs |
| `VALIDATION_FAILED`                                              | Read the schema resource again and fix the input     |
| `SURVEY_NOT_FOUND`                                               | Wrong id, or another organization's survey           |
| `EMAIL_NOT_VERIFIED`                                             | Don't retry; the user has to verify their email      |
| `TIER_LIMIT_REACHED`, `FEATURE_NOT_IN_TIER`, `AI_QUOTA_EXCEEDED` | Don't retry; a person has to upgrade or wait         |
| `SURVEY_CHANGED_SINCE`                                           | Read the survey and make the change the user wants   |
| `RATE_LIMITED`                                                   | Wait and retry                                       |
| `UPSTREAM_ERROR`, `INTERNAL_ERROR`                               | Retry later                                          |

When `create_survey` with `active: true` creates the survey but can't publish it, `details.createdSurveyId` names
the draft. Publish it with `update_survey` once the problem is fixed instead of creating it again.

A rejected key fails before any tool runs. It answers with HTTP `401` or `403` and a JSON-RPC error with code
`-32001`:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32001,
    "message": "API_KEY_MISSING: Authorization header with a Bearer API key is required",
    "data": { "code": "API_KEY_MISSING" }
  }
}
```

## Rate limits

MCP budgets are separate from the REST API's. Each JSON-RPC method has its own, and `tools/call` counts each tool
separately:

| Applies to                        | Limit                   |
| --------------------------------- | ----------------------- |
| Each key, per method or tool      | 300 requests per minute |
| Each key, `get_survey_statistics` | 60 requests per minute  |
| Each key, `generate_insights`     | 2 requests per minute   |
| Each organization or IP address   | 300 requests per minute |

Over the limit, the request answers HTTP `429` with a `Retry-After` header and a JSON-RPC error whose `data` carries
`retryAfterSeconds`.

**Batches.** A JSON-RPC batch counts every message in it, and is refused as a whole before anything runs if it would
go over a limit. A batch of more than 20 messages is rejected with HTTP `400` and `data.maxBatchSize: 20`; send
fewer per request. Current MCP clients send one message per request and never meet this.
