Phase 7h: sqlrite-mcp — MCP server adapter (incl. 7g.8 ask tool)#73
Merged
Conversation
Adds a new workspace-member crate `sqlrite-mcp` with a single
`[[bin]]` target that exposes a SQLRite database as a Model Context
Protocol (MCP) server over stdio. LLM agents (Claude Code, Cursor,
Codex, mcp-inspector) spawn it as a subprocess and get seven tools
for driving the database without any custom integration:
- list_tables — discover what's in the DB
- describe_table — column metadata + row count for one table
- query — run a SELECT, return rows as JSON
- execute — DDL / DML / transactions (hidden under --read-only)
- schema_dump — full CREATE TABLE script (same dump `ask` uses)
- vector_search — k-NN lookup against a VECTOR column
(uses HNSW index if present, brute-force otherwise)
- ask — natural-language → SQL via sqlrite-ask
(Phase 7g.8 — gated behind default-on `ask` feature)
Hand-rolled JSON-RPC 2.0 over line-delimited JSON on stdio. ~1100
LOC for the whole binary; no tokio, no async runtime, no third-party
MCP framework — same dep-frugal theme as sqlrite-ask's hand-rolled
JSON over ureq. Sync, single-client, strictly serial dispatch.
Notable implementation details:
- **stdio_redirect.rs**: the engine's `process_command` calls
`print!`/`println!` for REPL-convenience output (CREATE schema
dump, INSERT row dump, SELECT result table) — those would corrupt
the MCP protocol channel. Solved with a `dup2(2, 1)` dance at
startup that redirects fd 1 to fd 2; JSON-RPC responses go through
a saved-off duplicate of the original fd 1. The same pollution
affects the existing SDKs but isn't visible there because their
stdout doesn't matter; fixing it in the engine is a future cleanup.
- **Read-only mode**: `--read-only` opens the DB with a shared lock
via `Connection::open_read_only` AND hides `execute` from
`tools/list` AND rejects any `tools/call` for it server-side.
- **Tool errors vs protocol errors**: SQL parse failures, dimension
mismatches, "unknown table" surface as `result.isError: true` so
the LLM reads the message and retries. Malformed requests, unknown
methods, server-not-initialized surface as standard JSON-RPC error
codes per the JSON-RPC 2.0 spec.
- **vector_search SQL shape**: `SELECT * FROM <t> ORDER BY
vec_distance_<metric>(<col>, <emb>) LIMIT k`. Engine's parser
doesn't yet support function calls in SELECT projections, so the
numeric distance value isn't returned (rows still come back in the
right order; HNSW index is still picked up by the optimizer).
Tests: 16 integration tests in `tests/protocol.rs` spawn the binary
as a subprocess, pipe JSON-RPC requests in, parse responses. Covers
lifecycle, every tool's success path, the major error shapes, and
both feature configurations (with and without `ask`).
Documentation: new `docs/mcp.md` (canonical reference, wiring into
Claude Code / Cursor / mcp-inspector, per-tool reference, security
notes); `sqlrite-mcp/README.md` (crates.io-facing); cross-references
from root README, `docs/_index.md`, `docs/ask.md`, `docs/roadmap.md`,
`docs/phase-7-plan.md`, `docs/release-plan.md`.
Release pipeline: two new jobs in `.github/workflows/release.yml` —
`publish-mcp` (cargo publish to crates.io, sequenced after
publish-crate + publish-ask because of the path-dep chain) and
`build-mcp-binaries` (per-platform binary tarballs for Linux x86_64,
Linux aarch64, macOS aarch64, Windows x86_64 — same matrix as
publish-ffi). `tag-all` adds the `sqlrite-mcp-vX.Y.Z` tag; `finalize`
adds it to the umbrella release body and the dep gate.
`scripts/bump-version.sh` adds `sqlrite-mcp/Cargo.toml` to the
version-bump file list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI failure on `rust (windows-latest)`:
error[E0432]: unresolved import `std::os::fd`
--> sqlrite-mcp\src\stdio_redirect.rs:48:18
error[E0599]: no function or associated item named `from_raw_fd`
found for struct `std::fs::File` in the current scope
Root cause: `std::os::fd` is Unix-only. Windows uses
`std::os::windows::io` with HANDLEs instead of fds, and the C
runtime fd returned by `_dup`/`_dup2` isn't a kernel handle — it
needs `_get_osfhandle` to convert to a Win32 HANDLE before being
wrapped in a `File`.
Fix: split `redirect_stdout_to_stderr()` into two `#[cfg]` arms.
- Unix: unchanged. `libc::dup` / `libc::dup2` / `libc::close` +
`std::os::fd::FromRawFd::from_raw_fd(fd) -> File`.
- Windows: hand-bind `_dup`, `_dup2`, `_close`, `_get_osfhandle` via
`unsafe extern "C"` (don't depend on libc's Windows surface
exposing the right symbol names). Convert C runtime fd →
Win32 HANDLE → `File` via `std::os::windows::io::FromRawHandle`.
The C runtime fd is intentionally leaked because the `File`
closes the underlying HANDLE on drop; closing both would
double-free the kernel handle.
Verified with `cargo check -p sqlrite-mcp --target
x86_64-pc-windows-gnu` (with mingw-w64 installed locally), both
with and without the `ask` feature. Unix tests still 16/16.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
joaoh82
added a commit
that referenced
this pull request
May 3, 2026
…ints) (#76) The engine's `process_command` had three direct stdout writes that made sense in a REPL but corrupted any other channel an embedder might be using stdout for: src/sql/mod.rs:150 — let _ = table.print_table_schema(); (CREATE) src/sql/mod.rs:208 — db_table.print_table_data(); (INSERT) src/sql/mod.rs:224 — print!("{rendered}"); (SELECT) Plus a leftover debug print in src/sql/parser/create.rs:190 dumping unhandled `sqlparser::ast::TableConstraint` debug reprs to stdout. This is the issue PR #73 papered over with the dup2(2,1) dance in sqlrite-mcp/src/stdio_redirect.rs. That fix is still in place (belt-and-suspenders against future regressions), but the engine now behaves correctly without it — the SDKs (Python, Node, Go, WASM, FFI), the Tauri desktop, and any future embedder all benefit. ## How Refactored process_command to return a richer struct, with a backwards-compat wrapper: pub struct CommandOutput { pub status: String, // "INSERT Statement executed.", etc. pub rendered: Option<String>, // SELECT prettytable; None otherwise } pub fn process_command(query, db) -> Result<String> // backwards-compat: returns just .status pub fn process_command_with_render(query, db) -> Result<CommandOutput> // new: rich return, never writes to stdout The REPL (`src/main.rs`) calls `_with_render` and prints rendered above status — same UX as before. The .ask meta-command does the same and concatenates the two pieces into the single String it bubbles up to the REPL's outer dispatch loop. The CREATE-TABLE schema dump and INSERT row dump aren't preserved — they were small UX niceties that interactive REPL users rarely need (the schema you just CREATEd, the row you just INSERTed). The INSERT case was actively bad UX on any non-trivial table — it dumped the entire table after every insert. Both gone. `sqlrite::process_command` keeps its existing signature for backwards compatibility — every existing call site (Connection::execute, the SDK FFI shims, the .ask handler's inline runner, the engine's own tests) keeps working unchanged. ## Verified - `cargo build --workspace` clean - `cargo test --workspace --exclude sqlrite-{python,nodejs,desktop}` — 330+ tests across engine + sqlrite-mcp + sqlrite-ask + sqlrite-ffi, all passing (engine's 251-test integration suite covers the process_command paths most directly affected) - REPL smoke: CREATE / INSERT / SELECT round-trip prints rendered table above status, no surprises - MCP smoke: same round-trip via JSON-RPC over stdio — `cat 2>/dev/null` filtered stderr is now empty (was prettytable noise before; stdio_redirect was the only thing keeping it off the protocol channel) ## Docs - `docs/architecture.md` — updates the engine module map entries for src/lib.rs and src/sql/mod.rs to mention the new types + the "never writes to stdout" guarantee - `docs/sql-engine.md` — entry-point section now shows both functions + the CommandOutput shape; explains the rendered-vs-status split - `docs/mcp.md` — security-notes section reframes stdio_redirect as defense-in-depth now that the engine is clean - `sqlrite-mcp/src/stdio_redirect.rs` — module-level comment rewritten to call out the engine fix + the three concrete future-regression scenarios the redirect protects against Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new workspace-member crate
sqlrite-mcpwith a single[[bin]]target that exposes a SQLRite database as a Model Context Protocol server over stdio. LLM agents (Claude Code, Cursor, Codex,mcp-inspector) spawn it as a subprocess and get seven tools for driving the database — no custom integration code on the LLM side.This wave also closes out Phase 7g.8 — the natural-language → SQL
asktool ships in the same crate behind a default-on cargo feature.Tools
list_tablesdescribe_tablequeryexecute--read-only)schema_dumpCREATE TABLEscriptvector_searchask(7g.8)sqlrite-ask(gatedfeature = "ask")Design
stdio_redirect.rs— the engine'sprocess_commandcallsprint!/println!for REPL-convenience output (CREATE schema dump, INSERT row dump, SELECT result table) which would corrupt the JSON-RPC protocol channel. Solved with adup2(2, 1)dance at startup that redirects fd 1 to fd 2; JSON-RPC responses go through a saved-off duplicate of the original fd 1.result.isError: true. Malformed requests, unknown methods, server-not-initialized → standard JSON-RPC error codes.Tests
16 integration tests in
sqlrite-mcp/tests/protocol.rs— spawn the binary, pipe JSON-RPC requests in, parse responses. Cover lifecycle, every tool's success path, the major error shapes, and both feature configurations (with and withoutask).Docs
docs/mcp.md— canonical reference (wiring into Claude Code / Cursor /mcp-inspector, per-tool reference with example requests, security notes)sqlrite-mcp/README.md— crates.io-facingREADME.md,docs/_index.md,docs/ask.md,docs/roadmap.md,docs/phase-7-plan.md,docs/release-plan.mdRelease pipeline
publish-mcpjob →cargo publish -p sqlrite-mcp(sequenced afterpublish-crate+publish-askbecause of the path-dep chain)build-mcp-binariesmatrix job → per-platform binary tarballs (Linux x86_64, Linux aarch64, macOS aarch64, Windows x86_64)tag-alladds thesqlrite-mcp-vX.Y.Ztagfinalizeadds the new product line to the umbrella release body and the dep gatescripts/bump-version.shaddssqlrite-mcp/Cargo.tomlto the version-bump file listTest plan
cargo build --workspacecleancargo test -p sqlrite-mcp— 16/16 with default featurescargo test -p sqlrite-mcp --no-default-features— 16/16 (omitsask)cargo fmt --alldocs/mcp.md, verify the seven tools appear andaskworks against an Anthropic keypublish-mcpandbuild-mcp-binariesjobs run, the crate publishes to crates.io, and the per-platform tarballs land on the GH Release🤖 Generated with Claude Code