feat(mcp): read-only MCP server at /api/mcp on the official SDK (#246) - #1070
Conversation
- Add stdio transport with pure JSON-RPC stdout isolation and graceful shutdown - Expose list_connections, inspect_schema, and run_read_query MCP tools - Implement fail-closed execution fence bridging to inspectAgentStatement - Add safe serializer supporting BigInt, Buffers, Dates, and circular references - Implement deterministic row ceiling and 64KB byte budget truncation - Wire McpCancellationManager to native provider query cancellation and timeout - Add connection versioning to guarantee single-flight provider lifecycle - Add 24 comprehensive unit and integration tests including real stdio process
…nd hermetic wire budget
0d046ec to
b334235
Compare
…nd SSH tunnel discipline
b334235 to
4cbb5a0
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
cevheri
left a comment
There was a problem hiding this comment.
Thanks for working the design thread before writing code, and for reusing the shipped pagination contract from #816. The JSON-RPC dispatch and the tool schemas, the part I asked you for, are the sound part of this.
Requesting changes, on security and architecture.
Auth is ours, decision (5). route.ts:29 compares a plaintext MCP_TOKEN with ===, not the constant-time secretsMatch we already have, and grants role admin, which route.ts:98 turns into every admin-visible connection with its resolved credentials. It also cannot work: proxy.ts is untouched and /api/mcp is on no public list, so I ran a real MCP client against a build of this branch and the token channel failed while the session channel connected. Your tests import GET and POST and call them directly, so CI never saw that. Please drop the branch, the .env.example block and the auth claim in the GET payload.
inspect-schema.ts:21 catches a refusal by matching "in-memory" in the message and re-acquires with no profile, which is the editor's writable pool. run-read-query.ts:79 is narrower but the same inversion. Let the refusal reach the client.
Please drop bin/libredb-mcp.ts, server.ts and transport/stdio.ts with the SDK: decision (3). The bin cannot run from the published package anyway, files ships no src, and the SDK puts express and hono into production dependencies.
Still needed: an audit event and a rate limit the way agent/drive does it, a cancellation key carrying caller identity, English tool descriptions, a user-facing doc, and the two route-auth.test.ts lines reverted.
|
this is the big one :) |
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| - | - | Basic Auth String | 557d756 | tests/unit/mcp/config.test.ts | View secret |
| - | - | Username Password | 979470a | tests/api/mcp/token.test.ts | View secret |
| - | - | Basic Auth String | e14f205 | tests/api/mcp/token.test.ts | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
babac69 to
4fe500a
Compare
4fe500a to
222da45
Compare
222da45 to
7984266
Compare
cevheri
left a comment
There was a problem hiding this comment.
Thanks, that was fast and it landed. I checked this at the head tree, not from the file list: no MCP_TOKEN and no x-libredb-mcp-token anywhere, no re-acquisition fallback left in either tool, the STDIO entry point and the SDK gone with package.json and bun.lock back to the merge base, and tests/security/route-auth.test.ts reverted so /api/mcp goes through the import pin like every other route. Four of my five asks are answered.
I built this branch and drove it. Four things block.
-
The dispatcher silently discards responses for queries it has already run. I sent a batch of 50 and got 1 response back, HTTP 200, no error and no log line. Controls: 3 pings return 3, 5 small queries return 5, 5 large ones return 1, so it is driven by response size and not by batch length. Reject the oversized batch, or return a per-id error for each response that does not fit.
-
No audit event when a tool call reaches an engine. I pointed you at /api/agent/drive last round; that route audits only denials, so my citation was wrong and you built what I asked for. The pattern is src/lib/db/operations/execution.ts:169 and :209: tool name, connection id, caller, outcome, correlation id, never the SQL.
-
src/lib/mcp/context.ts is a second provider cache in front of the factory's own, and it never hits. The key serializes the whole connection and getManagedConnections stamps a fresh createdAt on every call, so I measured a new provider constructed on every single request, twelve in one session for one connection. Call acquireExecutionProfileProvider per request and delete the static maps; the factory already owns provider lifetime and cache keys.
-
The ReDoS test at tests/unit/mcp/serializer.test.ts:104 cannot fail for the rule it names. The payload is "A".repeat(50000), which has no "://", so it never reaches the [^/\s]+@ quantifier CodeQL flagged at serializer.ts:94. I put a catastrophic variant of the same rule behind that test: 0.06 ms on your payload, well inside the 100 ms budget, and 653 ms on "http://" plus 37 characters. So the test passes a regex that is not safe. Point it at a scheme followed by a long repeat. For what it is worth the shipped rule measures linear here, 0.09 ms at 50 KB and 0.40 ms at 240 KB, so I do not think the alert is exploitable, but that is my measurement and not your test's.
Smaller, same push: bucket "ai" at route.ts:37 should be "query", this route reaches a database and no LLM, and the two membership comments in rate-limit.ts need the same edit. One batch request also executed 20 queries for a single rate-limit slot, so the limit meters requests rather than executions. The catch at inspect-schema.ts:104 turns an engine refusal into an empty column list, which is the shape of the fallbacks you just removed. run_read_query reports an offset cursor its input schema cannot accept. Comments and most new test names are still Portuguese, and everything that lands here is English.
docs/MCP.md says 401 for a missing session. I measured 307 to /login for no credential, for a stale cookie and for an unknown header, so please correct that line. The PR body still advertises the STDIO transport and the token seam you deleted.
One thing is ours, not yours. There is no machine credential yet, so only a browser session cookie reaches this endpoint. I will write the scoped credential and the src/proxy.ts branch for it, the way verifyAgentDriveToken is admitted today, and I will rule on CodeQL alert 536 myself.
…actor CodeQL alert 536 (js/polynomial-redos, high) is open against refs/pull/1070/merge at src/lib/mcp/serializer.ts:94 and needs a written ruling before it can be dismissed or fixed. It is deferred rather than settled for two reasons: the CodeQL check reports SUCCESS because alerts do not fail the job, so a green rollup hides it; and the code is not on main, it arrives only if #1070 merges, and a dismissal anchors to a source location. Records what was measured on 2026-09-24 so the ruling can be made cold: the shipped expression is linear here, 0.09 ms on a scheme plus 50 KB and 0.40 ms on a scheme plus 240 KB, which reads as a false positive but is one runtime and not a ruling. Opens a "Security scanner triage" section rather than extending one of the historical security phase sections, which record decisions made during those phases.
…offset validation - Map wire budget overflow to per-id JSON-RPC errors instead of dropping responses - Emit agent_operation audit events for run_read_query and inspect_schema - Delegate provider caching and lifecycle directly to db factory - Enforce capability guard rejecting positive offset when pagination is unsupported - Bubble up schema description errors with redacted messages - Meter batch queries under the query rate limit bucket - Translate all internal MCP comments and test titles to English
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
… credential URL is committed
…d live Both clients connected to a standalone build with a minted token and called the MCP tools on 2026-09-26: Claude Code in both protocol eras, OpenCode over the 2025-11-25 transport. OpenCode's section is written from its official MCP servers page.
…n and outcome pair
…and add the MCP case to D102
… literal is committed
|
@Roberton003 the new features are on this branch now. While this was open the spec moved to a stateless revision (2026-07-28), so the transport runs on the official SDK after all; that is why it is back after I asked you to drop it. The three tools still start from your code, and the description has the details. Please don't push to the branch while the review is open, so we don't overwrite each other. Thanks again for the groundwork. |
|
Thanks for the update and for adapting the transport to the 2026-07-28 stateless spec revision, @cevheri! The branch looks great. Leaving it completely untouched on my side so you can finish the review and merge. Glad to have contributed the foundation for MCP in LibreDB Studio. |
…_URL is set The Origin check runs before the token, and the canonical URL's host was in its allowlist, so a probe carrying that Origin got 401 when MCP was configured and 403 when it was not. The Origin list is now the localhost names and the ALLOWED_ORIGINS hosts; the Host list keeps the canonical host. MCP clients send no Origin, so only a browser-based client needs its origin in ALLOWED_ORIGINS.
could I ask you to test the MCP feature locally(it does not matter which one you use: claude, codex, opencode, hermes, deepseek harness, pi, antigravity, kiro, cursor, vscode, copilot etc..) |
The screen rendered snippets for five clients but not OpenCode, although docs/MCP.md carries an OpenCode section verified live. The template now builds that opencode.json, and the docs guard compares the section with it.
…ay to the MCP screen A from-scratch run of the MCP guide stalled on facts the page did not carry: npx resolves a relative SEED_CONFIG_PATH against the directory it is run from; a Docker container without a volume on /app/data loses its generated JWT_SECRET and with it every MCP token; a first start prints the generated admin credentials once and keeps them in auth-bootstrap.json; an admin lands on the dashboard and reaches MCP through Editor and the user menu; a SQLite seed's database is a path on the machine running Studio.
…npx runs from The launcher spawns server.js with its cwd in the payload cache, so SEED_CONFIG_PATH=./seed-connections.yaml pointed into ~/.libredb-studio/<version>/payload and seed connections were skipped as not found. Relative values of the filesystem path variables are now resolved against process.cwd() before the spawn; absolute and empty values and the URL paths BASE_PATH and NEXT_PUBLIC_MONACO_VS_PATH are left alone. A guard test fails when .env.example gains an unclassified *_PATH, *_DIR or *_FILE variable.
…cribes The resolvePathVariables tests had landed between the docblock for the spawned launcher tests and the describe it documents. They now sit before the resolveBindAddress table, so that docblock again follows the table it calls "above" and precedes "launcher startup URL". Note for the release: an npx user who set a relative STORAGE_SQLITE_PATH or WORKFLOW_LOCAL_DATA_DIR used to get that data under the payload cache (~/.libredb-studio/<version>/payload/...). Since 978316f the path resolves against the directory the command is run from, so move the old payload/data there to keep it. Users who leave these variables unset are not affected.
…of failing The editor opened every file read-write with create and PRAGMA journal_mode = WAL, so a read-only mount or a file owned by another user failed health, inventory and counts with 'attempt to write a readonly database'. When access(W_OK) refuses the file or its directory, connect() now uses SQLite's read-only open without create or the WAL pragmas, logs the decision once at info with the path, and query() names SQLite's read-only refusal on a write. A WAL-mode file in an unwritable directory still cannot be read, and connect() now says why and how to fix it. A writable file keeps the old sequence.
# Conflicts: # charts/libredb-studio/Chart.yaml # docs/BACKLOG.md # operator/helm-charts/libredb-studio/Chart.yaml # src/lib/db/connection-fingerprint.ts # tests/unit/lib/db/connection-fingerprint.test.ts # tests/unit/seed/types.test.ts
…h Kafka The mcp block changes chart templates, and 0.1.69 is now a released chart version (tag libredb-studio-0.1.69), so re-publishing it would mutate the released index and OCI digest (libredb#167). The Kafka changes line goes back to its released text; only the MCP line is new in 0.1.70.
…cOS too On macOS bun:sqlite links Apple's libsqlite3, whose close leaves -wal and -shm in place, and with the -shm there the read-only open succeeds, so the refusal test failed on macos-latest. The fixture now checkpoints and removes both sidecars on every platform. Reproduced on Linux with SQLITE_FCNTL_PERSIST_WAL: sidecars kept, the file opens; removed, it is refused. The doc and docblock now say the refusal is for a WAL file with no -shm beside it.
|
Pulled the latest commits (including the new OpenCode settings snippet and launcher fixes) and tested the branch locally against a live instance serving
The MCP feature works smoothly across all clients on my end! |
…Lite's words SQLite words the refusal of an unwritable WAL file by build: the library bundled on Linux says "attempt to write a readonly database" for the file alone, while Apple's libsqlite3 says "unable to open database file", and so does Linux when a -wal is left beside the file with no -shm. The hint matched only the first wording, so macOS, and Linux with a leftover -wal, got SQLite's bare refusal. connect() now gives the reason whenever the file's header says WAL mode (bytes 18 and 19 are 2), and a file this process cannot read keeps SQLite's words alone.
…1 and 2 both read opencode.ai now installs OpenCode 2, whose MCP page says servers go under mcp.servers and that v2 does not place server names directly under mcp; it also replaced enabled with disabled. The nested snippet connected on OpenCode 1.18.31 and 1.18.32 and on 2.0.18, on Linux and Windows, with a bogus-token control failing on each. docs/MCP.md adds that OpenCode 2's shared service reads the token when it starts (opencode service restart picks up a later one) and that its first mcp list can report no servers while it starts.
|
Thank you for testing it, @Roberton003. Three clients, two engines, and the refusal, 401 and 403 paths checked is a thorough pass, and DuckDB was not in my own live runs, so that fills a real gap. One change landed after your run: the OpenCode snippet now sits under |
|
A short way to try MCP with OpenCode. Open the section for your system; each one is complete on its own. The full guide, with the other clients, is macOSBefore you start, you need Node.js 24 or newer for npx, or Docker Desktop, and OpenCode with a model you can use. Open Terminal, make an empty folder, and run everything below from it. 1. Make a small SQLite file. sqlite3 shop.db "CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL, status TEXT); INSERT INTO orders (customer, total, status) VALUES ('ada', 10, 'paid'), ('bob', 20, 'open'), ('cem', 30, 'shipped');"2. Opt it in. MCP clients only see seed connections marked version: "1"
connections:
- id: shop
name: Shop
type: sqlite
database: /Users/you/demo/shop.db
roles: ["*"]
mcp: true3. Start Studio with MCP on, with npx or with Docker, and keep this window open while it runs. SEED_CONFIG_PATH=./seed-connections.yaml LIBREDB_MCP_ENABLED=true LIBREDB_MCP_TOKEN_LABEL=studio-mcp-1 npx @libredb/studioWith Docker (the data volume keeps your tokens valid when the container is recreated): docker run -p 3000:3000 -v libredb-data:/app/data \
-v "$PWD/seed-connections.yaml:/app/config/seed-connections.yaml:ro" \
-v "$PWD/shop.db:/data/shop.db:ro" \
-e SEED_CONFIG_PATH=/app/config/seed-connections.yaml \
-e LIBREDB_MCP_ENABLED=true \
-e LIBREDB_MCP_URL=http://127.0.0.1:3000/api/mcp \
-e LIBREDB_MCP_TOKEN_LABEL=studio-mcp-1 \
ghcr.io/libredb/libredb-studio:latest4. Get a token. Open http://localhost:3000 and sign in as 5. Connect OpenCode. Open a second Terminal window in the same folder and save this as {
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"libredb": {
"type": "remote",
"url": "http://127.0.0.1:3000/api/mcp",
"oauth": false,
"headers": { "Authorization": "Bearer {env:LIBREDB_MCP_TOKEN}" }
}
}
}
}Then put the token in that window and check the connection: export LIBREDB_MCP_TOKEN=<your token>
opencode mcp listIt should show 6. Ask it something. If OpenCode has no default model, or its answer does not use libredb, choose one with opencode run "Using libredb, list my connections, show the tables of seed:shop, then count its orders per status."It answers with LinuxBefore you start, you need Node.js 24 or newer for npx, or Docker, and OpenCode with a model you can use. For Open a terminal, make an empty folder, and run everything below from it. 1. Make a small SQLite file. sqlite3 shop.db "CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL, status TEXT); INSERT INTO orders (customer, total, status) VALUES ('ada', 10, 'paid'), ('bob', 20, 'open'), ('cem', 30, 'shipped');"2. Opt it in. MCP clients only see seed connections marked version: "1"
connections:
- id: shop
name: Shop
type: sqlite
database: /home/you/demo/shop.db
roles: ["*"]
mcp: true3. Start Studio with MCP on, with npx or with Docker, and keep this terminal open while it runs. SEED_CONFIG_PATH=./seed-connections.yaml LIBREDB_MCP_ENABLED=true LIBREDB_MCP_TOKEN_LABEL=studio-mcp-1 npx @libredb/studioWith Docker (the data volume keeps your tokens valid when the container is recreated): docker run -p 3000:3000 -v libredb-data:/app/data \
-v "$PWD/seed-connections.yaml:/app/config/seed-connections.yaml:ro" \
-v "$PWD/shop.db:/data/shop.db:ro" \
-e SEED_CONFIG_PATH=/app/config/seed-connections.yaml \
-e LIBREDB_MCP_ENABLED=true \
-e LIBREDB_MCP_URL=http://127.0.0.1:3000/api/mcp \
-e LIBREDB_MCP_TOKEN_LABEL=studio-mcp-1 \
ghcr.io/libredb/libredb-studio:latest4. Get a token. Open http://localhost:3000 and sign in as 5. Connect OpenCode. Open a second terminal in the same folder and save this as {
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"libredb": {
"type": "remote",
"url": "http://127.0.0.1:3000/api/mcp",
"oauth": false,
"headers": { "Authorization": "Bearer {env:LIBREDB_MCP_TOKEN}" }
}
}
}
}Then put the token in that terminal and check the connection: export LIBREDB_MCP_TOKEN=<your token>
opencode mcp listIt should show 6. Ask it something. If OpenCode has no default model, or its answer does not use libredb, choose one with opencode run "Using libredb, list my connections, show the tables of seed:shop, then count its orders per status."It answers with Windows (PowerShell)Before you start, you need Node.js 24 or newer for npx, or Docker Desktop, and OpenCode with a model you can use. For Open PowerShell, make an empty folder, and run everything below from it. If Set-ExecutionPolicy -Scope CurrentUser RemoteSigned1. Make a small SQLite file. sqlite3 shop.db "CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total REAL, status TEXT); INSERT INTO orders (customer, total, status) VALUES ('ada', 10, 'paid'), ('bob', 20, 'open'), ('cem', 30, 'shipped');"2. Opt it in. MCP clients only see seed connections marked version: "1"
connections:
- id: shop
name: Shop
type: sqlite
database: C:/Users/you/demo/shop.db
roles: ["*"]
mcp: true3. Start Studio with MCP on, with npx or with Docker, and keep this window open while it runs. $env:SEED_CONFIG_PATH = "./seed-connections.yaml"
$env:LIBREDB_MCP_ENABLED = "true"
$env:LIBREDB_MCP_TOKEN_LABEL = "studio-mcp-1"
npx @libredb/studioWith Docker Desktop (the data volume keeps your tokens valid when the container is recreated): docker run -p 3000:3000 -v libredb-data:/app/data `
-v "${PWD}/seed-connections.yaml:/app/config/seed-connections.yaml:ro" `
-v "${PWD}/shop.db:/data/shop.db:ro" `
-e SEED_CONFIG_PATH=/app/config/seed-connections.yaml `
-e LIBREDB_MCP_ENABLED=true `
-e LIBREDB_MCP_URL=http://127.0.0.1:3000/api/mcp `
-e LIBREDB_MCP_TOKEN_LABEL=studio-mcp-1 `
ghcr.io/libredb/libredb-studio:latest4. Get a token. Open http://localhost:3000 and sign in as 5. Connect OpenCode. Open a second PowerShell window in the same folder and save this as {
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"libredb": {
"type": "remote",
"url": "http://127.0.0.1:3000/api/mcp",
"oauth": false,
"headers": { "Authorization": "Bearer {env:LIBREDB_MCP_TOKEN}" }
}
}
}
}Then put the token in that window and check the connection: $env:LIBREDB_MCP_TOKEN = "<your token>"
opencode mcp listIt should show 6. Ask it something. If OpenCode has no default model, or its answer does not use libredb, choose one with opencode run "Using libredb, list my connections, show the tables of seed:shop, then count its orders per status."It answers with |
|
This was a big update and IMO the one needed to make this app agent-ready. Good job everyone and thanks ! |
Adds a Model Context Protocol server at
/api/mcp, so a coding agent or AI assistant can read the databases an operator opts in, with the same read-only execution path agent mode uses. Closes #246.The maintainers took this PR over from the first version and rebuilt it on the official TypeScript SDK (2.1.0) after an audit against the current spec (2026-07-28). What it is now:
LIBREDB_MCP_ENABLED,LIBREDB_MCP_URLandLIBREDB_MCP_TOKEN_LABELturn it on; the Helm chart has anmcpblock./settings/mcp), verified in the proxy and again in the route. The session cookie opens nothing here. Rotating the label revokes every token.mcp: trueare visible, filtered by the caller's role.list_connections,inspect_schemaandrun_read_query. Structured results, a 32 KiB page withhasMore, and an untrusted-data notice ahead of anything read from a database.mcp_operation, recorded before any provider is touched and failing closed. Every authenticated POST is metered on thequerybucket, the budget the editor already spends.Setup and limits are in
docs/MCP.md.Local checks on this head: format, lint, typecheck, knip, the drift guards, the full suite and the 100% line coverage gate pass, as do build, build:lib, attw and the Windows launcher checks.
Verified live on 2026-09-26 against the standalone build:
mcp_cancelled; SQLite runs to the end of the statement, as the docs say.WWW-Authenticate, batches get 400, and the well-known metadata paths get 404.