Skip to main content
Glama
xakpc

sqlite-sidecar-mcp

by xakpc
README.md
<a id="readme-top"></a>

<div align="center">

# sqlite-sidecar-mcp

**An agent-safe MCP sidecar for one live SQLite database.**

It gives an AI agent controlled access to a database that another application already owns,
and it limits the damage that an authorized but mistaken agent can cause.

[![ci][ci-shield]][ci-url]
[![license][license-shield]][license-url]
[![ghcr][ghcr-shield]][ghcr-url]
[![dotnet][dotnet-shield]][dotnet-url]

</div>

<details>
  <summary>Contents</summary>

- [About the project](#about-the-project)
- [Built with](#built-with)
- [Getting started](#getting-started)
- [The eight tools](#the-eight-tools)
- [Permissions](#permissions)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Security](#security)
- [Contributing](#contributing)
- [License](#license)
- [Contact](#contact)
- [Acknowledgments](#acknowledgments)

</details>

## About the project

An application owns a SQLite file. You want an agent to read it, and perhaps to change a few rows.
Handing the agent a SQL connection gives it the whole file and one `DELETE` with a wrong predicate
is unrecoverable. `sqlite-sidecar-mcp` sits next to that application as one more SQLite client and
speaks MCP over HTTP. The owning application does not change.

It runs in production next to the [MCP Canary](https://mcpcanary.com) API, where it gives the
operator's coding agent read-only access to the live database.

```mermaid
flowchart TD
    agent[AI / MCP client] -->|HTTPS| proxy[Reverse proxy]
    proxy -->|private HTTP| sidecar[sqlite-sidecar-mcp]
    sidecar -->|SQLite client| db[(app.db)]
    app[Owning application] -->|SQLite client| db
```

The product defends against two callers. One is an unauthorized client, which the token and the
request budget stop. The other is an **authorized client that makes a mistake**, and that one is the
unusual part: an agent with permission to write can still write the wrong thing. Structured writes,
a mandatory predicate, a row limit that counts cascades, and a per-minute write budget exist for it.

> [!IMPORTANT]
> **One sidecar process serves one database.** The request budget, the write budget and the
> idempotency cache live in process memory. A second replica gives you two independent budgets and
> two independent caches, and nothing reports it. Do not scale this service. Run a second sidecar
> only for a *different* database or a *different* trust boundary.

### Built with

- .NET 10, ASP.NET Core
- Model Context Protocol over Streamable HTTP
- `Microsoft.Data.Sqlite` with the bundled native SQLite
- [TOON](https://github.com/toon-format/toon) for row output, which costs fewer tokens than JSON

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Getting started

### Prerequisites

- Docker
- A SQLite database that **already exists**. The sidecar never creates one: creation would hide a
  wrong path and leave an empty database next to the real one.

### Run it

```bash
docker run -d --name sqlite-sidecar \
  -p 127.0.0.1:8080:8080 \
  -v /srv/myapp/data:/data \
  -e SQLITE_SIDECAR_DB=/data/app.db \
  -e SQLITE_SIDECAR_TOKEN='<a long random string>' \
  -e SQLITE_SIDECAR_PERMISSIONS=schema,read \
  --read-only --tmpfs /tmp \
  --cap-drop=ALL --security-opt=no-new-privileges \
  ghcr.io/xakpc/sqlite-mcp-sidecar:latest
```

The mount must be **writable**, also for the read-only permission set above. SQLite opens the `-shm`
file read-write for a read-only connection to a WAL database, and a read-only mount fails in a way
that reads like a permission fault rather than a configuration mistake.

### Connect an agent

The endpoint is `/db/mcp` and the token goes in an `Authorization` header:

```json
{
  "mcpServers": {
    "app-database": {
      "url": "https://example.com/db/mcp",
      "headers": { "Authorization": "Bearer <the token>" }
    }
  }
}
```

There is no OAuth. A client that insists on OAuth discovery finds no resource metadata on a `401`
and stops; the expected consumer is one agent that an operator configured with a static header.

### Generate a token and an environment block

```bash
dotnet run scripts/token.cs
```

It asks for the deployment shape, mints a token from a cryptographic random source, repairs a
permission set that would fail startup, and prints the block as `.env`, shell or compose.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## The eight tools

Which tools exist is decided by the permission set. A tool the deployment does not cover is absent
from `tools/list` — it does not appear and then fail.

| Tool | Needs | What it does |
| --- | --- | --- |
| `schema` | `schema` | Returns the DDL of the user tables, views and indexes. No row data, no internal `sqlite_%` objects, no file path. |
| `query` | `read` | Runs **one** caller-written read statement and returns rows as TOON, inside a row limit, a byte limit and a timeout. |
| `insert` | `write` | Inserts one row from a table name and a value map. The caller sends no SQL. |
| `update` | `write` | Updates rows matched by a structured filter. Needs a filter and a `maxRows`. |
| `delete` | `write` | Deletes rows matched by a structured filter. Needs a filter and a `maxRows`. |
| `backup` | `backup` | Copies the live database while the owning application keeps writing. Answers with a task; the client polls for the outcome. |
| `diagnostics` | `diagnostics` | Reports a fixed set of health values, including the journal mode and a `quick_check`. |
| `execute_write_sql` | `danger-raw-write` | Runs one caller-written `INSERT`, `UPDATE` or `DELETE`. See [Security](#security) before you enable it. |

A `query` answers in TOON, which is a compact tabular text:

```text
rows[2]{id,status}:
  54,done
  41,failed

truncated: false
```

`truncated: true` means a limit stopped the read, not that the query failed.

### Structured writes

`update` and `delete` take a flat filter, never SQL. Every condition joins with `AND`; there is no
`or` and no nesting. A disjunction is one call per branch, and that is the tighter bound: each call
is pre-counted and limited on its own, while one `or` call would pool the blast radius of every
branch into a single `maxRows`.

`maxRows` counts **every** row the write changes — rows removed by `ON DELETE CASCADE` and rows a
trigger writes included. A `delete` with `maxRows: 1` cannot quietly destroy a cascade subtree and
report one row. A filter that matches too many rows is rejected *before* the write starts, and a
write that turns out to exceed the limit is rolled back.

Every write tool takes a mandatory `requestId`. The same `requestId` returns the stored response of
a committed write instead of applying it twice. Failures are not cached, so a retry after a failure
is a real retry.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Permissions

One deployment has one token and one permission set. A permission is a property of the deployment,
not of a user — there are no users, roles or ACLs. Separate trust levels use separate deployments,
which keeps the boundary visible in the deployment configuration.

```bash
SQLITE_SIDECAR_PERMISSIONS=schema,read      # the default
```

| Permission | Risk | What it grants |
| --- | --- | --- |
| `schema` | Low | Metadata only. |
| `read` | Sensitive | It can read all data in the database. |
| `write` | Higher | Constrained data modification in **all** tables. |
| `backup` | Sensitive | It can make database copies. |
| `diagnostics` | Low to moderate | Metadata and health values. |
| `danger-raw-write` | High | Arbitrary `INSERT`, `UPDATE` and `DELETE` SQL in all tables. |

The name `danger-raw-write` is deliberately alarming. A neutral name would hide the cost.

> [!WARNING]
> **A permission applies to every table in the database.** There is no table allowlist and no column
> allowlist. `write` does not protect one table from an agent that has `write`. If a table must stay
> untouched, it belongs in a different database with a different sidecar.

Two rules the sidecar enforces at startup, so a mistake is a failed start and not a surprise later:

- **The read floor.** `write` and `danger-raw-write` are only valid together with `schema` and
  `read`. A structured write validates names against the live schema, so an agent that cannot call
  `schema` would be guessing. The missing permissions are never added silently, because that would
  make `SQLITE_SIDECAR_PERMISSIONS` an untrue record of the deployment.
- **No unknown names.** `SQLITE_SIDECAR_PERMISSIONS=reed` fails startup instead of quietly granting
  less than the operator believes.

No permission grants another. `read` does not imply `write`, and `danger-raw-write` is independent
of `write`.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Configuration

Every value is an environment variable with the `SQLITE_SIDECAR_` prefix. Startup fails when a value
is absent or invalid, and it reports **every** problem at once. The sidecar does not start in a
degraded state.

**Required:**

| Variable | Meaning |
| --- | --- |
| `SQLITE_SIDECAR_DB` | Path of the SQLite file. It must exist. |
| `SQLITE_SIDECAR_TOKEN` | The deployment bearer token. Supply it as a secret. |

<details>
  <summary><strong>Optional, with defaults</strong></summary>

| Variable | Default | Meaning |
| --- | --- | --- |
| `SQLITE_SIDECAR_PERMISSIONS` | `schema,read` | The permission set. |
| `SQLITE_SIDECAR_BACKUP_DIR` | none | Destination directory. Required, and must be writable, when `backup` is on. |
| `SQLITE_SIDECAR_MAX_ROWS` | `1000` | Row limit of one query result. |
| `SQLITE_SIDECAR_MAX_RESULT_BYTES` | `4194304` | Byte limit of one query result. |
| `SQLITE_SIDECAR_MAX_SQL_BYTES` | `32768` | Longest accepted statement. |
| `SQLITE_SIDECAR_QUERY_TIMEOUT_SECONDS` | `10` | A longer query is interrupted inside SQLite. |
| `SQLITE_SIDECAR_BUSY_TIMEOUT_SECONDS` | `3` | How long to wait for another writer's lock. |
| `SQLITE_SIDECAR_MAX_CONCURRENCY` | `4` | Concurrent database operations. |
| `SQLITE_SIDECAR_MAX_REQUESTS_PER_MINUTE` | `120` | The request budget, one window for the process. |
| `SQLITE_SIDECAR_MAX_WRITE_ROWS` | `100` | Server-side cap on changed rows per write. |
| `SQLITE_SIDECAR_MAX_WRITE_ROWS_PER_MINUTE` | `500` | The write budget, one window for the process. |

ASP.NET Core settings that matter: `ASPNETCORE_URLS` (the image sets `http://0.0.0.0:8080`) and
`ASPNETCORE_ENVIRONMENT`. Outside `Development` the sidecar logs JSON, which is what a container log
pipeline wants.

The effective write limit is `min(the caller's maxRows, SQLITE_SIDECAR_MAX_WRITE_ROWS)`, so an agent
can ask for less but never for more.

</details>

### Paths

| Path | Token | Rate limited |
| --- | --- | --- |
| `/db/mcp` | required | yes |
| `/health` | no | no |

`/health` reports that the process is alive and nothing else. It never touches the database: a probe
runs every few seconds, and an expensive probe would become a denial-of-service vector against the
owning application. The checks that do need the file run once, at startup.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Deployment

> [!CAUTION]
> **The proxy must not strip the `/db` prefix.** `/db/mcp` is the whole public path: the sidecar
> routes the same URL the agent calls. Both Kamal and Coolify strip a matched prefix *by default*,
> and a stripped prefix produces a `404` that reads like a broken server. Every recipe below turns
> stripping off.

Common to all of them: **one replica**, `/data` writable even for `schema,read`, and the container
port never published to the internet — the reverse proxy is the only way in, and it is also what
matches the `Host` header. The sidecar does no host filtering of its own.

<details>
  <summary><strong>Docker Compose</strong></summary>

```yaml
services:

  app:
    image: my-app
    volumes:
      - sqlite-data:/data

  sqlite-sidecar:
    image: ghcr.io/xakpc/sqlite-mcp-sidecar:latest
    deploy:
      replicas: 1
    environment:
      SQLITE_SIDECAR_DB: /data/app.db
      SQLITE_SIDECAR_TOKEN: ${SQLITE_SIDECAR_TOKEN}
      SQLITE_SIDECAR_PERMISSIONS: schema,read,write,backup
      SQLITE_SIDECAR_BACKUP_DIR: /backups
      ASPNETCORE_URLS: http://0.0.0.0:8080
    volumes:
      - sqlite-data:/data
      - sqlite-backups:/backups
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

volumes:
  sqlite-data:
  sqlite-backups:
```

Both containers mount the same volume. The database file must stay on a **local** filesystem: NFS
and SMB do not implement the locking SQLite needs.

</details>

<details>
  <summary><strong>Kamal</strong></summary>

One service for one database, and one host. A sidecar reads a local file, so it cannot follow a
multi-region role set: name the host that holds the database.

```yaml
service: sqlite-sidecar
image: ghcr.io/xakpc/sqlite-mcp-sidecar
servers:
  app:
    hosts: [<host that holds the database>]
    proxy:
      ssl: true
      host: <the public host name>
      path_prefix: /db
      strip_path_prefix: false
      app_port: 8080
      healthcheck:
        path: /health
volumes:
  - "<host data directory>:/data"
env:
  clear:
    SQLITE_SIDECAR_DB: /data/app.db
    SQLITE_SIDECAR_PERMISSIONS: schema,read
  secret:
    - SQLITE_SIDECAR_TOKEN
```

- `healthcheck.path` is not optional here. kamal-proxy asks for `/up` by default, and the deploy
  fails on that default.
- kamal-proxy has **no** rate limiting. It gives buffering, body-size limits and forwarded headers.
  The in-process request budget is therefore the only request limit on this platform.

</details>

<details>
  <summary><strong>Coolify</strong></summary>

Use a subdomain, not a path. Coolify generates Traefik labels for a domain path, and the
path-plus-strip-prefix combination has open defects. A subdomain costs nothing and removes the risk.
The application path stays `/db/mcp` either way.

```text
Domain            https://mcp.<domain>
Strip Prefixes    off
Health check      /health
Replicas          1
Volume            <host data directory>:/data, writable
```

Coolify runs Traefik, so a rate-limit middleware is available. It is not necessary: the in-process
budget is active on every platform.

</details>

<details>
  <summary><strong>A plain VPS, with systemd</strong></summary>

No orchestrator at all. Put the `docker run` under systemd so it survives a reboot:

```ini
# /etc/systemd/system/sqlite-sidecar.service
[Unit]
Description=sqlite-sidecar-mcp
After=docker.service
Requires=docker.service

[Service]
Restart=always
RestartSec=5
ExecStartPre=-/usr/bin/docker rm -f sqlite-sidecar
ExecStart=/usr/bin/docker run --rm --name sqlite-sidecar \
  -p 127.0.0.1:8080:8080 \
  -v /srv/myapp/data:/data \
  --env-file /etc/sqlite-sidecar.env \
  --read-only --tmpfs /tmp \
  --cap-drop=ALL --security-opt=no-new-privileges \
  ghcr.io/xakpc/sqlite-mcp-sidecar:latest
ExecStop=/usr/bin/docker stop sqlite-sidecar

[Install]
WantedBy=multi-user.target
```

`-p 127.0.0.1:8080:8080` binds to loopback, so only a proxy on the same host reaches it. Put the
token in `/etc/sqlite-sidecar.env` with mode `0600`, and point Caddy or nginx at `127.0.0.1:8080`
with the path forwarded unchanged.

</details>

<details>
  <summary><strong>Fly.io</strong></summary>

A Fly Volume attaches to exactly one machine, which suits this product: one file, one writer, one
sidecar.

```toml
app = "myapp"

[build]
  image = "ghcr.io/xakpc/sqlite-mcp-sidecar:latest"

[env]
  SQLITE_SIDECAR_DB = "/data/app.db"
  SQLITE_SIDECAR_PERMISSIONS = "schema,read"
  ASPNETCORE_URLS = "http://0.0.0.0:8080"

[[mounts]]
  source = "app_data"
  destination = "/data"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = false
  auto_start_machines = false
  min_machines_running = 1

[[http_service.checks]]
  path = "/health"
```

```bash
fly secrets set SQLITE_SIDECAR_TOKEN='<the token>'
```

> [!WARNING]
> The sidecar and the owning application must run on the **same machine**. A volume attaches to one
> machine, so two machines means two different database files, and the one-sidecar rule fails
> silently — each process would serve its own copy. Keep `auto_stop_machines = false` and do not
> scale the app.

</details>

### Token rotation

A deployment has one token. It never expires, and the sidecar accepts no second value. Rotation is a
secret change plus a restart, which stops every client at the same moment. That is accepted here: a
rotation window would need a list of valid tokens, which is a different product.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Security

Every request passes every layer, in order. No layer is a feature flag.

```mermaid
flowchart TD
    req[MCP request] --> budget[Request budget]
    budget --> auth[Bearer token check]
    auth --> perm[Deployment permission check]
    perm --> conc[Concurrency semaphore]
    conc --> agent[Agent protections]
    agent --> sandbox[SQLite sandbox]
    sandbox --> sqlite[(app.db)]

    budget -->|fail| e0[429 Too Many Requests]
    auth -->|fail| e1[Unauthorized]
    perm -->|fail| e2[PermissionDenied]
    agent -->|fail| e3[WriteLimitExceeded / WriteBudgetExceeded]
    sandbox -->|fail| e4[QueryRejected]
```

### What is guaranteed

With the default configuration, these statements are true:

- Remote access needs authentication.
- Default permissions are read-only.
- `read` does not imply `write`.
- A write permission is not valid without `schema` and `read`.
- `write` does not accept caller-supplied write SQL.
- Structured `update` and `delete` need a predicate, and the predicate joins with `AND` only.
- Structured `update` and `delete` have a hard row limit, and a broad filter is rejected before the
  write starts.
- That row limit counts **every** row the write changes, so a delete cannot destroy an unbounded
  cascade subtree while reporting one row.
- A repeated write with the same `requestId` applies one time only.
- Repeated structured writes reach a rate limit.
- Raw writes need an explicitly dangerous permission.
- Raw writes cannot run DDL, `ATTACH` or native extension loading.
- A caller cannot supply a backup filesystem path.
- A complete backup file is always a complete database. A partial copy keeps a `.partial` suffix.
- Queries have a time limit, a result limit and a concurrency limit.
- The native SQLite defensive mechanisms are on.
- The database file never goes over the network.

These are concrete controls. None of them makes a network-exposed database absolutely safe.

### The SQLite sandbox

The innermost layer is always on, and **no permission disables any part of it** —
`danger-raw-write` included. It uses the native SQLite authorizer rather than inspecting SQL with
string matching, so these are rejected during statement preparation on every path:

```text
ATTACH            DDL (CREATE / ALTER / DROP)      PRAGMA
load_extension    VACUUM                           transaction control
more than one statement in one request
```

Defensive mode is on, trusted schema is off, per-connection runtime limits bound SQL length,
expression depth and VDBE operations, and a running statement is interrupted — inside SQLite, not
merely abandoned — when it passes the timeout. Connection pooling is off, because a pooled handle
would keep the authorizer and limits of the previous operation.

### What is *not* guaranteed

> [!CAUTION]
> **There is no undo.** A `delete` inside the configured limits commits, and there is no restore
> tool. The operator owns the backup strategy. The limits protect against *breadth* — how many rows
> one mistake touches. They do not protect against *wrongness*: a correct-looking write of wrong
> values is applied exactly as asked.

**`danger-raw-write` means what the name says.** When an operator enables it, the token holder can
run this:

```sql
DELETE FROM jobs;
```

That is expected behavior, not a defect. The permission deliberately removes six protections —
structured writes, the mandatory predicate, the mandatory `maxRows`, the bounded pre-count, the
total-changes bound, and server-generated SQL. It keeps exactly two: the idempotency key and the
write budget. The SQLite sandbox above is untouched, so raw writes still cannot run DDL, attach a
database, load an extension, or write to an internal `sqlite_%` object.

**A permission covers the whole database.** See [Permissions](#permissions).

**A stolen token gets exactly the capabilities of its deployment.** The sidecar limits capability
and blast radius. It cannot make an intentionally granted destructive permission harmless. There is
no expiry and no revocation list — revoking means changing the secret and restarting.

**The budgets throttle; they do not isolate.** `SQLITE_SIDECAR_MAX_REQUESTS_PER_MINUTE` is one fixed
window for the whole process, with no per-caller partition, and it runs *before* authentication — so
a flood of wrong tokens is bounded too, and a rejected request still spends a permit. One agent that
exhausts the budget stops the other. The write budget behaves the same way. Both are process memory:
a restart resets them, and two processes do not share them.

**The container port must never face the internet.** The reverse proxy is the only path in, it
terminates TLS, and it is the host gate — the sidecar itself does no host filtering.

### Reporting a vulnerability

Use GitHub's private security advisories on this repository. Please do not open a public issue for a
security report.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## Contributing

Contributions are welcome. Fork the repository, create a branch, and open a pull request.

```bash
dotnet test --solution Xakpc.SQLiteMCPSidecar.slnx    # the whole suite

pwsh ./scripts/seed-dev-db.ps1                        # one time, before the first launch
pwsh ./scripts/dev-sidecar.ps1                        # a live sidecar on port 8080
```

Security tests are mandatory, not optional. A change to a query path, a write path or the sandbox
needs a test that proves the boundary still holds — and the suite runs a second time against the
Linux container, because the sandbox depends on the native SQLite build and a developer run on
another platform does not prove the shipped behaviour.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

## License

Apache-2.0. See [`LICENSE`](LICENSE).

## Contact

Pavel Osadchuk — [xakpc.dev](https://xakpc.dev) · [github.com/xakpc](https://github.com/xakpc)

Project: [github.com/xakpc/sqlite-mcp-sidecar](https://github.com/xakpc/sqlite-mcp-sidecar)

## Acknowledgments

- [Model Context Protocol](https://modelcontextprotocol.io) and the C# SDK
- [SQLite](https://sqlite.org), for the authorizer and the online backup API this is built on
- [TOON](https://github.com/toon-format/toon)
- [Best-README-Template](https://github.com/othneildrew/Best-README-Template)

<p align="right">(<a href="#readme-top">back to top</a>)</p>

[ci-shield]: https://img.shields.io/github/actions/workflow/status/xakpc/sqlite-mcp-sidecar/ci.yml?branch=master&style=for-the-badge
[ci-url]: https://github.com/xakpc/sqlite-mcp-sidecar/actions/workflows/ci.yml
[license-shield]: https://img.shields.io/badge/license-Apache--2.0-blue?style=for-the-badge
[license-url]: https://github.com/xakpc/sqlite-mcp-sidecar/blob/master/LICENSE
[ghcr-shield]: https://img.shields.io/badge/ghcr.io-sqlite--mcp--sidecar-2496ED?style=for-the-badge&logo=docker&logoColor=white
[ghcr-url]: https://github.com/xakpc/sqlite-mcp-sidecar/pkgs/container/sqlite-mcp-sidecar
[dotnet-shield]: https://img.shields.io/badge/.NET-10-512BD4?style=for-the-badge&logo=dotnet&logoColor=white
[dotnet-url]: https://dotnet.microsoft.com