Skip to main content

inillucent_cli/
mcp.rs

1//! The MCP server: the command table, served to an agent.
2//!
3//! Invariant: **every tool is a row of [`crate::command::COMMANDS`], and the
4//! server invents nothing.** `tools/list` is generated from the table, each
5//! schema is generated from that command's parameters, and each description is
6//! the summary and detail a person reads in `inillucent help`. A tool the table
7//! does not have cannot be served, and a command the table has cannot be
8//! forgotten - `command_parity.rs` asserts both directions.
9//!
10//! The transport is stdio JSON-RPC 2.0, one object per line, which is what the
11//! Model Context Protocol specifies for a local server. There is no HTTP here
12//! and there should not be: a database server that listens on a socket is a
13//! different product with a different threat model, and this one is started by
14//! the agent that talks to it and dies with it.
15//!
16//! ## Why the default answer is a table rather than JSON
17//!
18//! Every tool takes `output`, and it defaults to `text`. It is `output` rather
19//! than `format` because `inillucent_export` has a `format` of its own, naming
20//! CSV against JSON against Markdown - a different question, and one word
21//! cannot answer both. A structured result is
22//! strictly more informative, and the local 27B model this was verified against
23//! is measurably worse at reading one: it drops columns out of an array of
24//! objects and answers from the first row. An aligned table is what it reads
25//! correctly, so that is the default and `output: "json"` is one parameter away
26//! for a client that would rather parse.
27//!
28//! ## What it does not do
29//!
30//! No resources, no prompts, no sampling, no subscriptions. Each is a real part
31//! of the protocol and none of them is a database operation; declaring a
32//! capability this server does not implement would be the same kind of untrue
33//! claim the capability table exists to prevent.
34
35use std::io::{BufRead, Write};
36use std::path::PathBuf;
37
38use crate::command::{self, Arguments, Command, Context, Failed, OpenMode};
39use crate::json::{self, Json};
40
41/// The protocol version this server speaks.
42///
43/// Sent to every client, including one that asks for a revision this server does
44/// not support. MCP requires a server to select a revision it supports.
45pub const PROTOCOL: &str = "2025-06-18";
46
47/// The most bytes one request line may hold.
48///
49/// **A megabyte, and the bound is on the line rather than on the parsed
50/// object**, because a reader that parsed first would have allocated whatever
51/// it was sent before it could decide. `BufRead::read_line` on a client that
52/// never sends a newline grows a `String` until the process dies, and this is
53/// the only place that can stop it.
54///
55/// A megabyte is far above any tool call - the largest thing one carries is a
56/// SQL statement and a parameter array - and far below a size that costs
57/// anything to refuse.
58pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
59
60/// The most bytes one answer may hold before it is refused rather than sent.
61///
62/// **Refused, not truncated.** A truncated JSON-RPC frame is not a smaller
63/// answer, it is an unparseable one, and a client that received one would
64/// report a broken server. So an answer that would be too large is replaced by
65/// a refusal that says which budget it was and what to do about it.
66pub const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
67
68/// The most rows one MCP call hands back.
69///
70/// **Ten thousand, against the command line's absence of a ceiling**, which is
71/// the distinction the whole budget rests on: a person asking their own
72/// database for every row is asking for what they want, and an agent asking a
73/// served database for the same thing is what `--root` and `--readonly` already
74/// exist for. It is also well above what an agent can read: a model that is
75/// handed ten thousand rows is going to summarise the first fifty.
76pub const MAX_ROWS: usize = 10_000;
77
78/// What the server was started with.
79pub struct Settings {
80    /// The database opened when a call does not name one.
81    pub database: String,
82    /// Whether every statement that changes something is refused.
83    pub readonly: bool,
84    /// The directory outside which no path may be named.
85    pub root: Option<PathBuf>,
86    /// How many rows a call gets back when it does not say.
87    pub limit: usize,
88    /// The most rows one call may hand back.
89    pub max_rows: usize,
90    /// How long one call may run before it is stopped.
91    pub max_time: std::time::Duration,
92}
93
94/// The initialization stage of one MCP stdio session.
95#[derive(Default)]
96enum Lifecycle {
97    /// The client has not sent its initialize request.
98    #[default]
99    AwaitingInitialize,
100    /// The server answered initialize and waits for notifications/initialized.
101    AwaitingInitializedNotification,
102    /// The client completed initialization and may use normal methods.
103    Ready,
104}
105
106/// State held for the lifetime of one MCP stdio session.
107#[derive(Default)]
108pub struct Session {
109    lifecycle: Lifecycle,
110}
111
112impl Default for Settings {
113    /// The settings an agent gets when it starts the server with no arguments.
114    fn default() -> Settings {
115        Settings {
116            database: ":memory:".to_string(),
117            readonly: false,
118            root: None,
119            limit: 200,
120            max_rows: MAX_ROWS,
121            max_time: std::time::Duration::from_secs(60),
122        }
123    }
124}
125
126/// Returns the tools an MCP client is offered.
127///
128/// One per command that is not `cli_only`, named `inillucent_<command>` with
129/// dashes folded to underscores, because a tool name is an identifier in every
130/// client that has ever been written and a dash is not.
131pub fn tools() -> Vec<Json> {
132    command::COMMANDS
133        .iter()
134        .filter(|command| command.cli_only.is_none())
135        .map(tool_of)
136        .collect()
137}
138
139/// Returns one command as an MCP tool declaration.
140///
141/// @param command - the table row
142fn tool_of(command: &'static Command) -> Json {
143    json::object(vec![
144        ("name", json::text(tool_name(command))),
145        (
146            "description",
147            json::text(format!("{}\n\n{}", command.summary, command.detail)),
148        ),
149        ("inputSchema", schema_of(command)),
150    ])
151}
152
153/// Returns the tool name a command is served under.
154///
155/// @param command - the table row
156pub fn tool_name(command: &Command) -> String {
157    format!("inillucent_{}", command.name.replace('-', "_"))
158}
159
160/// Returns the JSON Schema for a command's arguments.
161///
162/// @param command - the table row
163pub fn schema_of(command: &Command) -> Json {
164    let properties: Vec<(String, Json)> = command
165        .params
166        .iter()
167        .map(|param| {
168            let mut member = vec![
169                ("type", json::text(param.kind.schema_type())),
170                ("description", json::text(param.description)),
171            ];
172            // An array's element type has to be declared or a client cannot
173            // validate what it is about to send. These arrays hold SQL values,
174            // which are exactly the five JSON scalars, so the schema says so
175            // rather than leaving `items` open.
176            if param.kind == command::Kind::Values {
177                member.push((
178                    "items",
179                    json::object(vec![(
180                        "type",
181                        Json::Array(vec![
182                            json::text("string"),
183                            json::text("number"),
184                            json::text("boolean"),
185                            json::text("null"),
186                        ]),
187                    )]),
188                ));
189            }
190            if let Some(allowed) = command.allowed_values(param.name) {
191                member.push((
192                    "enum",
193                    Json::Array(allowed.iter().map(|value| json::text(*value)).collect()),
194                ));
195            }
196            (param.name.to_string(), json::object(member))
197        })
198        .collect();
199    let required: Vec<Json> = command
200        .params
201        .iter()
202        .filter(|param| param.required)
203        .map(|param| json::text(param.name))
204        .collect();
205    json::object(vec![
206        ("type", json::text("object")),
207        ("properties", Json::Object(properties)),
208        ("required", Json::Array(required)),
209        ("additionalProperties", Json::Bool(false)),
210    ])
211}
212
213/// Reads requests from a reader and writes answers to a writer until it ends.
214///
215/// **The reading happens on a second thread (task-1932, H11).** A server that
216/// reads, answers, and only then reads again cannot see a message that arrives
217/// *while* it is answering - which is every message worth acting on
218/// immediately, and `notifications/cancelled` is the one the protocol defines
219/// for it. A `tools/call` running a scan of a large table held this server for
220/// its whole sixty second deadline and the client's cancellation sat unread in
221/// the pipe behind it.
222///
223/// The reader thread does two things: it sets this session's cancellation flag
224/// the moment it sees a cancellation notification, and it hands every line to
225/// the main thread. Nothing else is interpreted there - the protocol lives in
226/// `handle_with_session`, and a second reader of it would be a second server.
227///
228/// @param settings - what the server was started with
229/// @param input - where requests arrive, owned so it can be read from a thread
230/// @param output - where answers go
231pub fn serve<R: BufRead + Send + 'static>(
232    settings: Settings,
233    mut input: R,
234    output: &mut impl Write,
235) -> Result<(), String> {
236    // **A served database is one that exists.** A server makes no file: pointed
237    // at a typo it used to create an empty database and answer every question
238    // from it for the rest of its life (task-1979, E2).
239    let mut context = Context::open_for(
240        &settings.database,
241        OpenMode::of(settings.readonly),
242        settings.root.clone(),
243        false,
244    )
245    .map_err(|failure| failure.message)?;
246    // **Safe mode, unconditionally.** See `Context::refuse_the_world`: this
247    // server's clients are agents, its standard output is the protocol, and
248    // there is no case for handing one a shell on the host.
249    context.refuse_the_world();
250    context.limit = settings.limit.min(settings.max_rows);
251    context.set_max_rows(Some(settings.max_rows));
252    // **The engine's own budget, armed for the life of the server rather than
253    // per call.** A row ceiling on the command surface bounds what a *tool
254    // call* hands back; this bounds what the engine does on the way there, so a
255    // `SELECT` whose `WHERE` rejects everything after scanning a hundred
256    // million rows still stops. The two are different questions and both need
257    // an answer.
258    context.set_limits(
259        inillucent_driver::StatementLimits::served().with_time(Some(settings.max_time)),
260    );
261    let mut session = Session::default();
262
263    // The reader thread. It owns the input for the life of the server, sends
264    // each line here, and stops when the input ends or the main thread is gone.
265    let cancel = context.cancel_flag();
266    // This server clears the flag itself, at the boundary below, so `arm` must
267    // not clear it again - see `budget::arm_as_it_stands`.
268    context.preserve_cancellation();
269    // **What is running, and what has been cancelled, under one lock
270    // (task-1932, H11).** A call and its cancellation arrive as two lines in
271    // one write, and the two threads can interleave in either order:
272    //
273    // - the cancellation is read *before* the main thread takes the call off
274    //   the queue, in which case `running` is not yet its id and the id is
275    //   recorded - the main thread finds it and answers cancelled without
276    //   running anything;
277    // - the cancellation is read *after*, in which case `running` is its id and
278    //   the flag is set - the main thread has already cleared the flag and
279    //   armed the budget, so the statement stops at its next batch.
280    //
281    // Both decisions are made holding this lock, which is what makes the pair
282    // exhaustive. Before it the second case lost about one run in three: the
283    // id check had already passed and `budget::arm`'s clear wiped the flag.
284    let state: std::sync::Arc<std::sync::Mutex<Cancellation>> =
285        std::sync::Arc::new(std::sync::Mutex::new(Cancellation::default()));
286    let noted = std::sync::Arc::clone(&state);
287    let (lines, arriving) = std::sync::mpsc::channel::<Arrival>();
288    std::thread::spawn(move || {
289        let mut line = String::new();
290        loop {
291            line.clear();
292            let arrival = match read_request(&mut input, &mut line) {
293                Ok(0) => Arrival::Ended,
294                Ok(_) => {
295                    if let Some(id) = cancellation_target(&line) {
296                        if let Ok(mut held) = noted.lock() {
297                            if held.running.as_deref() == Some(id.as_str()) {
298                                cancel.store(true, std::sync::atomic::Ordering::Relaxed);
299                            } else {
300                                held.cancelled.push(id);
301                            }
302                        }
303                    }
304                    Arrival::Line(line.clone())
305                }
306                Err(TooLong) => Arrival::TooLong,
307            };
308            let ended = matches!(arrival, Arrival::Ended | Arrival::TooLong);
309            if lines.send(arrival).is_err() || ended {
310                return;
311            }
312        }
313    });
314
315    let mut line = String::new();
316    loop {
317        line.clear();
318        match arriving.recv() {
319            // The reader ended, or went away with it.
320            Ok(Arrival::Ended) | Err(_) => return Ok(()),
321            Ok(Arrival::Line(arrived)) => line.push_str(&arrived),
322            Ok(Arrival::TooLong) => {
323                // The connection is not recoverable: the rest of an over-long
324                // line is still in the stream and would be read as the next
325                // request. Saying so and stopping is the honest end.
326                let _ = writeln!(
327                    output,
328                    "{}",
329                    error_response(
330                        Json::Null,
331                        -32600,
332                        &format!(
333                            "a request may not be longer than {MAX_REQUEST_BYTES} bytes, and this \
334                             connection has sent one that is."
335                        )
336                    )
337                );
338                let _ = output.flush();
339                return Ok(());
340            }
341        }
342        if line.trim().is_empty() {
343            continue;
344        }
345        // A request whose cancellation arrived first is answered as cancelled
346        // rather than run. The id is taken out of the list, so a client that
347        // reuses an id is not cancelled twice by one notification; and the flag
348        // is cleared and `running` published in the same critical section, so
349        // the reader thread's next decision is made against this request rather
350        // than the one before it.
351        match claim(&line, &state, &context) {
352            Claim::Cancelled(id) => {
353                let answer = error_response(id, -32800, "this request was cancelled.");
354                writeln!(output, "{answer}").map_err(|error| error.to_string())?;
355                output.flush().map_err(|error| error.to_string())?;
356                continue;
357            }
358            Claim::Running => {}
359        }
360        let answered = handle_with_session(&mut context, &mut session, &line);
361        if let Ok(mut held) = state.lock() {
362            held.running = None;
363        }
364        if let Some(answer) = answered {
365            let answer = enforce_response_budget(answer);
366            writeln!(output, "{answer}").map_err(|error| error.to_string())?;
367            output.flush().map_err(|error| error.to_string())?;
368        }
369    }
370}
371
372/// Replaces an oversized response with a JSON RPC error for the same request.
373///
374/// @param answer - the completed response before it is written to the client
375fn enforce_response_budget(answer: String) -> String {
376    if answer.len() <= MAX_RESPONSE_BYTES {
377        return answer;
378    }
379    let id = response_id(&answer);
380    error_response(
381        id,
382        -32603,
383        &format!(
384            "this answer would have been {} bytes, past the {MAX_RESPONSE_BYTES} a \
385             reply may hold. Ask for fewer rows or fewer columns.",
386            answer.len()
387        ),
388    )
389}
390
391/// What the reader thread found.
392enum Arrival {
393    /// One request line, whole.
394    Line(String),
395    /// The input ended.
396    Ended,
397    /// A line ran past [`MAX_REQUEST_BYTES`].
398    TooLong,
399}
400
401/// Returns the request id a cancellation notification names.
402///
403/// `Some("")` for a cancellation with no `requestId`, which is not a shape the
404/// protocol defines but is one a hand-written client sends: the flag is still
405/// set for it, and no future request matches an empty id.
406///
407/// @param line - the request line as it arrived
408fn cancellation_target(line: &str) -> Option<String> {
409    let request = json::parse(line).ok()?;
410    if request.get("method").and_then(Json::text) != Some("notifications/cancelled") {
411        return None;
412    }
413    Some(
414        request
415            .get("params")
416            .and_then(|params| params.get("requestId"))
417            .map(id_text)
418            .unwrap_or_default(),
419    )
420}
421
422/// Returns a request id as the text two ids are compared by.
423///
424/// @param id - the id, as it arrived
425fn id_text(id: &Json) -> String {
426    match id {
427        Json::Text(text) => text.clone(),
428        Json::Int(number) => number.to_string(),
429        Json::Real(number) => number.to_string(),
430        other => format!("{other:?}"),
431    }
432}
433
434/// What the two threads agree about, under one lock.
435#[derive(Default)]
436struct Cancellation {
437    /// The id of the request the main thread is answering, if any.
438    running: Option<String>,
439    /// The ids of requests a cancellation named before they started.
440    cancelled: Vec<String>,
441}
442
443/// What claiming a request decided.
444enum Claim {
445    /// A cancellation for it had already arrived; this is its id.
446    Cancelled(Json),
447    /// It is now the running request.
448    Running,
449}
450
451/// Takes a request as the running one, or reports that it was cancelled first.
452///
453/// **The one critical section (task-1932, H11).** Clearing the flag, checking
454/// the recorded ids and publishing `running` all happen here, so the reader
455/// thread's next decision is made against this request. Splitting them is the
456/// window a cancellation used to be lost in.
457///
458/// @param line - the request line as it arrived
459/// @param state - what the two threads agree about
460/// @param context - the session whose cancellation flag is being cleared
461fn claim(line: &str, state: &std::sync::Mutex<Cancellation>, context: &Context) -> Claim {
462    let Ok(request) = json::parse(line) else {
463        return Claim::Running;
464    };
465    let Some(id) = request.get("id") else {
466        // A notification has no id, so nothing can cancel it and nothing has
467        // to be published about it.
468        return Claim::Running;
469    };
470    let text = id_text(id);
471    let Ok(mut held) = state.lock() else {
472        return Claim::Running;
473    };
474    if let Some(at) = held.cancelled.iter().position(|named| *named == text) {
475        held.cancelled.remove(at);
476        return Claim::Cancelled(id.clone());
477    }
478    context
479        .cancel_flag()
480        .store(false, std::sync::atomic::Ordering::Relaxed);
481    held.running = Some(text);
482    Claim::Running
483}
484
485/// A request line that ran past [`MAX_REQUEST_BYTES`].
486struct TooLong;
487
488/// Reads one request line, refusing one that is too long.
489///
490/// **Byte by byte up to the ceiling, rather than `read_line` and a check
491/// afterwards.** `read_line` on a client that never sends a newline grows the
492/// string until the process dies, so a check after it never runs. This is the
493/// same argument `inillucent-remote`'s `MAX_MESSAGE` makes about a length a
494/// server announces: a bound that is applied after the allocation is not a
495/// bound.
496///
497/// @param input - where requests arrive
498/// @param line - the buffer to fill
499fn read_request(input: &mut impl BufRead, line: &mut String) -> Result<usize, TooLong> {
500    // **A blank line is read past, and only the end of the stream ends the
501    // session** (task-2066 section 4.2, item 27). This returned `Ok(0)` for
502    // both, and `serve` reads `Ok(0)` as the end of input - so one stray
503    // newline from a client ended the session, the next request was never
504    // answered, and the process exited 0 as though the client had hung up.
505    // JSON-RPC over a line protocol has no meaning for an empty line, and
506    // reading past it is what every other implementation does.
507    loop {
508        let mut bytes: Vec<u8> = Vec::new();
509        let ended = loop {
510            let mut one = [0u8; 1];
511            match input.read(&mut one) {
512                Ok(0) | Err(_) => break true,
513                Ok(_) => {}
514            }
515            let byte = one.first().copied().unwrap_or(b'\n');
516            if byte == b'\n' {
517                break false;
518            }
519            if bytes.len() >= MAX_REQUEST_BYTES {
520                return Err(TooLong);
521            }
522            bytes.push(byte);
523        };
524        // Whitespace rather than emptiness, so a carriage return and newline
525        // pair - which is what a client on Windows sends - is the same blank
526        // line as a bare newline.
527        let blank = bytes.iter().all(|byte| byte.is_ascii_whitespace());
528        if blank {
529            match ended {
530                true => return Ok(0),
531                false => continue,
532            }
533        }
534        line.push_str(&String::from_utf8_lossy(&bytes));
535        return Ok(line.len());
536    }
537}
538
539/// Answers one request, or returns nothing for a notification.
540///
541/// @param context - the open database
542/// @param session - lifecycle state held for this client connection
543/// @param line - the request, as it arrived
544pub fn handle_with_session(
545    context: &mut Context,
546    session: &mut Session,
547    line: &str,
548) -> Option<String> {
549    let request = match json::parse(line) {
550        Ok(request) => request,
551        // -32700 is JSON-RPC's parse error, and it is answered with a null id
552        // because the id is exactly what could not be read.
553        Err(why) => return Some(error_response(Json::Null, -32700, &why)),
554    };
555    let Json::Object(_) = request else {
556        return Some(error_response(
557            Json::Null,
558            -32600,
559            "a request must be an object.",
560        ));
561    };
562    let id = request.get("id").cloned().unwrap_or(Json::Null);
563    if !valid_request_id(&id) {
564        return Some(error_response(
565            Json::Null,
566            -32600,
567            "a request id must be a string, number, or null.",
568        ));
569    }
570    if request.get("jsonrpc").and_then(Json::text) != Some("2.0") {
571        return Some(error_response(id, -32600, "'jsonrpc' must be '2.0'."));
572    }
573    let Some(method) = request.get("method").and_then(Json::text) else {
574        return Some(error_response(id, -32600, "a request needs a 'method'."));
575    };
576    // A notification has no id and takes no answer. Writing one anyway is the
577    // most common way a hand-written server breaks a strict client.
578    let is_notification = request.get("id").is_none();
579    let params = request.get("params").cloned().unwrap_or(Json::Null);
580    if request.get("params").is_some() && !matches!(params, Json::Object(_) | Json::Array(_)) {
581        return Some(error_response(
582            Json::Null,
583            -32600,
584            "request params must be an object or array.",
585        ));
586    }
587    if method == "initialize" {
588        if !matches!(session.lifecycle, Lifecycle::AwaitingInitialize) {
589            return Some(error_response(
590                id,
591                -32600,
592                "initialize was already completed.",
593            ));
594        }
595        let result = initialize(&params);
596        if result.is_ok() {
597            session.lifecycle = Lifecycle::AwaitingInitializedNotification;
598        }
599        return response_for(id, is_notification, result);
600    }
601    if method == "notifications/initialized" {
602        if matches!(
603            session.lifecycle,
604            Lifecycle::AwaitingInitializedNotification
605        ) {
606            session.lifecycle = Lifecycle::Ready;
607            return None;
608        }
609        return response_for(
610            id,
611            is_notification,
612            Err(Failed::misuse(
613                "notifications/initialized must follow initialize.",
614            )),
615        );
616    }
617    if !matches!(session.lifecycle, Lifecycle::Ready) {
618        return Some(error_response(
619            id,
620            -32002,
621            "MCP initialization must complete before this method is used.",
622        ));
623    }
624    let result = match method {
625        "tools/list" => Ok(json::object(vec![("tools", Json::Array(tools()))])),
626        "tools/call" => call(context, &params),
627        "ping" => Ok(json::object(vec![])),
628        _ if is_notification => return None,
629        other => {
630            return Some(error_response(
631                id,
632                -32601,
633                &format!("this server has no '{other}' method."),
634            ))
635        }
636    };
637    response_for(id, is_notification, result)
638}
639
640/// Answers one request for callers that do not maintain a stdio session.
641///
642/// @param context - the open database
643/// @param line - the request, as it arrived
644pub fn handle(context: &mut Context, line: &str) -> Option<String> {
645    let mut session = Session {
646        lifecycle: Lifecycle::Ready,
647    };
648    handle_with_session(context, &mut session, line)
649}
650
651/// Returns whether a JSON-RPC request id has one of the permitted types.
652///
653/// @param id - the request id the client supplied or the null default
654fn valid_request_id(id: &Json) -> bool {
655    matches!(
656        id,
657        Json::Null | Json::Int(_) | Json::Real(_) | Json::Text(_)
658    )
659}
660
661/// Renders a method result unless the request was a notification.
662///
663/// @param id - the request id to include in a response
664/// @param is_notification - whether the request omitted its id member
665/// @param result - the method result or parameter refusal
666fn response_for(id: Json, is_notification: bool, result: Result<Json, Failed>) -> Option<String> {
667    if is_notification {
668        return None;
669    }
670    Some(match result {
671        Ok(value) => json::object(vec![
672            ("jsonrpc", json::text("2.0")),
673            ("id", id),
674            ("result", value),
675        ])
676        .write(),
677        Err(failure) => error_response(id, -32602, &failure.message),
678    })
679}
680
681/// Returns what a client is told when it connects.
682///
683/// @param params - what the client sent, whose `protocolVersion` is echoed
684fn initialize(params: &Json) -> Result<Json, Failed> {
685    let Json::Object(_) = params else {
686        return Err(Failed::misuse("initialize params must be an object."));
687    };
688    for (name, required) in [
689        ("protocolVersion", true),
690        ("capabilities", true),
691        ("clientInfo", true),
692    ] {
693        if required && params.get(name).is_none() {
694            return Err(Failed::misuse(format!("initialize needs '{name}'.")));
695        }
696    }
697    if params.get("protocolVersion").and_then(Json::text).is_none() {
698        return Err(Failed::misuse("'protocolVersion' has to be text."));
699    }
700    if !matches!(params.get("capabilities"), Some(Json::Object(_))) {
701        return Err(Failed::misuse("'capabilities' has to be an object."));
702    }
703    if !matches!(params.get("clientInfo"), Some(Json::Object(_))) {
704        return Err(Failed::misuse("'clientInfo' has to be an object."));
705    }
706    Ok(json::object(vec![
707        ("protocolVersion", json::text(PROTOCOL)),
708        (
709            "capabilities",
710            json::object(vec![(
711                "tools",
712                json::object(vec![("listChanged", Json::Bool(false))]),
713            )]),
714        ),
715        (
716            "serverInfo",
717            json::object(vec![
718                ("name", json::text("inillucent")),
719                ("version", json::text(env!("CARGO_PKG_VERSION"))),
720            ]),
721        ),
722        (
723            "instructions",
724            json::text(
725                "inillucent is an embedded SQL database that speaks SQLite's dialect, with \
726                 full-text and vector search built in. Call inillucent_tables to see what is \
727                 there, inillucent_describe before writing SQL against a table you did not \
728                 create, inillucent_query to read and inillucent_exec to write. If a call comes \
729                 back with status 'unsupported', that construct is not built yet - it is not a \
730                 mistake in your SQL, and rewording it will not help.",
731            ),
732        ),
733    ]))
734}
735
736/// Runs one tool call.
737///
738/// @param context - the open database
739/// @param params - the `name` and `arguments` the client sent
740fn call(context: &mut Context, params: &Json) -> Result<Json, Failed> {
741    let Some(name) = params.get("name").and_then(Json::text) else {
742        return Err(Failed::misuse("a tools/call needs a 'name'."));
743    };
744    let Some(command) = command::find(name) else {
745        return Ok(tool_error(&format!(
746            "there is no tool called '{name}'. Call tools/list to see what there is."
747        )));
748    };
749    if command.cli_only.is_some() {
750        return Ok(tool_error(&format!(
751            "'{name}' is not served over MCP: {}",
752            command.cli_only.unwrap_or_default()
753        )));
754    }
755    let arguments = Arguments::from_json(
756        command,
757        &params.get("arguments").cloned().unwrap_or(Json::Null),
758    )?;
759    let wants_json = arguments.text("output") == Some("json");
760    match command::run(command, context, &arguments) {
761        Ok(produced) => {
762            let body = match wants_json {
763                true => produced.to_json().pretty(0),
764                false => produced.text.clone(),
765            };
766            Ok(content(&body, false))
767        }
768        // A refusal is a *result* with `isError`, not a JSON-RPC error: the
769        // request was well formed and the server answered it. A client that saw
770        // a protocol error here would report a broken server rather than
771        // showing the model a sentence it can act on.
772        Err(failure) => {
773            let body = match wants_json {
774                true => failure.to_json(command.name).pretty(0),
775                false => failure.to_text(),
776            };
777            Ok(content(&body, true))
778        }
779    }
780}
781
782/// Extracts the JSON RPC id from a completed response.
783///
784/// @param response - the response that may need replacing because it is too large
785fn response_id(response: &str) -> Json {
786    json::parse(response)
787        .ok()
788        .and_then(|value| value.get("id").cloned())
789        .unwrap_or(Json::Null)
790}
791
792/// Wraps text as an MCP tool result.
793///
794/// @param text - what to show
795/// @param failed - whether the tool refused
796fn content(text: &str, failed: bool) -> Json {
797    json::object(vec![
798        (
799            "content",
800            Json::Array(vec![json::object(vec![
801                ("type", json::text("text")),
802                ("text", json::text(text)),
803            ])]),
804        ),
805        ("isError", Json::Bool(failed)),
806    ])
807}
808
809/// Wraps a message the server itself is refusing with.
810///
811/// @param message - what to say
812fn tool_error(message: &str) -> Json {
813    content(message, true)
814}
815
816/// Renders a JSON-RPC error response.
817///
818/// @param id - the request's id, or null when it could not be read
819/// @param code - the JSON-RPC error code
820/// @param message - what went wrong
821fn error_response(id: Json, code: i64, message: &str) -> String {
822    json::object(vec![
823        ("jsonrpc", json::text("2.0")),
824        ("id", id),
825        (
826            "error",
827            json::object(vec![
828                ("code", Json::Int(code)),
829                ("message", json::text(message)),
830            ]),
831        ),
832    ])
833    .write()
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    /// Opens a scratch context for a test.
841    fn context() -> Context {
842        Context::open(":memory:", OpenMode::ReadWrite, None).expect("an in-memory database opens")
843    }
844
845    /// Every served tool is a command, and every command that is not cli-only
846    /// is served.
847    #[test]
848    fn the_tools_are_the_commands() {
849        let served: Vec<String> = tools()
850            .iter()
851            .filter_map(|tool| tool.get("name").and_then(Json::text).map(str::to_string))
852            .collect();
853        let expected: Vec<String> = command::COMMANDS
854            .iter()
855            .filter(|command| command.cli_only.is_none())
856            .map(tool_name)
857            .collect();
858        assert_eq!(served, expected);
859        assert!(served.contains(&"inillucent_query".to_string()));
860        assert!(!served.contains(&"inillucent_shell".to_string()));
861    }
862
863    /// A tool name is a valid identifier in every client.
864    #[test]
865    fn tool_names_have_no_dashes() {
866        for tool in tools() {
867            let name = tool
868                .get("name")
869                .and_then(Json::text)
870                .unwrap_or_default()
871                .to_string();
872            assert!(!name.contains('-'), "{name} has a dash in it");
873        }
874    }
875
876    /// Initialize selects the revision the server supports.
877    #[test]
878    fn initialize_selects_the_supported_version() {
879        let mut session = Session::default();
880        let answer = handle_with_session(
881            &mut context(),
882            &mut session,
883            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\
884             \"params\":{\"protocolVersion\":\"2099-01-01\",\"capabilities\":{},\
885             \"clientInfo\":{\"name\":\"test\"}}}",
886        )
887        .unwrap_or_default();
888        assert!(answer.contains(&format!("\"protocolVersion\":\"{PROTOCOL}\"")));
889        assert!(answer.contains("\"name\":\"inillucent\""));
890    }
891
892    /// Invalid JSON RPC envelopes and initialize payloads return protocol errors.
893    #[test]
894    fn invalid_requests_and_initialize_payloads_are_refused() {
895        for request in [
896            "{\"id\":41,\"method\":\"ping\"}",
897            "{\"jsonrpc\":\"1.0\",\"id\":42,\"method\":\"ping\"}",
898            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}",
899        ] {
900            let answer = handle(&mut context(), request).unwrap_or_default();
901            assert!(answer.contains("\"error\""), "{answer}");
902        }
903    }
904
905    /// JSON-RPC rejects scalar params and non scalar request ids with a null id.
906    #[test]
907    fn invalid_json_rpc_member_types_are_refused() {
908        for request in [
909            "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":\"ping\",\"params\":\"bad\"}",
910            "{\"jsonrpc\":\"2.0\",\"id\":true,\"method\":\"ping\"}",
911            "{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"ping\"}",
912        ] {
913            let answer = handle(&mut context(), request).unwrap_or_default();
914            assert!(answer.contains("\"code\":-32600"), "{answer}");
915            assert!(answer.contains("\"id\":null"), "{answer}");
916        }
917    }
918
919    /// Normal methods wait for initialize and notifications/initialized.
920    #[test]
921    fn initialization_must_complete_before_normal_methods() {
922        let mut held = context();
923        let mut session = Session::default();
924        let before = handle_with_session(
925            &mut held,
926            &mut session,
927            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}",
928        )
929        .unwrap_or_default();
930        assert!(before.contains("\"code\":-32002"), "{before}");
931        let initialized = handle_with_session(
932            &mut held,
933            &mut session,
934            "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\"}}}",
935        )
936        .unwrap_or_default();
937        assert!(initialized.contains("\"result\""), "{initialized}");
938        let waiting = handle_with_session(
939            &mut held,
940            &mut session,
941            "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\"}",
942        )
943        .unwrap_or_default();
944        assert!(waiting.contains("\"code\":-32002"), "{waiting}");
945        assert!(handle_with_session(
946            &mut held,
947            &mut session,
948            "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}",
949        )
950        .is_none());
951        let listed = handle_with_session(
952            &mut held,
953            &mut session,
954            "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/list\"}",
955        )
956        .unwrap_or_default();
957        assert!(listed.contains("\"result\""), "{listed}");
958    }
959
960    /// A notification is not answered.
961    #[test]
962    fn a_notification_gets_no_answer() {
963        assert!(handle(
964            &mut context(),
965            "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"
966        )
967        .is_none());
968    }
969
970    /// A round trip through the tools creates a table and reads it back.
971    #[test]
972    fn a_call_creates_and_reads() {
973        let mut held = context();
974        let made = handle(
975            &mut held,
976            "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\
977             \"name\":\"inillucent_exec\",\"arguments\":{\
978             \"sql\":\"CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)\"}}}",
979        )
980        .unwrap_or_default();
981        assert!(made.contains("\"isError\":false"), "{made}");
982        handle(
983            &mut held,
984            "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\
985             \"name\":\"inillucent_exec\",\"arguments\":{\
986             \"sql\":\"INSERT INTO people VALUES (?1, ?2)\",\"params\":[1,\"Ada\"]}}}",
987        );
988        let read = handle(
989            &mut held,
990            "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\
991             \"name\":\"inillucent_query\",\"arguments\":{\
992             \"sql\":\"SELECT name FROM people\"}}}",
993        )
994        .unwrap_or_default();
995        assert!(read.contains("Ada"), "{read}");
996    }
997
998    /// A tool that does not exist is a result, not a protocol error.
999    #[test]
1000    fn an_unknown_tool_is_a_tool_error() {
1001        let answer = handle(
1002            &mut context(),
1003            "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\
1004             \"params\":{\"name\":\"inillucent_nonsense\",\"arguments\":{}}}",
1005        )
1006        .unwrap_or_default();
1007        assert!(answer.contains("\"isError\":true"));
1008        assert!(answer.contains("\"result\""));
1009        assert!(!answer.contains("\"error\""));
1010    }
1011
1012    /// A document that is not JSON gets the parse error and a null id.
1013    #[test]
1014    fn a_broken_request_is_refused() {
1015        let answer = handle(&mut context(), "{not json").unwrap_or_default();
1016        assert!(answer.contains("-32700"));
1017        assert!(answer.contains("\"id\":null"));
1018    }
1019
1020    /// A method nobody implements is refused by name.
1021    #[test]
1022    fn an_unknown_method_is_refused() {
1023        let answer = handle(
1024            &mut context(),
1025            "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"resources/list\"}",
1026        )
1027        .unwrap_or_default();
1028        assert!(answer.contains("-32601"));
1029    }
1030
1031    /// Every schema declares its required parameters and nothing else.
1032    #[test]
1033    fn schemas_declare_what_is_required() {
1034        for command in command::COMMANDS {
1035            let schema = schema_of(command);
1036            let required: Vec<String> = schema
1037                .get("required")
1038                .and_then(Json::array)
1039                .unwrap_or_default()
1040                .iter()
1041                .filter_map(|name| name.text().map(str::to_string))
1042                .collect();
1043            let expected: Vec<String> = command
1044                .params
1045                .iter()
1046                .filter(|param| param.required)
1047                .map(|param| param.name.to_string())
1048                .collect();
1049            assert_eq!(
1050                required, expected,
1051                "{} declares the wrong required set",
1052                command.name
1053            );
1054            assert_eq!(schema.get("additionalProperties"), Some(&Json::Bool(false)));
1055        }
1056    }
1057
1058    /// Tool arguments reject unknown names, wrong types, and disallowed text values.
1059    #[test]
1060    fn tool_arguments_are_checked_against_the_command_schema() {
1061        for arguments in [
1062            "{\"sql\":\"SELECT 1\",\"limit\":\"one\"}",
1063            "{\"sql\":\"SELECT 1\",\"limti\":1}",
1064            "{\"sql\":\"SELECT 1\",\"output\":\"yaml\"}",
1065        ] {
1066            let answer = handle(
1067                &mut context(),
1068                &format!("{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{{\"name\":\"inillucent_query\",\"arguments\":{arguments}}}}}"),
1069            )
1070            .unwrap_or_default();
1071            assert!(answer.contains("\"code\":-32602"), "{answer}");
1072        }
1073        let query_schema = schema_of(command::find("query").unwrap_or(&command::COMMANDS[0]));
1074        let output = query_schema
1075            .get("properties")
1076            .and_then(|value| value.get("output"))
1077            .unwrap_or(&Json::Null)
1078            .write();
1079        assert!(output.contains("\"enum\":[\"text\",\"json\"]"), "{output}");
1080    }
1081
1082    /// An oversized replacement response keeps the original JSON RPC id.
1083    #[test]
1084    fn response_budget_errors_keep_the_request_id() {
1085        let response = format!(
1086            "{{\"jsonrpc\":\"2.0\",\"id\":77,\"result\":\"{}\"}}",
1087            "x".repeat(MAX_RESPONSE_BYTES)
1088        );
1089        let replacement = enforce_response_budget(response);
1090        assert!(replacement.contains("\"id\":77"), "{replacement}");
1091        assert!(replacement.contains("\"code\":-32603"), "{replacement}");
1092    }
1093
1094    /// A read-only server refuses a write and says why.
1095    #[test]
1096    fn read_only_refuses_a_write() {
1097        let mut held = Context::open(":memory:", OpenMode::ReadOnly, None).expect("opens");
1098        let answer = handle(
1099            &mut held,
1100            "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\
1101             \"name\":\"inillucent_exec\",\"arguments\":{\"sql\":\"CREATE TABLE t (a)\"}}}",
1102        )
1103        .unwrap_or_default();
1104        assert!(answer.contains("\"isError\":true"), "{answer}");
1105        assert!(answer.contains("read only"), "{answer}");
1106    }
1107}