diff --git a/Cargo.lock b/Cargo.lock index d9f5b5d97aa..6ace4612c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1633,8 +1633,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1651,13 +1661,37 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.107", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn 2.0.107", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.107", ] @@ -2069,7 +2103,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.107", @@ -5271,6 +5305,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-clean" version = "1.0.1" @@ -6529,6 +6569,41 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "rmcp" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.107", +] + [[package]] name = "rolldown" version = "0.1.0" @@ -7147,12 +7222,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.107", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -7350,6 +7439,17 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -7430,7 +7530,7 @@ version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.107", @@ -8343,6 +8443,22 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spacetimedb-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest 0.12.24", + "rmcp", + "schemars 1.0.4", + "serde", + "serde_json", + "spacetimedb-lib", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "spacetimedb-memory-usage" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index ede22ce89ed..2d6fc671acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/fs-utils", "crates/lib", "crates/metrics", + "crates/spacetimedb-mcp", "crates/paths", "crates/pg", "crates/physical-plan", @@ -280,6 +281,8 @@ rustc-hash = "2" rustyline = { version = "12.0.0", features = [] } scoped-tls = "1.0.1" scopeguard = "1.1.0" +rmcp = "1.7" +schemars = "1" second-stack = "0.3" self-replace = "1.5" semver = "1" diff --git a/crates/spacetimedb-mcp/Cargo.toml b/crates/spacetimedb-mcp/Cargo.toml new file mode 100644 index 00000000000..085b94d2d73 --- /dev/null +++ b/crates/spacetimedb-mcp/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "spacetimedb-mcp" +version = "0.1.0" +edition.workspace = true + +[[bin]] +name = "spacetimedb-mcp" +path = "src/main.rs" + +[dependencies] +rmcp = { workspace = true, features = ["server", "transport-io", "macros"] } +spacetimedb-lib = { workspace = true, features = ["serde"] } +reqwest.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +schemars.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/crates/spacetimedb-mcp/README.md b/crates/spacetimedb-mcp/README.md new file mode 100644 index 00000000000..878acd01389 --- /dev/null +++ b/crates/spacetimedb-mcp/README.md @@ -0,0 +1,85 @@ +# spacetimedb-mcp + +A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for +SpacetimeDB. It exposes SpacetimeDB to MCP-aware agents and editors as a set of +tools, built on the official Rust MCP SDK ([`rmcp`](https://crates.io/crates/rmcp)). + +> Status: early. Read-only schema introspection works today (`get_schema`, +> `list_tables`, `list_reducers`); a `ping` health check is also included. +> Further tools (SQL, subscriptions, the CLI workflow) are deferred for now. + +## Transport + +The server speaks JSON-RPC over **stdio**: an MCP client launches it as a +subprocess and exchanges messages on stdin/stdout. Logs go to **stderr only** — +stdout is reserved for the protocol stream, so anything else printed there would +corrupt it. + +## Build + +```bash +cargo build -p spacetimedb-mcp +``` + +The binary lands at `target/debug/spacetimedb-mcp`. + +## Test + +```bash +cargo test -p spacetimedb-mcp +``` + +Unit tests cover the schema-to-output transformations and the +serialize/deserialize round trip the client relies on; an integration test +serves a canned schema over a throwaway HTTP server and checks the full +fetch-and-decode path, so no running SpacetimeDB instance is required. + +## Run + +Point an MCP client at the built binary. The introspection tools talk to a +running SpacetimeDB host, configured via environment variables: + +| Variable | Default | Purpose | +| ------------------- | ----------------------- | -------------------------------------------------- | +| `SPACETIMEDB_HOST` | `http://127.0.0.1:3000` | Base URL of the SpacetimeDB host to query. | +| `SPACETIMEDB_TOKEN` | _(unset)_ | Bearer token, required only for private databases. | + +The target database (a name or identity) is passed as an argument to each tool, +so one server can introspect any database on the host. Example client config: + +```json +{ + "mcpServers": { + "spacetimedb": { + "command": "/path/to/target/debug/spacetimedb-mcp", + "env": { "SPACETIMEDB_HOST": "http://127.0.0.1:3000" } + } + } +} +``` + +## Smoke test + +Drive the JSON-RPC handshake by hand to confirm the round trip works: + +```bash +printf '%s\n' \ +'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ +'{"jsonrpc":"2.0","method":"notifications/initialized"}' \ +'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{"message":"hi"}}}' \ +| ./target/debug/spacetimedb-mcp 2>/dev/null +``` + +The `tools/call` response should echo `pong: hi`. + +## Tools + +| Tool | Arguments | Description | +| --------------- | ---------- | -------------------------------------------------------------------- | +| `ping` | `message?` | Health check. Echoes an optional message back. | +| `get_schema` | `database` | Full module definition (typespace, tables, reducers) as JSON. | +| `list_tables` | `database` | Names of all tables in the database. | +| `list_reducers` | `database` | Reducers in the database, with each reducer's lifecycle role if any. | + +All introspection is read-only. Write operations, SQL, and subscriptions are +intentionally out of scope for now. diff --git a/crates/spacetimedb-mcp/src/introspect.rs b/crates/spacetimedb-mcp/src/introspect.rs new file mode 100644 index 00000000000..006cddb9296 --- /dev/null +++ b/crates/spacetimedb-mcp/src/introspect.rs @@ -0,0 +1,85 @@ +//! Pure transformations from a SpacetimeDB module definition into the shapes +//! the MCP tools return. Kept free of I/O so they can be unit-tested directly. + +use serde::Serialize; +use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; +use spacetimedb_lib::sats; + +/// Names of every table in the module, in declaration order. +pub fn table_names(def: &RawModuleDefV9) -> Vec { + def.tables.iter().map(|t| t.name.to_string()).collect() +} + +/// A reducer, reduced to the bits an agent cares about when browsing a module. +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ReducerSummary { + pub name: String, + /// The reducer's lifecycle role (`Init`, `OnConnect`, `OnDisconnect`), + /// or `None` for an ordinary reducer. + pub lifecycle: Option, +} + +/// Summarize every reducer in the module, in declaration order. +pub fn reducer_summaries(def: &RawModuleDefV9) -> Vec { + def.reducers + .iter() + .map(|r| ReducerSummary { + name: r.name.to_string(), + lifecycle: r.lifecycle.map(|l| format!("{l:?}")), + }) + .collect() +} + +/// Serialize the full module definition to pretty JSON, using SpacetimeDB's +/// own SATS serialization so the output matches `spacetime describe --json`. +pub fn schema_json(def: &RawModuleDefV9) -> serde_json::Result { + serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(def)) +} + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_lib::db::raw_def::v9::{Lifecycle, RawModuleDefV9Builder}; + use spacetimedb_lib::sats::{AlgebraicType, ProductType}; + + /// A small synthetic module: two tables, two reducers (one lifecycle). + fn sample() -> RawModuleDefV9 { + let mut b = RawModuleDefV9Builder::new(); + b.build_table_with_new_type_for_tests("widget", ProductType::from([("id", AlgebraicType::U64)]), false) + .finish(); + b.build_table_with_new_type_for_tests("gadget", ProductType::from([("name", AlgebraicType::String)]), false) + .finish(); + let no_params: [(&str, AlgebraicType); 0] = []; + b.add_reducer("init", ProductType::from(no_params), Some(Lifecycle::Init)); + b.add_reducer("do_thing", ProductType::from([("x", AlgebraicType::U64)]), None); + b.finish() + } + + #[test] + fn table_names_lists_every_table() { + let mut names = table_names(&sample()); + names.sort(); + assert_eq!(names, vec!["gadget".to_string(), "widget".to_string()]); + } + + #[test] + fn reducer_summaries_capture_name_and_lifecycle() { + let summaries = reducer_summaries(&sample()); + let init = summaries.iter().find(|s| s.name == "init").unwrap(); + assert_eq!(init.lifecycle.as_deref(), Some("Init")); + let ordinary = summaries.iter().find(|s| s.name == "do_thing").unwrap(); + assert_eq!(ordinary.lifecycle, None); + } + + #[test] + fn schema_json_round_trips_through_deserialize_wrapper() { + // The MCP client decodes the schema endpoint with `DeserializeWrapper`, + // while `schema_json` serializes with `SerdeWrapper`. They must be duals; + // this guards that contract so the live decode path can't silently break. + use spacetimedb_lib::de::serde::DeserializeWrapper; + let json = schema_json(&sample()).unwrap(); + let DeserializeWrapper(decoded): DeserializeWrapper = serde_json::from_str(&json).unwrap(); + assert_eq!(table_names(&decoded).len(), 2); + assert_eq!(decoded.reducers.len(), 2); + } +} diff --git a/crates/spacetimedb-mcp/src/main.rs b/crates/spacetimedb-mcp/src/main.rs new file mode 100644 index 00000000000..6771b9c8780 --- /dev/null +++ b/crates/spacetimedb-mcp/src/main.rs @@ -0,0 +1,127 @@ +//! MCP server for SpacetimeDB. +//! +//! Transport is stdio: the process is launched as a subprocess by an MCP client, +//! speaks JSON-RPC over stdin/stdout, and logs to stderr only. Nothing else may +//! touch stdout or the protocol stream is corrupted. + +mod introspect; +mod stdb; + +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ErrorData, ServerCapabilities, ServerInfo}, + schemars, tool, tool_handler, tool_router, + transport::stdio, + ServerHandler, ServiceExt, +}; +use serde::Deserialize; + +#[derive(Clone)] +struct SpacetimeDbMcp { + // Required by the `#[tool_router]`/`#[tool_handler]` macro pattern: the + // router is built in `new` and consumed by the generated handler. rustc's + // dead-code pass can't see the macro-internal use, hence the allow. + #[allow(dead_code)] + tool_router: ToolRouter, +} + +/// Surface an internal error to the MCP client as a tool error. +fn to_mcp_error(err: anyhow::Error) -> ErrorData { + ErrorData::internal_error(err.to_string(), None) +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct PingParams { + /// Optional message echoed back, to confirm round-trip works. + message: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct DatabaseParams { + /// Name or identity of the target database, as known to the SpacetimeDB host. + database: String, +} + +#[tool_router] +impl SpacetimeDbMcp { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Health check. Echoes back an optional message to confirm the server is alive.")] + async fn ping(&self, Parameters(p): Parameters) -> String { + match p.message { + Some(m) => format!("pong: {m}"), + None => "pong".to_string(), + } + } + + #[tool( + description = "Fetch the full schema (module definition) of a database as JSON: typespace, tables, and reducers." + )] + async fn get_schema(&self, Parameters(p): Parameters) -> Result { + let def = stdb::Client::from_env() + .module_def(&p.database) + .await + .map_err(to_mcp_error)?; + introspect::schema_json(&def).map_err(|e| to_mcp_error(e.into())) + } + + #[tool(description = "List the names of all tables defined in a database.")] + async fn list_tables(&self, Parameters(p): Parameters) -> Result { + let def = stdb::Client::from_env() + .module_def(&p.database) + .await + .map_err(to_mcp_error)?; + serde_json::to_string_pretty(&introspect::table_names(&def)).map_err(|e| to_mcp_error(e.into())) + } + + #[tool( + description = "List the reducers in a database, with each reducer's lifecycle role (init, on_connect, on_disconnect) when it has one." + )] + async fn list_reducers(&self, Parameters(p): Parameters) -> Result { + let def = stdb::Client::from_env() + .module_def(&p.database) + .await + .map_err(to_mcp_error)?; + serde_json::to_string_pretty(&introspect::reducer_summaries(&def)).map_err(|e| to_mcp_error(e.into())) + } +} + +#[tool_handler] +impl ServerHandler for SpacetimeDbMcp { + fn get_info(&self) -> ServerInfo { + // ServerInfo is #[non_exhaustive]; build from Default (which fills + // server_info name/version from this crate via from_build_env) and + // override only what we need. + let mut info = ServerInfo::default(); + // Default's from_build_env() reports rmcp's own name/version; override + // with ours so clients identify this server correctly. + info.server_info.name = env!("CARGO_PKG_NAME").into(); + info.server_info.version = env!("CARGO_PKG_VERSION").into(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.instructions = Some( + "MCP server for SpacetimeDB. Introspect a database's schema, tables, and reducers \ + against a running SpacetimeDB host (set SPACETIMEDB_HOST, and SPACETIMEDB_TOKEN \ + for private databases)." + .into(), + ); + info + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Logs MUST go to stderr; stdout is reserved for the JSON-RPC stream. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + tracing::info!("starting spacetimedb-mcp on stdio"); + let service = SpacetimeDbMcp::new().serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/spacetimedb-mcp/src/stdb.rs b/crates/spacetimedb-mcp/src/stdb.rs new file mode 100644 index 00000000000..7d592fefc2b --- /dev/null +++ b/crates/spacetimedb-mcp/src/stdb.rs @@ -0,0 +1,122 @@ +//! Minimal read-only HTTP client for a running SpacetimeDB instance. +//! +//! Talks to the same `/v1/database/{name_or_identity}/schema` endpoint the +//! `spacetime` CLI uses, and decodes the response into the in-tree +//! `RawModuleDefV9` type. Reusing SpacetimeDB's own schema representation +//! keeps this server in lockstep with the engine instead of reparsing text. + +use anyhow::{bail, Context}; +use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; +use spacetimedb_lib::de::serde::DeserializeWrapper; + +/// How to reach SpacetimeDB. The host (and optional auth token) are fixed for +/// the client's lifetime; the target database is supplied per request, so one +/// server can introspect any database on that host. +pub struct Client { + host: String, + token: Option, + http: reqwest::Client, +} + +impl Client { + /// Build a client for an explicit host and optional bearer token. + pub fn new(host: impl Into, token: Option) -> Self { + Self { + host: host.into(), + token, + http: reqwest::Client::new(), + } + } + + /// Build a client from the environment. + /// + /// `SPACETIMEDB_HOST` defaults to a local instance. `SPACETIMEDB_TOKEN`, + /// when set, is sent as a bearer token so private databases are reachable. + pub fn from_env() -> Self { + let host = std::env::var("SPACETIMEDB_HOST").unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()); + let token = std::env::var("SPACETIMEDB_TOKEN").ok().filter(|t| !t.is_empty()); + Self::new(host, token) + } + + /// Fetch and decode the module definition (schema) for `database`, which + /// may be either a database name or an identity. + pub async fn module_def(&self, database: &str) -> anyhow::Result { + let host = self.host.trim_end_matches('/'); + let url = format!("{host}/v1/database/{database}/schema"); + let mut req = self.http.get(&url).query(&[("version", "9")]); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + + let res = req + .send() + .await + .with_context(|| format!("could not reach SpacetimeDB at {host} (is the host running and reachable?)"))?; + + let status = res.status(); + if !status.is_success() { + if status == reqwest::StatusCode::NOT_FOUND { + bail!("database '{database}' not found at {host} (HTTP 404)"); + } + let body = res.text().await.unwrap_or_default(); + let detail = if body.is_empty() { + String::new() + } else { + format!(": {}", body.trim()) + }; + bail!("schema request for '{database}' failed with HTTP {status}{detail}"); + } + + let DeserializeWrapper(module_def) = res + .json::>() + .await + .context("decoding schema response (unexpected format from the host)")?; + Ok(module_def) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::introspect; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// Bind a throwaway HTTP server that answers one request with `body`, and + /// return its base URL. Lets us exercise the real fetch + decode path + /// without a live SpacetimeDB instance. + async fn serve_once(body: String) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut scratch = [0u8; 2048]; + let _ = sock.read(&mut scratch).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + sock.write_all(response.as_bytes()).await.unwrap(); + sock.flush().await.unwrap(); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn module_def_fetches_and_decodes() { + use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9Builder; + use spacetimedb_lib::sats::{AlgebraicType, ProductType}; + + // Serialize a schema the way the host would, then serve it back. + let mut b = RawModuleDefV9Builder::new(); + b.build_table_with_new_type_for_tests("widget", ProductType::from([("id", AlgebraicType::U64)]), false) + .finish(); + let def = b.finish(); + let body = introspect::schema_json(&def).unwrap(); + + let host = serve_once(body).await; + let fetched = Client::new(host, None).module_def("widget").await.unwrap(); + assert_eq!(introspect::table_names(&fetched), vec!["widget".to_string()]); + } +}