• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25277076831

03 May 2026 10:47AM UTC coverage: 56.877% (-0.4%) from 57.232%
25277076831

push

github

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

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

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/lis... (continued)

0 of 60 new or added lines in 3 files covered. (0.0%)

5492 of 9656 relevant lines covered (56.88%)

1.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/sqlrite-mcp/src/protocol.rs
1
//! JSON-RPC 2.0 dispatcher + MCP lifecycle.
2
//!
3
//! Owns:
4
//!
5
//! - The [`ServerState`] type — wraps the open `Connection`, the
6
//!   read-only flag, and an `initialized` boolean (the client must
7
//!   send `initialize` before anything else; we track that here).
8
//!
9
//! - The [`handle`] entrypoint that takes one inbound JSON-RPC
10
//!   message string and returns the response JSON value (or `None`
11
//!   if the message was a notification, which JSON-RPC says gets no
12
//!   reply).
13
//!
14
//! - The four MCP methods we implement: `initialize`,
15
//!   `notifications/initialized`, `tools/list`, `tools/call`. Plus
16
//!   a no-op `shutdown` and `notifications/cancelled` for politeness.
17
//!
18
//! Tool registry lives in [`tools::dispatch`] — this file calls
19
//! through that function and packages whatever it returns into a
20
//! tool-result JSON shape.
21
//!
22
//! ## MCP version
23
//!
24
//! We declare protocol version `2025-11-25` (current as of Phase
25
//! 7h's design). If the client requests an older version, we echo
26
//! ours back and let the client decide whether to disconnect — that
27
//! matches the spec's recommendation.
28

29
use serde::{Deserialize, Serialize};
30
use serde_json::{Value, json};
31

32
use sqlrite::Connection;
33

34
use crate::error::ProtocolError;
35
use crate::tools;
36

37
/// Protocol version we declare in `initialize`. Pin to a single
38
/// constant so future bumps are a one-line change.
39
pub const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
40

41
/// Per-process server state.
42
pub struct ServerState {
43
    pub conn: Connection,
44
    pub read_only: bool,
45
    /// Set to `true` after a successful `initialize` call. Required
46
    /// by the spec — methods other than `initialize` should be
47
    /// rejected before initialization completes.
48
    pub initialized: bool,
49
}
50

51
impl ServerState {
NEW
52
    pub fn new(conn: Connection, read_only: bool) -> Self {
×
53
        Self {
54
            conn,
55
            read_only,
56
            initialized: false,
57
        }
58
    }
59
}
60

61
/// Inbound JSON-RPC envelope. Loose typing on `params` (Option<Value>)
62
/// because each method has its own params shape and we'd rather
63
/// downcast in the handler than chase a giant tagged enum here.
64
#[derive(Debug, Deserialize)]
65
struct Request {
66
    #[allow(dead_code)]
67
    jsonrpc: String,
68
    /// Notifications omit `id`. Requests carry one.
69
    id: Option<Value>,
70
    method: String,
71
    #[serde(default)]
72
    params: Option<Value>,
73
}
74

75
/// Outbound success envelope. We don't use a struct for errors —
76
/// they're rarer and we hand-build the JSON for clarity at the
77
/// callsite.
78
#[derive(Debug, Serialize)]
79
struct Response<'a> {
80
    jsonrpc: &'static str,
81
    id: &'a Value,
82
    result: Value,
83
}
84

85
/// Top-level dispatcher. Takes one inbound JSON-RPC message string
86
/// and returns the response value (or `None` for notifications).
87
///
88
/// Errors from this layer are JSON-RPC errors — the response body
89
/// itself carries the error code + message, the caller still writes
90
/// a response to the wire.
91
pub fn handle(message: &str, state: &mut ServerState) -> Option<Value> {
92
    // Step 1: parse the JSON-RPC envelope.
93
    let req: Request = match serde_json::from_str(message) {
94
        Ok(r) => r,
95
        Err(err) => {
96
            // Can't recover the id — JSON-RPC spec says id should be
97
            // null in this case.
98
            return Some(error_response(
99
                &Value::Null,
100
                &ProtocolError::parse_error(format!("invalid JSON: {err}")),
101
            ));
102
        }
103
    };
104

105
    let id = req.id.clone();
106
    let is_notification = id.is_none();
107

108
    // Step 2: lifecycle check. The spec lets us answer `initialize`
109
    // and any `notifications/*` before initialization, but
110
    // `tools/list` / `tools/call` / `shutdown` MUST wait.
111
    let needs_init = !matches!(
112
        req.method.as_str(),
113
        "initialize" | "notifications/initialized" | "notifications/cancelled"
114
    );
115
    if needs_init && !state.initialized {
116
        if is_notification {
117
            return None;
118
        }
119
        return Some(error_response(
120
            &id.unwrap_or(Value::Null),
121
            &ProtocolError::server_not_initialized(format!(
122
                "received `{}` before `initialize`",
123
                req.method
124
            )),
125
        ));
126
    }
127

128
    // Step 3: dispatch.
129
    let result = match req.method.as_str() {
130
        "initialize" => handle_initialize(state, req.params.as_ref()),
131
        "notifications/initialized" => {
132
            state.initialized = true;
133
            return None; // notification — no reply
134
        }
135
        "notifications/cancelled" => {
136
            // We don't run tools async, so cancellation is a no-op —
137
            // by the time we'd see the cancel notification, the tool
138
            // has already completed (or is about to).
139
            return None;
140
        }
141
        "shutdown" => Ok(Value::Null),
142
        "tools/list" => handle_tools_list(state),
143
        "tools/call" => handle_tools_call(state, req.params.as_ref()),
144
        // MCP also defines `ping` for keep-alive.
145
        "ping" => Ok(json!({})),
146
        other => {
147
            if is_notification {
148
                return None;
149
            }
150
            return Some(error_response(
151
                &id.unwrap_or(Value::Null),
152
                &ProtocolError::method_not_found(other),
153
            ));
154
        }
155
    };
156

157
    if is_notification {
158
        return None;
159
    }
160

161
    let id = id.unwrap_or(Value::Null);
162
    match result {
163
        Ok(value) => Some(
164
            serde_json::to_value(Response {
165
                jsonrpc: "2.0",
166
                id: &id,
167
                result: value,
168
            })
169
            .unwrap(),
170
        ),
171
        Err(err) => Some(error_response(&id, &err)),
172
    }
173
}
174

175
/// Build a JSON-RPC error response for a given id + error.
176
fn error_response(id: &Value, err: &ProtocolError) -> Value {
177
    json!({
178
        "jsonrpc": "2.0",
179
        "id": id,
180
        "error": {
181
            "code": err.code,
182
            "message": err.message,
183
        }
184
    })
185
}
186

187
// ----------------------------------------------------------------------
188
// Lifecycle methods
189
// ----------------------------------------------------------------------
190

191
fn handle_initialize(
192
    state: &mut ServerState,
193
    params: Option<&Value>,
194
) -> Result<Value, ProtocolError> {
195
    // We don't actually need anything from the client's params, but
196
    // peek at `protocolVersion` so we can echo it back if the client
197
    // asks for our supported version (rather than always echoing
198
    // ours, which would be silently wrong if they're on an older
199
    // version we still happen to support). Today we declare exactly
200
    // one supported version, so we ignore the client's value.
201
    let _ = params;
202

203
    // Mark initialized eagerly. Strictly the spec says we should wait
204
    // for `notifications/initialized`, but tools-only servers don't
205
    // do anything between the response and the notification, so it
206
    // doesn't matter — and it makes the loop cleaner if we accept
207
    // tools/list even from a client that skipped the notification
208
    // (some implementations do).
209
    state.initialized = true;
210

211
    Ok(json!({
212
        "protocolVersion": MCP_PROTOCOL_VERSION,
213
        "capabilities": {
214
            // listChanged: false because our tool set is static for
215
            // this binary version + feature set.
216
            "tools": { "listChanged": false }
217
        },
218
        "serverInfo": {
219
            "name": "sqlrite-mcp",
220
            "version": env!("CARGO_PKG_VERSION"),
221
        }
222
    }))
223
}
224

225
// ----------------------------------------------------------------------
226
// tools/list
227
// ----------------------------------------------------------------------
228

229
fn handle_tools_list(state: &ServerState) -> Result<Value, ProtocolError> {
230
    Ok(json!({
231
        "tools": tools::list(state.read_only),
232
    }))
233
}
234

235
// ----------------------------------------------------------------------
236
// tools/call
237
// ----------------------------------------------------------------------
238

239
#[derive(Debug, Deserialize)]
240
struct ToolsCallParams {
241
    name: String,
242
    #[serde(default)]
243
    arguments: Value,
244
}
245

246
fn handle_tools_call(
247
    state: &mut ServerState,
248
    params: Option<&Value>,
249
) -> Result<Value, ProtocolError> {
250
    let params = params.ok_or_else(|| {
251
        ProtocolError::invalid_params("tools/call requires `name` + `arguments` params")
252
    })?;
253
    let parsed: ToolsCallParams = serde_json::from_value(params.clone()).map_err(|err| {
254
        ProtocolError::invalid_params(format!("invalid tools/call params: {err}"))
255
    })?;
256

257
    // Hidden + rejected: `execute` under --read-only. We could rely
258
    // on the engine's read-only locking to surface a SQL error, but
259
    // a tool-level rejection gives the LLM a clearer message ("this
260
    // tool is disabled in read-only mode") than a lock-acquisition
261
    // failure deep inside the engine.
262
    if parsed.name == "execute" && state.read_only {
263
        return Ok(tool_error_result(
264
            "the `execute` tool is disabled in read-only mode (--read-only). \
265
             Use `query` for SELECT statements, or restart the server without --read-only.",
266
        ));
267
    }
268

269
    match tools::dispatch(&parsed.name, parsed.arguments, state) {
270
        Ok(text) => Ok(tool_text_result(text, false)),
271
        Err(crate::error::ToolError(msg)) => Ok(tool_text_result(msg, true)),
272
    }
273
}
274

275
/// Build a `{ content: [{type: "text", text}], isError }` shape —
276
/// the canonical MCP tool result.
277
pub(crate) fn tool_text_result(text: String, is_error: bool) -> Value {
278
    json!({
279
        "content": [{ "type": "text", "text": text }],
280
        "isError": is_error,
281
    })
282
}
283

284
/// Convenience wrapper for tool-error responses.
285
pub(crate) fn tool_error_result(msg: impl Into<String>) -> Value {
286
    tool_text_result(msg.into(), true)
287
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc