# MCP endpoint reference

## Overview

Traffic Parrot speaks the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), the open standard assistants use to discover and use what a tool offers. Point an MCP client at a running instance and your assistant can work with the instance itself: the mappings it is serving, the requests that have arrived, and the scenarios defined: read without you pasting any of it into the conversation, and changed without you copying JSON back out of it.

This page is the reference for that endpoint: who can reach it, the handshake, the tools and their arguments, what `dryRun` does, and every error it answers. To point a client at it and get going, start with [Connect your assistant with MCP](https://trafficparrot.com/documentation/5.65.x/generate-mocks-with-ai-agent.html#mcp) on the AI-agent page, which has the client configuration; come here when you need the detail. The install directory also ships `AGENTS.md`, a brief for the assistant itself that covers this endpoint, the client registration, the tools, and writing mapping files.

Traffic Parrot serves MCP itself, so there is nothing extra to install and no separate process to keep running: if the instance is up, the endpoint is up. It listens on the management port (`8080` by default) and takes JSON-RPC 2.0 over HTTP POST. MCP clients are configured with a bare endpoint URL, so unlike the rest of the management API it does not sit under `/api`:

```
http://localhost:8080/mcp
```

The [trusted-network guidance](https://trafficparrot.com/documentation/5.65.x/generate-mocks-with-ai-agent.html#harden-first) for AI agents applies here too, and matters more: some of these tools change what the instance is serving. [Who can reach it](https://trafficparrot.com/documentation/5.65.x/mcp.html#access), below, is the setting that decides that.

## Who can reach it

By default the endpoint accepts requests from the machine Traffic Parrot is running on and refuses everything else, which is the common case: the assistant runs where you do. Nothing to configure, and no token to manage.

To reach it from anywhere else, set both of these in `trafficparrot.properties` (see the [Configuration Reference](https://trafficparrot.com/documentation/5.65.x/properties.html#mcp-properties)) and restart:

```properties
trafficparrot.mcp.authenticator=BEARER_TOKEN
trafficparrot.mcp.token=a-secret-of-your-choosing
```

Every request must then carry `Authorization: Bearer <token>`. There is no default token, deliberately, and Traffic Parrot will not start with `BEARER_TOKEN` and no token set: a token that ships with the product is a published one.

The most common reason to need this is Traffic Parrot in a container with a published port: requests then arrive from the container network rather than from loopback, so the default refuses them even though you are sitting at the same machine.

A request the authenticator refuses comes back as HTTP 403 with a JSON-RPC error saying which property to change, so your assistant can tell you what to do rather than reporting an opaque failure. The reason never contains the configured token.

### With the web UI login switched on

If you have also switched on the web UI login ([`trafficparrot.gui.security.mode=LOGIN_PROPERTIES`](https://trafficparrot.com/documentation/5.65.x/properties.html#gui-properties)), that covers `/mcp` as well and answers first. Your MCP client must then send HTTP BASIC credentials from `trafficparrot.gui.login.properties` on every request to `/mcp`, as well as anything the MCP authenticator requires; a request that omits them is refused whether or not its bearer token is right. That makes three refusals from the two logins, and a fourth from the [Origin check](https://trafficparrot.com/documentation/5.65.x/mcp.html#access-origin) below, which applies whether or not the login is on; no single field tells them apart. Each is identifiable from its status and shape, so your assistant can tell you what you are looking at:

| Status | Body | Refused by | What to send or change |
| --- | --- | --- | --- |
| `401` | A JSON-RPC error (`-32001`) whose message names the property that switched the login on and the file the credentials come from. | The web UI login, always. | HTTP BASIC credentials from `trafficparrot.gui.login.properties`, on every request. |
| `403` | A JSON-RPC error (`-32001`) whose message says what to send. | The `trafficparrot.mcp.authenticator` setting above. | `Authorization: Bearer <token>`, matching `trafficparrot.mcp.token`. |
| `403` | A small plain JSON object with no `error.code`. | The web UI login again, refusing a signed-in user whose `trafficparrot.gui.login.properties` entry does not grant `traffic-parrot-gui-role`. | The login entry: grant it `traffic-parrot-gui-role`. |
| `403` | A JSON-RPC error (`-32002`) whose message quotes the web origin it refused. | The [Origin check](https://trafficparrot.com/documentation/5.65.x/mcp.html#access-origin) below, whether or not the login is on. | For a client that speaks HTTP directly, nothing: it sends no `Origin`. For a browser-hosted client, its origin in `trafficparrot.mcp.allowedOrigins`. |

The signed-in user's roles apply to the tools as well. A user whose entry grants `traffic-parrot-gui-role` but not `traffic-parrot-gui-edit-role` can list the tools and call the seven that only read; a call to any other tool is answered, before the tool runs, as a tool result with `isError: true` whose text names the role. See [Role based access](https://trafficparrot.com/documentation/5.65.x/user_guide.html#role-based-access).

### From a browser

A request that carries an `Origin` header is refused unless that origin is listed in `trafficparrot.mcp.allowedOrigins`, whichever authenticator is in use and whether or not the login is on. Only a browser sends that header, so an assistant that speaks HTTP directly (Claude Code, Claude Desktop, Copilot, Cursor, Gemini and `curl` all do) is unaffected. The list ships empty, so a browser-hosted MCP client is refused until you name its origin:

```properties
trafficparrot.mcp.allowedOrigins=https://mcp-client.example.com,http://localhost:3000
```

Each entry is `scheme://host` or `scheme://host:port`, matched without regard to case. The `null` origin that a sandboxed frame or a `file://` page sends names no host and cannot be listed.

The check exists because a page you are merely visiting can make its own hostname resolve to `127.0.0.1` (DNS rebinding), so your browser posts to `/mcp` from your own machine, which the default authenticator accepts. The `Origin` header is the one part of that request the page can neither set nor remove, which is why it is what gets checked, and checked before the body is read. The check covers `/mcp` only; the web UI and the admin API answer as they did before.

A refused request comes back as HTTP 403 with a JSON-RPC error (`-32002`, `id` `null`) whose message quotes the refused origin and names the property to list it in. It is deliberately not the authenticator's `-32001`: that one refuses the caller, this one refuses the page the caller is acting for, and the two are fixed at different properties.

## The handshake

An MCP client opens the conversation with an `initialize` call. Your client does this for you when it connects, but making the call by hand is the quickest way to confirm the endpoint is reachable:

```bash
curl -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
```

Traffic Parrot identifies itself and answers with the protocol revision it has negotiated with your client. The call above named no revision, so it gets the newest one Traffic Parrot speaks:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": { "tools": { "listChanged": false } },
    "serverInfo": { "name": "traffic-parrot", "version": "5.63.2" }
  }
}
```

A client that does name a revision, in `params.protocolVersion`, gets that same revision back when Traffic Parrot speaks it:

```bash
curl -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18"}}'
```

That answers `"protocolVersion": "2025-06-18"`. The revisions Traffic Parrot speaks are `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05` and `2024-10-07`, newest first. A client that asks for anything else, or asks for nothing at all, is answered `2025-11-25` rather than having its handshake refused. The rest of the answer is the same whichever revision is negotiated: the revision describes the shape of the messages, while `capabilities` describes what this instance can actually do.

`serverInfo.version` is the version of the instance you reached, which is a quick way to confirm your client is talking to the instance you meant. `listChanged: false` says the tool list is fixed for the lifetime of the connection, so a client does not need to watch for it changing.

Once connected, a client can check that the endpoint is still there with a `ping` call, which Traffic Parrot answers with an empty `result` object. It is a protocol method rather than a tool: it does not appear in the tool list and is not called through `tools/call`.

## After the handshake

Once a revision has been negotiated, an MCP client states it on the requests that follow, in an `MCP-Protocol-Version` header. Your client does this for you, sending back the revision the handshake answered with. The header is read whichever way your client capitalises its name:

```bash
curl -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Traffic Parrot checks that header. A request naming a revision it does not speak is refused with HTTP 400 and a JSON-RPC error, rather than being served as though the two ends had agreed on a revision they had not. The message names both the revision that arrived and every revision this instance speaks, so the refusal carries what your client needs to correct itself:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32000,
    "message": "Unsupported protocol version: 2026-01-01 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"
  }
}
```

The header is not required. A request that carries none is served exactly as it would be otherwise, so a client that does not set it, or a call you write by hand, keeps working. Sending it with an empty value is not the same as leaving it out: an empty value names a revision Traffic Parrot does not speak, and is refused like any other.

The `initialize` call is the exception, and is served even when the header names a revision Traffic Parrot does not speak. The handshake is where the revision is settled, and the [negotiation above](https://trafficparrot.com/documentation/5.65.x/mcp.html#handshake) deliberately answers a client that asks for a revision Traffic Parrot does not have with `2025-11-25` rather than refusing it. Refusing that same client on its header would take that back, so the header is not checked on a handshake.

## The tools it offers

A client asks for the catalogue with `tools/list`. Traffic Parrot offers thirteen tools. Seven of them only read, and are safe to point an assistant at while you are relying on the instance:

| Tool | What it reads | Arguments |
| --- | --- | --- |
| `list_mappings` | The HTTP mappings (stubs) the instance is serving from its currently selected scenario, with their request matchers and a summary of each response. | None. |
| `get_mapping` | One mapping in full, by id, including its request matchers and its response. A body held inline comes back in full; a body stored in an external file comes back as `bodyFileName` only, which means "a body exists, held in that file" rather than an empty response: `get_body_file` reads it. | `id` (required). |
| `get_body_file` | One of the selected scenario's `__files` entries, as text, by the name `get_mapping` reports as `bodyFileName`: this is how an assistant sees a response body that is stored in a file instead of inline. Only that scenario's `__files` directory is readable, and a name pointing outside it is refused. A binary file is not returned, because the tool carries text: the result says so, names the file's size and points at the [Static Files](https://trafficparrot.com/documentation/5.65.x/http.html#static-files-browser) page. Contents longer than 64 KB come back cut short, and the result's `bytes`, `returnedBytes` and `truncated` fields say how much of the file came back, so an assistant reads `truncated` before treating `contents` as the whole body. | `name` (required): the file name exactly as `get_mapping` reported it, which may include a sub-directory, as in `sub/dir/body.json`. |
| `list_received_requests` | Requests the instance has received, from the request journal, each tagged with the scenario that served it, so the assistant can see the real traffic a mapping has to match. | `limit`: most recent first. Requests served by scenarios on their own port are always included. |
| `report_coverage` | Which endpoints of the selected scenario have mappings and which do not, so you can find gaps and drift against a specification. | None. |
| `list_scenarios` | The scenarios defined in the instance, and which one is currently selected. | None. |
| `verify_mapping_matches` | Whether the mappings loaded now still match the requests the instance has received, by re-testing each received request against them. Use it after changing mappings to find traffic that no longer matches. A mapping with a custom request matcher cannot be evaluated this way and is listed under `notVerifiableMappings` rather than reported as matching nothing. | `id`: check one mapping; omit for all. `limit`: received requests to check, most recent first; omit for the whole journal. |

The other six change something. Each one says where the change lands, because that is what you cannot tell from the tool name: the mapping tools change what the **running** instance is serving from its selected scenario **and the change is written through to that scenario's mapping files**, so a scenario directory kept in version control shows it as a local change, while `cleanup_mappings` writes a new scenario to disk.

| Tool | What it changes | Arguments |
| --- | --- | --- |
| `create_mapping` | Adds one new mapping to the running instance. The id is assigned for you and returned. Supplying the id of a mapping that already exists is refused rather than replacing it; that is what `update_mapping` is for. A mapping's file is named after the mapping, so a name that would produce the same file name as another mapping's is refused rather than overwriting it: rename the mapping and call again. | `mapping` (required): a WireMock stub mapping JSON object with at least a `request` and a `response`, the same shape `get_mapping` returns. `dryRun`: returns the mapping that would be created and changes nothing. |
| `update_mapping` | Replaces one existing mapping, keeping its id. | `id` and `mapping` (both required; any id inside the mapping is ignored). `dryRun`: returns the mapping that would be replaced and the full replacement, and changes nothing. |
| `delete_mapping` | Removes one mapping. | `id` (required). `dryRun`: returns the mapping that would be removed and changes nothing. |
| `select_scenario` | Switches which scenario the instance is serving. Every other tool works on the selected scenario, so this decides what they all see. | `name` (required): as returned by `list_scenarios`. |
| `generate_mappings_from_openapi` | Builds mappings from an OpenAPI specification file, so a scenario can be brought back in line with a newer spec. Re-running it for the same file replaces the mappings that file produced last time rather than adding a second copy. | `specificationFile` (required): the *name* of a file already in the instance's OpenAPI directory. The specification itself cannot be supplied here, and a path is not accepted. `dryRun`: returns the mappings that would be added. A preview builds those mappings by reading the specification exactly as a real run does, so an example that points at a URL with `externalValue` is fetched over the network even when `dryRun` is true. That fetch passes through the same guard as importing a specification from a URL (`trafficparrot.http.importfromurl.enabled` and `trafficparrot.http.importfromurl.allowedHosts`), so on an instance that has not opted in it is refused and the example becomes a placeholder naming the reason. |
| `cleanup_mappings` | Turns the recorded mappings of the selected scenario into a flexible mock: hardcoded values become URL patterns, static responses become templates, duplicates are consolidated. The result is written to a *new* scenario and the recording it was made from is left untouched. The new scenario carries the HTTP mappings and `__files` only, so it is not a complete copy of the recording: any mapping directory left behind (`jms-mappings` and the other non-HTTP mapping directories) is named in `droppedMappingDirectories`, so the assistant can tell you the cleaned scenario is not a drop-in replacement. See [What the cleaned scenario does not carry](https://trafficparrot.com/documentation/5.65.x/http.html#cleanup-not-copied). | `targetScenario`: the new scenario's name, which must be a name inside the instance's `scenarios` directory; omit for a suggested one. An existing name is refused rather than overwritten, and so is a name that would resolve outside that directory (one climbing out of it with `..`, or an absolute path). `dryRun`: returns what the cleanup would do and writes nothing. |

`tools/list` reports the arguments each tool accepts, and your client shows them to you. Read them there rather than from a list written down elsewhere: the catalogue ships inside Traffic Parrot, so it always describes the instance you are actually connected to. The tables above are the same catalogue at the time of this release.

### What `dryRun` does

**Anything that can lose work takes `dryRun`, and it defaults to `true`.** A call that omits it previews and changes nothing, and the preview tells the assistant to call again with `"dryRun": false` to apply it. So an assistant that reaches for one of these tools without being explicit shows you what it would do first. Every one of these tools that writes a mapping takes `dryRun`, `create_mapping` included. `select_scenario` is the only one with none: it writes no mapping, and a scenario switch is undone by switching back.

`dryRun` means nothing in Traffic Parrot changes. It does not mean nothing happens at all. A preview does strictly *less* than the real call (`generate_mappings_from_openapi`, for one, skips removing the mappings an earlier import of the same file created), but the work it does can still reach outside the host: `generate_mappings_from_openapi` reads the specification file and fetches any `externalValue` example URL in it during a preview, just as it would when applying. That fetch goes out from the Traffic Parrot host, is limited to `http` and `https`, follows no redirects, and is capped in size and time. It passes through the same guard as importing a specification from a URL (see [Properties](https://trafficparrot.com/documentation/5.65.x/properties.html)): it is refused while `trafficparrot.http.importfromurl.enabled` is `false`, which is the default; refused when the URL's host is not listed in `trafficparrot.http.importfromurl.allowedHosts`; and refused when that host resolves to a loopback, link-local, private or cloud-metadata address, even if it is listed. So an instance that has not opted in fetches nothing during a preview, and one that has reaches only the hosts it lists; either way, treat a specification the same way whether you preview it or apply it.

A fetch that cannot complete (a refusal by that guard, an unreachable host, a non-200 response, or a timeout) does not fail the import: Traffic Parrot puts a short `Traffic Parrot could not fetch externalValue: <url> (<reason>)` placeholder in place of that example, and a response larger than the size cap is truncated rather than rejected. Either way the mapping is still created and looks right in the preview listing, but the placeholder or truncated content becomes the response body (or a response header value) that the stub then serves at replay. A fetch that did not complete is summarised in the tool's `warnings` field, on a preview as well as an apply: one entry counts them and lists up to ten of the placeholders. A truncated response is not reported there, and a preview's mapping summaries carry no body, so truncation is only visible after an apply, in the mapping's response body. A refused or failed fetch also writes a WARN line to the [log](https://trafficparrot.com/documentation/5.65.x/logging.html), during a preview as well as an apply: `Refused to fetch externalValue from <url>: <reason>` for a refusal by the guard, and `Failed to fetch externalValue from <url>: <reason>` otherwise. So if the Traffic Parrot host cannot reach the `externalValue` URLs, or has not been allowed to, read the warnings after a preview, and the mapping after an apply, before you rely on either.

## Running a tool

Your client runs a tool with `tools/call`, naming it and passing its arguments. You do not normally write these calls yourself (the assistant does), but making one by hand is the quickest way to see the shape of an answer:

```bash
curl -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"delete_mapping","arguments":{"id":"8f2b..."}}}'
```

The result comes back as MCP content blocks. Because `delete_mapping` takes `dryRun` and the call above did not say otherwise, nothing was deleted: the answer describes what would happen and how to commit it:

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [ { "type": "text", "text": "{\"dryRun\":true,\"wouldDelete\":{...},\"wouldOrphanFiles\":[],\"toApply\":\"Call delete_mapping again with \\\"dryRun\\\": false.\"}" } ],
    "isError": false
  }
}
```

Only the advertised tools can be called, so a call naming a tool this instance does not offer is refused before it reaches anything.

`isError: true` means the tool ran but could not answer: an unknown mapping id, a disabled request journal, an argument the tool rejects as missing, of the wrong type or out of range (`Argument 'id' is required`, `Argument 'dryRun' must be true or false, but was 'no'`), or a failure the tool ran into (`Tool 'list_scenarios' failed: Permission denied. The cause is in the Traffic Parrot log.`), so the assistant can correct the call, or tell you what stood in its way, and try again. That is different from a JSON-RPC error, which means the call itself was wrong (`tools/call` without a `name`, a tool this instance does not offer, or `arguments` that is not an object comes back as `-32602`, an unknown method as `-32601`; [the error-code table](https://trafficparrot.com/documentation/5.65.x/mcp.html#errors) lists every shape). Both are different again from an empty result, which is a successful answer that says there is nothing there, so an assistant is never left reading "nothing matched" as "the tool failed". When a tool fails unexpectedly the result's text names the tool and the reason (a permission error, a missing file, a file that already exists, the operating system's text for any other file-system failure, or otherwise the type of the exception), never a path; the cause, with its stack trace, is in the Traffic Parrot log, because what goes back to the assistant also goes to whichever model provider it uses.

## The errors it answers

[Running a tool](https://trafficparrot.com/documentation/5.65.x/mcp.html#running-a-tool) tells the outcomes apart by shape; this table is the JSON-RPC error codes themselves, and the HTTP status each arrives under. The status tells you which layer answered: `400` means the request was refused before anything was dispatched, so the error's `id` is `null` (nothing trustworthy was read), while `200` means your call was dispatched and answered with an error carrying the call's own `id`. In a batch each call is answered on its own, so error objects sit in the answer array alongside results, all under one `200`.

| Code | When you see it | HTTP status |
| --- | --- | --- |
| `-32700` | The body was not JSON, or was empty. | 400 |
| `-32600` | JSON arrived, but not a JSON-RPC 2.0 request. A body that is not a request object or a batch of them, a batch with nothing in it, and a batch of more than 100 requests are refused whole; a call whose `jsonrpc` member is missing or not `"2.0"`, or that names no `method`, is answered individually. | 400 for the body, 200 for a dispatched call |
| `-32601` | The `method` is not one Traffic Parrot serves. The methods are `initialize`, `ping`, `tools/list` and `tools/call`; a call naming an unknown *tool* comes back as `-32602`, not this. | 200 |
| `-32602` | The call itself was wrong: `tools/call` without a `name`, a tool this instance does not offer, or `arguments` that is not an object; the message names which. Params sent by position rather than as a named object are also this code, refused before dispatch. An argument the named tool rejects, as missing, of the wrong type or out of range, is not this code but a result with `isError: true` that names the argument, so the assistant can correct it, as MCP `2025-11-25` asks; see [Running a tool](https://trafficparrot.com/documentation/5.65.x/mcp.html#running-a-tool). | 200; 400 for positional params |
| `-32603` | A call in a batch failed in dispatch, before its tool ran. The message is fixed (`This batch member failed. The cause is in the Traffic Parrot log.`) and carries nothing from the failure, and the batch's other members are still answered, because their writes may already be on disk. A tool that fails once it is running is not this code but a result with `isError: true` naming the tool and the reason, since MCP `2025-11-25` files a tool's failure under tool execution errors; see [Running a tool](https://trafficparrot.com/documentation/5.65.x/mcp.html#running-a-tool). | 200 |
| `-32000` | The `MCP-Protocol-Version` header names a revision this instance does not speak. The refusal and its message are shown in [After the handshake](https://trafficparrot.com/documentation/5.65.x/mcp.html#protocol-version-header). | 400 |
| `-32001` | The request was refused before it was read, by the MCP authenticator (403) or the web UI login (401). A third refusal, the login's role-mismatch 403, carries no JSON-RPC error at all; [Who can reach it](https://trafficparrot.com/documentation/5.65.x/mcp.html#access) tells the three apart. | 401 or 403 |
| `-32002` | The request carried an `Origin` header naming a web origin that is not listed in `trafficparrot.mcp.allowedOrigins`. Refused before the body is read, so `id` is `null`; the message quotes the origin and names the property. [From a browser](https://trafficparrot.com/documentation/5.65.x/mcp.html#access-origin) says why only a browser meets this. | 403 |

A refusal by HTTP itself carries no JSON-RPC envelope at all: `GET /mcp` is answered 405 (the endpoint takes `POST` only), a `Content-Type` other than JSON is answered 415, and an `Accept` that allows neither `application/json` nor `text/event-stream` is answered 406. The body of those follows your `Accept` header (an HTML page for a browser, a small JSON object for a client that asked for JSON), but it never carries an `error.code` member. So an answer without a code is not a new kind of JSON-RPC error: it is the request being refused before JSON-RPC was reached, and the thing to fix is usually the request's method, `Content-Type` or `Accept` header, or, for the role-mismatch 403 above, the login entry.
