# MCP

> Streamable HTTP MCP at /v1/mcp: cassette tools marked x-tapes-mcp, the legacy search tool, and stateless transport limits.

- Repository: papercomputeco/tapes
- GitHub: https://github.com/papercomputeco/tapes
- Human docs: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750
- Complete Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms-full.txt

## Source Files

- `api/mcp/mcp.go`
- `api/mcp/search.go`
- `api/mcp/cassette.go`
- `api/cassetterunner/mcp.go`
- `api/openapi_routes.go`
- `docs/mcp.md`

---

---
title: "MCP"
description: "Streamable HTTP MCP at /v1/mcp: cassette tools marked x-tapes-mcp, the legacy search tool, and stateless transport limits."
---

The read API (`tapes serve` / `tapes serve api`, default `:8081`) mounts a **stateless streamable HTTP** Model Context Protocol server at `/v1/mcp`. The implementation name is `tapes` (binary `utils.Version`). Each request snapshots the live cassette registry; when both a span searcher and an embedder are configured, the server also registers the legacy core tool `search`.

Configure an MCP client as a **streamable HTTP** server pointed at:

```text
http://localhost:8081/v1/mcp
```

Do not point MCP at ingest (`:8082`). Cassette tools call admitted cassette origins the same way the cassette HTTP proxy does; they are not ingest writes.

## Endpoint

One Fiber `All` route wraps the MCP SDK's streamable HTTP handler. Only these verbs are published in `GET /openapi` (tag `mcp`):

| Method | Operation ID | Role |
| --- | --- | --- |
| `POST` | `invokeMcp` | JSON-RPC 2.0 (`initialize`, `tools/list`, `tools/call`, …) |
| `GET` | `openMcpStream` | Server-sent event stream (`text/event-stream`) |
| `DELETE` | `closeMcpSession` | Session-termination semantics |

The published request/response types are deliberately loose JSON-RPC 2.0 envelopes (`params` / `result` are method-dependent). The SDK implements the method set; this server does not re-declare MCP method schemas as first-class Go types.

:::endpoint POST /v1/mcp Invoke streamable MCP
JSON-RPC 2.0 body. Typical methods: `initialize`, `tools/list`, `tools/call`.

**Responses**

- `200` — JSON-RPC result or tool result
- `400` — invalid JSON-RPC
- `500` — server-side MCP error
:::

:::endpoint GET /v1/mcp Open MCP event stream
SSE stream for streamed MCP messages on the same path.
:::

:::endpoint DELETE /v1/mcp Close MCP session
Requests termination when a client uses session-oriented transport semantics.
:::

<RequestExample>
```json
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "query": "how was logging configured?",
      "top_k": 3
    }
  }
}
```
</RequestExample>

## Transport limits

The handler is created with `StreamableHTTPOptions{Stateless: true}`. Capabilities advertise `tools.listChanged: false`.

| Constraint | Behavior |
| --- | --- |
| Stateless HTTP | No durable MCP session on the server. `tools/list` is a per-request snapshot of `Registry.Instances()`. |
| No list-changed notifications | Cassette admit / refresh / remove does not push `notifications/tools/list_changed`. Reconnect or call `tools/list` again. |
| Cassette tool timeout | `30s` per upstream POST. |
| Cassette success body | Hard cap `8 MiB`. Oversize is a tool error. |
| Cassette error body | Non-2xx payload truncated at `4 KiB` before it is placed in the tool error text. |
| Cassette HTTP client | Redirects refused (`CheckRedirect` → last response). Shared with the cassette REST proxy. |
| Tool count | At most `128` advertised tools **per cassette**. Over that refuses the refreshed document. |

<Warning>
Because the transport is stateless, a client that stays connected while the cassette fleet changes will not be notified. Call `tools/list` again after you admit, refresh, or remove a cassette.
</Warning>

```mermaid
sequenceDiagram
  participant Client as MCP client
  participant API as Read API :8081 /v1/mcp
  participant Reg as Cassette registry
  participant Emb as Embedder + span store
  participant Cass as Cassette origin

  Client->>API: POST initialize / tools/list
  API->>Reg: Instances() snapshot
  API-->>Client: tools (cassette.* plus optional search)

  alt tools/call search
    Client->>API: arguments query, top_k
    API->>Emb: Embed(query) then Search(nil-org, vector, top_k)
    Emb-->>API: span hits
    API-->>Client: structured SearchOutput + JSON text
  else tools/call cassette.tool
    Client->>API: JSON object arguments
    API->>Cass: POST local path, JSON body, X-Tapes-Cassette
    Cass-->>API: 2xx JSON object
    API-->>Client: structured content (tool error on non-2xx)
  end
```

## Cassette tools

A cassette advertises tools by putting `x-tapes-mcp` on an **operation** (not on the document, not on a path item, and not inside `x-tapes-cassette`). Admission extracts tools from the **republished** OpenAPI document (paths already rewritten under `/v1/cassettes/{name}`). A bad `x-tapes-mcp` declaration refuses the whole refreshed document. A later invalid refresh keeps the last admitted document **and** its tools.

Published tool name:

```text
{cassetteName}.{x-tapes-mcp.name}
```

Example: cassette `summary` + `"name": "summarize_session"` → `summary.summarize_session`. The bundled example cassette publishes `mcp-tool.ping`.

<ParamField body="x-tapes-mcp.name" type="string" required>
Local tool name. ASCII letters, digits, `.`, `-`, `_` only. Combined with the cassette name the full MCP name must be ≤ 128 bytes.
</ParamField>

<ParamField body="x-tapes-mcp.annotations" type="object">
MCP hints only: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. They are not authorization. Unknown extension fields are ignored.
</ParamField>

Title is the operation `summary`. Description is operation `description`, falling back to `summary`.

### Admission contract

`x-tapes-mcp` is accepted only when all of the following hold:

| Rule | Requirement |
| --- | --- |
| Document | OpenAPI **3.1** (JSON Schema) |
| Method | `POST` only |
| Parameters | No path, query, header, or cookie parameters. All arguments in the JSON body. |
| Request body | Inline (no request-body `$ref`), **required**, `application/json`, schema resolves to an **object** |
| Schema refs | Local `#/components/schemas/...` are bundled into a standalone MCP input schema. Remote refs are refused. Root `type` is forced to `"object"`. |
| Responses | At least one inline `2xx` / `2XX` `application/json` **object** schema |
| Placement | Extension on the operation. Document- or path-level `x-tapes-mcp` is an admission error. |
| Uniqueness | Duplicate `x-tapes-mcp.name` values in one cassette are refused |

Malformed advertised tools fail cassette refresh. Changing a tool changes the republished OpenAPI ETag; it does not change the cassette manifest digest. Admitting a cassette also trusts its operation and schema prose — MCP clients may place that text in an agent context.

```json
{
  "post": {
    "operationId": "summarizeSession",
    "summary": "Summarize a session",
    "x-tapes-mcp": {
      "name": "summarize_session",
      "annotations": {
        "readOnlyHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      }
    },
    "requestBody": {
      "required": true,
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "properties": { "session_id": { "type": "string" } },
            "required": ["session_id"]
          }
        }
      }
    },
    "responses": {
      "200": {
        "description": "Summary",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": { "summary": { "type": "string" } }
            }
          }
        }
      }
    }
  }
}
```

Cassettes that need other HTTP semantics should expose a JSON-body POST facade rather than expecting Tapes to act as a general OpenAPI client.

### Call path

`tools/call` on a cassette tool:

1. Validates arguments against the admitted input schema (invalid args never hit the cassette).
2. Marshals arguments as the JSON POST body (`{}` when the client omits arguments and the schema allows it).
3. Rewrites the public path (`/v1/cassettes/{name}/…`) to the cassette local prefix (for example `/api/{name}/…`).
4. Copies caller identity and tracing headers from the inbound MCP HTTP request. Drops hop-by-hop / representation headers (`Accept-Encoding`, `If-None-Match`, spoofed `X-Forwarded-*`, and the rest of the cassette proxy exclusion list).
5. Sets `Accept` and `Content-Type` to `application/json`, `X-Tapes-Cassette: {name}`, and fresh forwarded headers.
6. POSTs to the admitted cassette origin (scheme/host/port of the configured OpenAPI URL). Redirects are not followed.

A successful 2xx body must be **exactly one JSON object** (not null, not an array, no trailing JSON). That object is returned as MCP structured content. Non-2xx status, transport failure, oversize body, or a non-object body is a tool error.

<CodeGroup>
```sh title="Admit the example cassette, then attach MCP"
make -C pkg/cassette/examples/mcp-tool run
tapes serve api --cassettes=http://127.0.0.1:9999/openapi
```

```sh title="Same operation over HTTP (not MCP)"
curl -X POST http://127.0.0.1:9999/api/mcp-tool/ping \
  -H 'Content-Type: application/json' \
  -d '{"ping":"ping"}'
# {"pong":"pong"}
```
</CodeGroup>

Standalone API refresh interval defaults to `--cassette-refresh=30s`. `tapes serve` also takes `--cassettes` / `cassettes` in config. Tapes does not start cassette processes.

## Legacy core search tool

While span search is being extracted to a cassette, the core tool is registered only when **both** `SpanSearcher` and `Embedder` are set (and a logger is required in that case). Supplying only one of the two is a server construction error. Omitting both is valid: `/v1/mcp` still serves cassette tools.

`tapes serve` always constructs the embedder and span store. `tapes serve api` attaches them only when a vector-store target is set (it defaults to `storage.postgres_dsn` when that DSN is present). `--embed-spans=false` stops the in-process embed worker; it does not remove the `search` tool if the embedder and store were still created.

| Field | Value |
| --- | --- |
| Name | `search` |
| Required | `query` string |
| Optional | `top_k` integer; `<= 0` or omitted → `5` |
| Tenant | Always `00000000-0000-0000-0000-000000000000` (same nil-org bucket as header-less `GET /v1/search/spans`) |
| Index | Main-conversation LLM spans, delta-only content |

The tool embeds `query` with the configured embedding provider, then runs the same `SpanSearcher.Search` path as `GET /v1/search/spans`.

<ParamField body="query" type="string" required>
Search text. Embedded, then compared to the span projection.
</ParamField>

<ParamField body="top_k" type="integer">
Result cap. Default `5`.
</ParamField>

<ResponseField name="query" type="string">
Echo of the input query.
</ResponseField>

<ResponseField name="count" type="integer">
Number of hits in this response.
</ResponseField>

<ResponseField name="results" type="SearchResult[]">
Each hit: `session_id`, `trace_id`, `span_id`, `score`, `user_prompt`, `snippet`, `model`, `started_at`.
</ResponseField>

Structured content is also serialized as a JSON `TextContent` block.

<ResponseExample>
```json
{
  "query": "how was logging configured?",
  "count": 1,
  "results": [
    {
      "session_id": "sess-1",
      "trace_id": "trace-1",
      "span_id": "span-1",
      "score": 0.91,
      "user_prompt": "how do I do X?",
      "snippet": "you do X like this",
      "model": "claude-opus-4",
      "started_at": "2026-06-15T12:00:00Z"
    }
  ]
}
```
</ResponseExample>

Search failures (embedder error, store error, `spanembed.ErrNotInitialized`) return an MCP **tool error** (`IsError: true`) rather than a JSON-RPC transport failure. An uninitialized span-embedding projection means the embed worker has not created/populated the table yet.

## Enable it locally

<Steps>
<Step title="Start storage, embeddings, and the API">
```bash
tapes local up
tapes serve
```

`tapes local up` brings up PostgreSQL/pgvector and the local embedding model. `tapes serve` listens on `:8081` for the read API (including `/v1/mcp`) and embeds eligible spans in the background by default.
</Step>
<Step title="Point a client at streamable HTTP">
Use `http://localhost:8081/v1/mcp`. Seed or capture data before expecting `search` hits:

```bash
tapesctl seed --tapes-url http://localhost:8081
```
</Step>
<Step title="Optional: attach cassette tools">
Start the cassette yourself, then pass its OpenAPI URL:

```bash
tapes serve --cassettes=http://127.0.0.1:9999/openapi
```

Or set `cassettes = ["http://127.0.0.1:9999/openapi"]` in `.tapes/config.toml`.
</Step>
</Steps>

<Info>
If search dependencies are not configured, `search` is omitted from `tools/list`; cassette tools stay available. If the store exists but the span projection is not initialized, `search` is listed and fails until the embed worker writes embeddings.
</Info>

## Scope and authorization

Header-less core MCP search uses the same nil-org tenant UUID as header-less HTTP search. Cassette tools expose whatever the admitted POST implements. MCP annotations are descriptive hints. Caller `Authorization` and other non-excluded inbound headers are forwarded to the cassette; they do not replace gateway or cassette authorization.

## Troubleshooting

| Symptom | What to check |
| --- | --- |
| `search` missing from `tools/list` | Standalone API has no vector-store / Postgres DSN, or embedder + span store were not both constructed. Cassette tools can still appear. |
| `search` tool error contains `not initialized` | Embed worker has not prepared the span-embedding projection. Confirm `tapes serve` embed-spans (or `tapes serve embed-worker`) and matching `embedding.model` / `embedding.dimensions`. |
| Cassette tool missing after edit | Invalid `x-tapes-mcp` refuses the refresh; last good tools remain. Check `GET /v1/cassettes` rejections. Re-run `tools/list`. |
| Cassette tool never invoked | Schema validation failed (required body fields). The cassette sees zero requests. |
| Cassette tool error with HTTP status | Upstream returned non-2xx, a non-object JSON body, trailing JSON, null, or a body over 8 MiB. |
| Stale tool list | Expected: `listChanged` is false. Re-list or reconnect. |
| Wrong port | MCP is on the **read** API (`:8081`), not capture/ingest (`:8082`) or the LLM proxy (`:8080`). |

## Related pages

<CardGroup>
<Card title="Cassettes" href="/cassettes">
`cassette/v1alpha1` admission, `x-tapes-mcp`, and the `/v1/cassettes/{name}` proxy.
</Card>
<Card title="Search spans" href="/search-spans">
`GET /v1/search/spans`, `tapesctl search`, and the same span projection the MCP `search` tool uses.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081`, including the `mcp` tag.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
`embedding.provider`, model, dimensions, `tapes auth`, and the embed worker.
</Card>
<Card title="Run a cassette locally" href="/run-a-cassette">
Admit the bundled hello-world cassette and call the republished surface.
</Card>
</CardGroup>
