Skip to content

Phase 7h: sqlrite-mcp — MCP server adapter (incl. 7g.8 ask tool)#73

Merged
joaoh82 merged 2 commits into
mainfrom
phase-7h-mcp-server
May 3, 2026
Merged

Phase 7h: sqlrite-mcp — MCP server adapter (incl. 7g.8 ask tool)#73
joaoh82 merged 2 commits into
mainfrom
phase-7h-mcp-server

Conversation

@joaoh82

@joaoh82 joaoh82 commented May 2, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a new workspace-member crate sqlrite-mcp with 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 ask tool ships in the same crate behind a default-on cargo feature.

Tools

Tool Purpose
list_tables Discover tables
describe_table Column metadata + row count
query SELECT → JSON rows
execute DDL / DML / transactions (hidden under --read-only)
schema_dump Full CREATE TABLE script
vector_search k-NN over a VECTOR column (uses HNSW index when present)
ask (7g.8) Natural-language → SQL via sqlrite-ask (gated feature = "ask")

Design

  • 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.
  • Sync, single-client dispatch. Engine isn't safe for concurrent mutation; serial completion matches the model.
  • stdio_redirect.rs — the engine's process_command calls print!/println! for REPL-convenience output (CREATE schema dump, INSERT row dump, SELECT result table) which would corrupt the JSON-RPC 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.
  • Tool errors vs protocol errors. SQL parse failures, dimension mismatches → 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 without ask).

$ cargo test -p sqlrite-mcp
test result: ok. 16 passed; 0 failed; ...
$ cargo test -p sqlrite-mcp --no-default-features
test result: ok. 16 passed; 0 failed; ...

Docs

  • New docs/mcp.md — canonical reference (wiring into Claude Code / Cursor / mcp-inspector, per-tool reference with example requests, security notes)
  • New sqlrite-mcp/README.md — crates.io-facing
  • Cross-references from root README.md, docs/_index.md, docs/ask.md, docs/roadmap.md, docs/phase-7-plan.md, docs/release-plan.md

Release pipeline

  • New publish-mcp job → cargo publish -p sqlrite-mcp (sequenced after publish-crate + publish-ask because of the path-dep chain)
  • New build-mcp-binaries matrix job → per-platform binary tarballs (Linux x86_64, Linux aarch64, macOS aarch64, Windows x86_64)
  • tag-all adds the sqlrite-mcp-vX.Y.Z tag
  • finalize adds the new product line to the umbrella release body and the dep gate
  • scripts/bump-version.sh adds sqlrite-mcp/Cargo.toml to the version-bump file list

Test plan

  • cargo build --workspace clean
  • cargo test -p sqlrite-mcp — 16/16 with default features
  • cargo test -p sqlrite-mcp --no-default-features — 16/16 (omits ask)
  • cargo fmt --all
  • Manual smoke test: spawn binary, pipe initialize + tools/list + DDL/DML/SELECT round-trip → JSON-RPC clean (no engine prettytable noise on stdout)
  • Post-merge manual test: wire into Claude Code with the snippet from docs/mcp.md, verify the seven tools appear and ask works against an Anthropic key
  • Post-merge release: cut a Release PR — confirm the new publish-mcp and build-mcp-binaries jobs run, the crate publishes to crates.io, and the per-platform tarballs land on the GH Release

🤖 Generated with Claude Code

joaoh82 and others added 2 commits May 2, 2026 23:54
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>
@joaoh82 joaoh82 merged commit 860f843 into main May 3, 2026
15 of 16 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant