From 3873fad612eef27e5b88841b6ea097b59eb8ea70 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Fri, 21 Aug 2026 22:54:12 +0200 Subject: [PATCH] Add MCP server --- Cargo.lock | 289 +++++++++++++++++++++++++++++++++++++++++++++++- srv/Cargo.toml | 5 +- srv/src/main.rs | 20 ++++ srv/src/mcp.rs | 89 +++++++++++++++ 4 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 srv/src/mcp.rs diff --git a/Cargo.lock b/Cargo.lock index c60de8b..1364e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,7 +419,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.20", "v_frame", @@ -507,6 +507,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bincode" version = "2.0.1" @@ -777,6 +783,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1066,6 +1073,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1194,6 +1235,12 @@ dependencies = [ "linux-raw-sys 0.9.4", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.17.0" @@ -1785,6 +1832,25 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1925,6 +1991,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1958,7 +2025,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2480,6 +2547,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -2589,11 +2662,14 @@ dependencies = [ "axum", "clap", "inkopslista-lib", + "rmcp", "serde", "serde_json", "sqlite", "tokio", "tower-http 0.7.0", + "tracing", + "tracing-subscriber", ] [[package]] @@ -3028,6 +3104,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -3278,6 +3363,15 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3855,6 +3949,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4395,6 +4495,26 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.13.1" @@ -4430,7 +4550,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -4502,6 +4622,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8dddc5b1924b9a59fba420166160ca2c4663a4e01803e52eda33070f56d63c8" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey 0.2.3", + "pin-project-lite", + "rand 0.10.2", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rowan" version = "0.16.1" @@ -4657,6 +4821,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4724,6 +4914,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -4780,6 +4981,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5166,6 +5376,19 @@ dependencies = [ "sqlite3-src", ] +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5371,6 +5594,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -5500,6 +5732,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5509,6 +5752,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5668,6 +5912,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5820,7 +6094,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -5860,6 +6134,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -5876,6 +6151,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/srv/Cargo.toml b/srv/Cargo.toml index 66e6512..ddef10c 100644 --- a/srv/Cargo.toml +++ b/srv/Cargo.toml @@ -9,6 +9,9 @@ serde_json = "1.0.145" inkopslista-lib = { path = "../lib" } sqlite = "0.37.0" clap = { version = "4.6.6", features = ["derive", "env"] } -axum = { version = "0.8.9", features = ["http1"] } +axum = { version = "0.8.9", features = ["http1", "http2", "tokio"] } tower-http = { version = "0.7.0", features = ["fs", "trace", "compression-gzip"] } tokio = { version = "1.53.1", features = ["full"] } +rmcp = { version = "3.1.2", features = ["server", "macros", "schemars", "transport-streamable-http-server", "transport-streamable-http-server-session"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/srv/src/main.rs b/srv/src/main.rs index ae0af06..d7b81e8 100644 --- a/srv/src/main.rs +++ b/srv/src/main.rs @@ -9,6 +9,10 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; +use tracing::Level; + +mod mcp; /// The directory where the web files for the gui is stored. fn www_path() -> &'static std::path::Path { @@ -116,6 +120,8 @@ struct Opt { bind: SocketAddr, #[clap(long, env = "DB_PATH")] db_path: PathBuf, + #[clap(long, env = "RUST_LOG", default_value = "debug")] + log_level: String, #[clap(long, env = "NUM_DAYS", default_value = "5")] num_days: u32, } @@ -123,6 +129,11 @@ struct Opt { #[tokio::main] async fn main() { let opt = Opt::parse(); + + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(&opt.log_level)) + .init(); + let db = sqlite::Connection::open_thread_safe(opt.db_path).unwrap(); db.execute(include_str!("db/init.sql")) .expect("unable to initialize database"); @@ -135,8 +146,17 @@ async fn main() { .route("/api/favorites", get(get_favorites)) .route("/api/favorites/{item}/category", put(put_category)) .fallback_service(ServeDir::new(www_path())) + .nest_service("/mcp", mcp::service(db.clone())) + .layer( + TraceLayer::new_for_http() + .make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO)) + .on_request(tower_http::trace::DefaultOnRequest::new().level(Level::INFO)) + .on_response(tower_http::trace::DefaultOnResponse::new().level(Level::INFO)), + ) .with_state(db); let listener = tokio::net::TcpListener::bind(opt.bind).await.unwrap(); + tracing::info!("Listening on http://{}", opt.bind); + axum::serve(listener, app).await.unwrap(); } diff --git a/srv/src/mcp.rs b/srv/src/mcp.rs new file mode 100644 index 0000000..a95caaf --- /dev/null +++ b/srv/src/mcp.rs @@ -0,0 +1,89 @@ +//! MCP routes + +use axum::Json; +use axum::extract::{Path, State}; +use inkopslista_lib::ItemData; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::tower::{ + StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::{schemars, tool, tool_router}; +use serde::Deserialize; +use sqlite::ConnectionThreadSafe; +use std::fmt::Write; +use std::sync::Arc; + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct PutParams { + /// The name of the shopping-list item, + item: String, + /// The number of items to buy. + #[serde(default = "default_amount")] + amount: i64, + /// Whether the item has been checked off. + #[serde(default)] + checked: bool, +} + +fn default_amount() -> i64 { + 1 +} + +#[derive(Clone)] +pub struct McpServer { + db: Arc, +} + +#[tool_router(server_handler)] +impl McpServer { + #[tool(description = "Read the entire shopping list")] + async fn list(&self) -> String { + // TODO: don't call the HTTP request handlers. Split the DB stuff out instead. + let db = State(self.db.clone()); + let items = crate::get_list(db).await.0; + let mut out = String::new(); + for item in items { + _ = writeln!( + &mut out, + "- [{checked}] {n}x {name}", + checked = if item.data.checked { "x" } else { " " }, + n = item.data.amount, + name = item.name, + ); + } + out + } + + #[tool(description = "Put an item onto the shopping list.")] + async fn put(&self, Parameters(params): Parameters) -> String { + // TODO: don't call the HTTP request handlers. Split the DB stuff out instead. + let db = State(self.db.clone()); + let data = ItemData { + amount: params.amount, + checked: params.checked, + }; + crate::put_list(db, Path(params.item), Json(data)).await; + "Ok".to_string() + } +} + +/// Create an MCP service that can be plugged into axum +pub fn service( + db: Arc, +) -> StreamableHttpService { + // TODO: tower_http::auth::add_authorization + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_json_response(false) + .disable_allowed_hosts() + .disable_allowed_origins(); + + let server = McpServer { db }; + + StreamableHttpService::new( + move || Ok(server.clone()), + LocalSessionManager::default().into(), + config, + ) +}