> ## Documentation Index
> Fetch the complete documentation index at: https://ventstream.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP server

> Give AI agents read-only access to your sink data with ventstream mcp

VentStream ships a read-only [MCP](https://modelcontextprotocol.io) server:
`ventstream mcp`. It lets AI agents — Claude Desktop, IDE assistants, support
bots — query the documents your pipelines materialize in the sinks, without
holding any source-database credentials.

The server is part of the open-source engine binary. It needs nothing but
your pipeline config and read-only sink credentials, and it never touches
the source database: agents read the sink documents the pipeline keeps in
sync within milliseconds.

## The config

`ventstream mcp` takes the same pipeline config your engine already runs —
usually the exact file, unchanged. From it the server learns three things:
where the sink is (and its credentials), what the targets are, and what the
documents look like (from the joins spec). A typical config:

<AccordionGroup>
  <Accordion title="pipeline.yaml — a typical pipeline config" icon="file-code">
    ```yaml theme={null}
    # pipeline.yaml — the same file the CDC engine runs
    schema_version: 1
    roles: [cdc]

    source:                    # ignored by the MCP server — it never reads the source
      kind: postgres
      postgres:
        host_ref: env:VS_PG_HOST
        port: 5432
        user_ref: env:VS_PG_USER
        password_ref: env:VS_PG_PASSWORD
        database_ref: env:VS_PG_DATABASE
        publication: shop_pub
        slot: shop_slot
        bootstrap: { mode: snapshot, chunk_size: 10000 }
        denormalize_mode: sql

    sink:                      # what the MCP server reads
      kind: meilisearch
      meilisearch:
        endpoint_ref: env:VS_MEILI_ENDPOINT
        api_key_ref: env:VS_MEILI_API_KEY     # use a read-only key here
        index_prefix: "shop_"
        index_routing: { mode: by_projection_target }

    specs:
      joins: joins.yaml        # defines the targets and document shapes

    runtime:
      health_listen: 127.0.0.1:4043
      dlq_path: ./state/dlq.jsonl
    ```
  </Accordion>

  <Accordion title="joins.yaml — targets and document shapes" icon="file-code">
    The joins spec is where targets get their names and the documents get
    their shape:

    ```yaml theme={null}
    # joins.yaml
    joins:
      - name: orders
        primary:
          table: public.orders
          pk: id
        related:
          - id: customer
            table: public.customers
            pk: id
            join_on: { from: customer_id, to: id }
            embed_as: customer
            cardinality: one
          - id: items
            table: public.order_items
            pk: id
            join_on: { from: id, to: order_id }
            embed_as: items
            cardinality: many
        target:
          index: orders        # ← this is the target name agents query
    ```
  </Accordion>
</AccordionGroup>

With this config the server exposes one target, `orders`, whose documents
are orders with an embedded `customer` object and `items` array — and it
serves that shape description to agents automatically.

A complete runnable version of this setup — schema, seed data, configs,
token flow, Claude wiring — lives in
[`examples/mcp`](https://github.com/ventstream/ventstream/tree/main/examples/mcp).

## Run it

```bash theme={null}
ventstream mcp --config pipeline.yaml
```

Serve several pipelines from one endpoint by repeating `--config`. Target
names must be unique across the loaded configs:

```bash theme={null}
ventstream mcp \
  --config pg-search.yaml \
  --config mongo-cache.yaml
```

The env vars referenced by the config (`VS_MEILI_ENDPOINT`, …) must be set
for the MCP server too — give it read-only sink credentials rather than
the writer's.

## Running as a role

The subcommand above is the interactive way. For supervised deployments —
systemd, Kubernetes, or Fleet-managed — the engine can run the MCP server
as a **role**, dispatched from the config itself with no CLI arguments:

```yaml theme={null}
schema_version: 1
roles: [mcp]               # mcp runs alone — never beside the cdc writer
sink:                      # required: what it reads
  kind: meilisearch
  meilisearch:
    endpoint_ref: env:VS_MEILI_ENDPOINT
    api_key_ref: env:VS_MEILI_API_KEY
    index_prefix: "shop_"
    index_routing: { mode: by_projection_target }
specs:
  joins: joins.yaml
runtime:
  health_listen: 0.0.0.0:4043     # /readyz for probes and supervisors
  mcp:
    listen: 0.0.0.0:8790
    keys_ref: env:VS_MCP_KEYS     # or auth_token_ref — required off loopback
```

`VS_ENGINE_CONFIG=…  ventstream` (no arguments) boots the server, serves
`/readyz` on the health listener, and shuts down cleanly on SIGTERM. An
mcp-role config has no `source:` — it only ever reads the sink.

**Fleet-managed:** apply the same YAML with
`ventstreamctl configurations apply --engine-config …` — the control plane
validates it and delivers it to the deployment like any pipeline config.
The managed-agent chart supports `workload.roles: mcp` (under
`kind: realtime`) with `service.mcpPort` exposing the listener, and
`ventstreamctl mcp keys generate` mints tokens, printing the token once
and the `token_hash` entry for your keys secret. Query traffic always
goes straight from agents to the MCP deployment — never through the
control plane.

## Transports

**stdio** (default) — the MCP client spawns the process. A Claude Desktop
entry looks like:

```json theme={null}
{
  "mcpServers": {
    "ventstream": {
      "command": "ventstream",
      "args": ["mcp", "--config", "/etc/ventstream/pipeline.yaml"],
      "env": { "VS_MEILI_API_KEY": "…" }
    }
  }
}
```

**HTTP** (`--listen`) — a shared endpoint for remote agents. Run one
instance per environment next to your sinks; agents connect over the
network and no sink credentials leave that deployment:

```bash theme={null}
ventstream mcp \
  --config pipeline.yaml \
  --listen 0.0.0.0:8790 \
  --auth-token-ref env:VS_MCP_TOKEN
```

Clients POST MCP JSON-RPC to `/mcp` with `Authorization: Bearer <token>`;
`/healthz` serves load-balancer checks. The server is stateless — no
session ids — so replicas behind a plain load balancer need no session
affinity.

Security is enforced, not optional:

* Binding a non-loopback address **requires** auth (`--auth-token-ref` or
  `--keys-ref`) — the server refuses to start without it.
* Browser-origin requests are rejected unless allow-listed with
  `--allow-origin` (DNS-rebinding protection).
* Request bodies cap at 1 MiB; tool dispatch times out at 30 s.
* Run it behind TLS termination (your load balancer or reverse proxy).

## Tokens and scoping

Mint a token with the built-in generator:

```bash theme={null}
ventstream mcp generate-token --hash
# vsk_ubKSepNIb2…                ← give this to the agent
# sha256:473fe34c1…              ← paste this into keys.yaml
```

The simplest setup is one full-access token via
`--auth-token-ref env:VS_MCP_TOKEN`. For per-agent control, use a keys
file — each key is a named token restricted to the targets it may query:

```yaml theme={null}
keys:
  - name: support-bot
    token_hash: sha256:473fe34c1…   # hash at rest, plaintext never stored
    targets: [orders]               # this agent sees ONLY the orders target
  - name: ops-agent
    token_hash: sha256:9c01d22ab…
    targets: all
```

```bash theme={null}
ventstream mcp --config pipeline.yaml \
  --listen 0.0.0.0:8790 --keys-ref file:/etc/ventstream/keys.yaml
```

A scoped key's world shrinks to its targets: `list_targets` and resources
list only them, and querying anything else returns the same error as a
target that does not exist — a scoped agent cannot discover what it is not
allowed to see. Every request is logged with its key name for attribution.
Revoke a key by removing it from the file and restarting (or rolling the
deployment).

## Tools

| Tool           | Arguments                     | What it does                                                        |
| -------------- | ----------------------------- | ------------------------------------------------------------------- |
| `list_targets` | —                             | Every target with its sink kind and document shape                  |
| `get_entity`   | `target`, `pk`                | O(1) fetch of one document by primary key                           |
| `search`       | `target`, `query`, `limit?`   | Full-text search (OpenSearch / Elasticsearch / Meilisearch targets) |
| `scan`         | `target`, `pattern`, `limit?` | Glob scan of doc ids (Redis targets)                                |

`pk` accepts a scalar or an array for composite keys. Values are normalized
exactly like the writer normalizes them, so `100` and `"100"` find the same
document.

```json theme={null}
{ "name": "get_entity", "arguments": { "target": "orders", "pk": 100 } }
```

returns the full joined document — parent row plus embedded relations —
straight from the sink.

Each target is also exposed as an MCP resource at `vs://targets/{name}`
describing its document shape (primary table, key columns, embedded
relations and their cardinality), derived from your joins spec. Agents read
it to learn what the documents look like before querying.

## How keys resolve

Doc ids and sink keys are deterministic, and the server links the same
encoder code the sinks use when writing — Redis keyspace, Meilisearch
primary keys, OpenSearch ids. A read key is byte-identical to the written
key by construction.

Target enumeration follows your sink routing:

* `fixed` — the configured target or index.
* `views` (Redis) — one target per view.
* `by_projection_target` — one target per join `target.index`.
* `by_output_relation` — targets are not derivable from config; pass them
  explicitly with `--target <name>` (repeated).

## Guardrails

* Use **read-only sink credentials** in the config you give the server —
  it only ever issues reads, but the credential should enforce it too.
* `--allow-target <name>` (repeated) restricts the served targets; anything
  else behaves as unknown.
* `--max-results <n>` caps `search`/`scan` result sizes (default 50,
  hard max 500).

## Notes

* A degraded pipeline does not stop reads — agents see whatever the sink
  currently holds. Watch pipeline health to know how fresh that is.
* Server-initiated SSE streams are not offered: every tool is a short
  single-shot read, answered as plain JSON.
