1use std::io::{BufRead, Write};
36use std::path::PathBuf;
37
38use crate::command::{self, Arguments, Command, Context, Failed, OpenMode};
39use crate::json::{self, Json};
40
41pub const PROTOCOL: &str = "2025-06-18";
46
47pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
59
60pub const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
67
68pub const MAX_ROWS: usize = 10_000;
77
78pub struct Settings {
80 pub database: String,
82 pub readonly: bool,
84 pub root: Option<PathBuf>,
86 pub limit: usize,
88 pub max_rows: usize,
90 pub max_time: std::time::Duration,
92}
93
94#[derive(Default)]
96enum Lifecycle {
97 #[default]
99 AwaitingInitialize,
100 AwaitingInitializedNotification,
102 Ready,
104}
105
106#[derive(Default)]
108pub struct Session {
109 lifecycle: Lifecycle,
110}
111
112impl Default for Settings {
113 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
126pub 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
139fn 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
153pub fn tool_name(command: &Command) -> String {
157 format!("inillucent_{}", command.name.replace('-', "_"))
158}
159
160pub 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 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
213pub fn serve<R: BufRead + Send + 'static>(
232 settings: Settings,
233 mut input: R,
234 output: &mut impl Write,
235) -> Result<(), String> {
236 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 context.refuse_the_world();
250 context.limit = settings.limit.min(settings.max_rows);
251 context.set_max_rows(Some(settings.max_rows));
252 context.set_limits(
259 inillucent_driver::StatementLimits::served().with_time(Some(settings.max_time)),
260 );
261 let mut session = Session::default();
262
263 let cancel = context.cancel_flag();
266 context.preserve_cancellation();
269 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 Ok(Arrival::Ended) | Err(_) => return Ok(()),
321 Ok(Arrival::Line(arrived)) => line.push_str(&arrived),
322 Ok(Arrival::TooLong) => {
323 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 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
372fn 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
391enum Arrival {
393 Line(String),
395 Ended,
397 TooLong,
399}
400
401fn 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
422fn 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#[derive(Default)]
436struct Cancellation {
437 running: Option<String>,
439 cancelled: Vec<String>,
441}
442
443enum Claim {
445 Cancelled(Json),
447 Running,
449}
450
451fn 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 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
485struct TooLong;
487
488fn read_request(input: &mut impl BufRead, line: &mut String) -> Result<usize, TooLong> {
500 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 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
539pub 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 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 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(¶ms);
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, ¶ms),
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
640pub 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
651fn valid_request_id(id: &Json) -> bool {
655 matches!(
656 id,
657 Json::Null | Json::Int(_) | Json::Real(_) | Json::Text(_)
658 )
659}
660
661fn 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
681fn 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
736fn 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 ¶ms.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 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
782fn 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
792fn 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
809fn tool_error(message: &str) -> Json {
813 content(message, true)
814}
815
816fn 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 fn context() -> Context {
842 Context::open(":memory:", OpenMode::ReadWrite, None).expect("an in-memory database opens")
843 }
844
845 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}