From bc2266bf9e381e9de0445dc2c290fd420885a3da Mon Sep 17 00:00:00 2001 From: fishnos Date: Mon, 15 Jun 2026 21:51:59 -0400 Subject: [PATCH 1/3] Add MCP server scaffold built on rmcp Set up the initial `spacetimedb-mcp` crate: a Model Context Protocol server on the official `rmcp` 1.7 SDK, communicating over stdio. The crate is registered as a workspace member, with `rmcp` and `schemars` added to the workspace dependencies. Includes a single `ping` health-check tool to confirm the end-to-end JSON-RPC flow (initialize, tools/list, tools/call) works. Logging is routed to stderr only, so it never corrupts the stdout protocol stream. SpacetimeDB-specific tools (schema and reducer introspection, SQL, subscriptions, and the CLI workflow) will follow in later commits. --- Cargo.lock | 124 +++++++++++++++++++++++++++-- Cargo.toml | 3 + crates/spacetimedb-mcp/Cargo.toml | 21 +++++ crates/spacetimedb-mcp/README.md | 61 ++++++++++++++ crates/spacetimedb-mcp/src/main.rs | 81 +++++++++++++++++++ 5 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 crates/spacetimedb-mcp/Cargo.toml create mode 100644 crates/spacetimedb-mcp/README.md create mode 100644 crates/spacetimedb-mcp/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index d9f5b5d97aa..ac6ec6d1869 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,20 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spacetimedb-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "rmcp", + "schemars 1.0.4", + "serde", + "serde_json", + "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..a08eeaeeeec --- /dev/null +++ b/crates/spacetimedb-mcp/Cargo.toml @@ -0,0 +1,21 @@ +[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"] } +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..560543733bb --- /dev/null +++ b/crates/spacetimedb-mcp/README.md @@ -0,0 +1,61 @@ +# 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 scaffold. Only a `ping` health-check tool exists today. +> SpacetimeDB-specific tools (read-only schema and reducer introspection first) +> are being added incrementally. + +## 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`. + +## Run + +Point an MCP client at the built binary. Example client config entry: + +```json +{ + "mcpServers": { + "spacetimedb": { + "command": "/path/to/target/debug/spacetimedb-mcp" + } + } +} +``` + +## 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 | Description | +| ------ | ---------------------------------------------- | +| `ping` | Health check. Echoes an optional message back. | + +More tools (schema, table, and reducer introspection) will be listed here as +they land. diff --git a/crates/spacetimedb-mcp/src/main.rs b/crates/spacetimedb-mcp/src/main.rs new file mode 100644 index 00000000000..73a5eb1309b --- /dev/null +++ b/crates/spacetimedb-mcp/src/main.rs @@ -0,0 +1,81 @@ +//! 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. + +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{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, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct PingParams { + /// Optional message echoed back, to confirm round-trip works. + message: Option, +} + +#[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_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 schema, tables, and reducers; \ + run SQL; call reducers; manage modules via the spacetime CLI." + .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(()) +} From 8b8d7d9f05d1aeaa7d340269c65aaf01c199fb10 Mon Sep 17 00:00:00 2001 From: fishnos Date: Tue, 16 Jun 2026 02:34:10 -0400 Subject: [PATCH 2/3] Add read-only schema introspection tools Add get_schema, list_tables, and list_reducers tools backed by a small HTTP client that queries a running SpacetimeDB host's schema endpoint (GET /v1/database/{name}/schema) and decodes it into the in-tree RawModuleDefV9 type, reusing the same mechanism as `spacetime describe`. The host and optional auth token are read from SPACETIMEDB_HOST and SPACETIMEDB_TOKEN; the target database is a per-call argument, so one server can introspect any database on the host. Write operations, SQL, and subscriptions remain out of scope. --- Cargo.lock | 2 + crates/spacetimedb-mcp/Cargo.toml | 2 + crates/spacetimedb-mcp/README.md | 33 +++++++++++----- crates/spacetimedb-mcp/src/main.rs | 63 ++++++++++++++++++++++++++++-- crates/spacetimedb-mcp/src/stdb.rs | 56 ++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 crates/spacetimedb-mcp/src/stdb.rs diff --git a/Cargo.lock b/Cargo.lock index ac6ec6d1869..6ace4612c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8448,10 +8448,12 @@ 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", diff --git a/crates/spacetimedb-mcp/Cargo.toml b/crates/spacetimedb-mcp/Cargo.toml index a08eeaeeeec..085b94d2d73 100644 --- a/crates/spacetimedb-mcp/Cargo.toml +++ b/crates/spacetimedb-mcp/Cargo.toml @@ -9,6 +9,8 @@ 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 diff --git a/crates/spacetimedb-mcp/README.md b/crates/spacetimedb-mcp/README.md index 560543733bb..4e43bd2f0d7 100644 --- a/crates/spacetimedb-mcp/README.md +++ b/crates/spacetimedb-mcp/README.md @@ -4,9 +4,9 @@ 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 scaffold. Only a `ping` health-check tool exists today. -> SpacetimeDB-specific tools (read-only schema and reducer introspection first) -> are being added incrementally. +> 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 @@ -25,13 +25,23 @@ The binary lands at `target/debug/spacetimedb-mcp`. ## Run -Point an MCP client at the built binary. Example client config entry: +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" + "command": "/path/to/target/debug/spacetimedb-mcp", + "env": { "SPACETIMEDB_HOST": "http://127.0.0.1:3000" } } } } @@ -53,9 +63,12 @@ The `tools/call` response should echo `pong: hi`. ## Tools -| Tool | Description | -| ------ | ---------------------------------------------- | -| `ping` | Health check. Echoes an optional message back. | +| 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. | -More tools (schema, table, and reducer introspection) will be listed here as -they land. +All introspection is read-only. Write operations, SQL, and subscriptions are +intentionally out of scope for now. diff --git a/crates/spacetimedb-mcp/src/main.rs b/crates/spacetimedb-mcp/src/main.rs index 73a5eb1309b..22da8fcba4a 100644 --- a/crates/spacetimedb-mcp/src/main.rs +++ b/crates/spacetimedb-mcp/src/main.rs @@ -4,14 +4,17 @@ //! speaks JSON-RPC over stdin/stdout, and logs to stderr only. Nothing else may //! touch stdout or the protocol stream is corrupted. +mod stdb; + use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ServerCapabilities, ServerInfo}, + model::{ErrorData, ServerCapabilities, ServerInfo}, schemars, tool, tool_handler, tool_router, transport::stdio, ServerHandler, ServiceExt, }; use serde::Deserialize; +use spacetimedb_lib::sats; #[derive(Clone)] struct SpacetimeDbMcp { @@ -22,12 +25,23 @@ struct SpacetimeDbMcp { 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 { @@ -43,6 +57,48 @@ impl SpacetimeDbMcp { 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)?; + serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(&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)?; + let names: Vec = def.tables.iter().map(|t| t.name.to_string()).collect(); + serde_json::to_string_pretty(&names).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)?; + let reducers: Vec = def + .reducers + .iter() + .map(|r| { + serde_json::json!({ + "name": r.name.to_string(), + "lifecycle": r.lifecycle.map(|l| format!("{l:?}")), + }) + }) + .collect(); + serde_json::to_string_pretty(&reducers).map_err(|e| to_mcp_error(e.into())) + } } #[tool_handler] @@ -58,8 +114,9 @@ impl ServerHandler for SpacetimeDbMcp { info.server_info.version = env!("CARGO_PKG_VERSION").into(); info.capabilities = ServerCapabilities::builder().enable_tools().build(); info.instructions = Some( - "MCP server for SpacetimeDB. Introspect schema, tables, and reducers; \ - run SQL; call reducers; manage modules via the spacetime CLI." + "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 diff --git a/crates/spacetimedb-mcp/src/stdb.rs b/crates/spacetimedb-mcp/src/stdb.rs new file mode 100644 index 00000000000..403ba4091b0 --- /dev/null +++ b/crates/spacetimedb-mcp/src/stdb.rs @@ -0,0 +1,56 @@ +//! 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::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) come from the +/// environment; 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 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 { + host, + token, + http: reqwest::Client::new(), + } + } + + /// 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 url = format!("{}/v1/database/{}/schema", self.host.trim_end_matches('/'), database); + 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!("requesting schema from {url}"))? + .error_for_status() + .with_context(|| format!("schema request to {url} failed"))?; + let DeserializeWrapper(module_def) = res + .json::>() + .await + .context("decoding schema response")?; + Ok(module_def) + } +} From ab09760d68a38e4023b4629c087a2e04e15e9b74 Mon Sep 17 00:00:00 2001 From: fishnos Date: Wed, 17 Jun 2026 15:50:53 -0400 Subject: [PATCH 3/3] Make schema introspection testable and harden error messages Split the schema-to-output shaping into pure functions (introspect module) so it can be unit-tested without a server, and add a Client::new constructor so the HTTP client can target an arbitrary host. Add tests: unit tests for table/reducer shaping and the SerdeWrapper / DeserializeWrapper round trip the client depends on, plus an integration test that serves a canned schema over a throwaway HTTP server and exercises the full fetch-and-decode path (no running instance required). Improve error messages for unreachable hosts, missing databases (404), and non-success responses. --- crates/spacetimedb-mcp/README.md | 11 +++ crates/spacetimedb-mcp/src/introspect.rs | 85 +++++++++++++++++++++ crates/spacetimedb-mcp/src/main.rs | 19 +---- crates/spacetimedb-mcp/src/stdb.rs | 94 ++++++++++++++++++++---- 4 files changed, 180 insertions(+), 29 deletions(-) create mode 100644 crates/spacetimedb-mcp/src/introspect.rs diff --git a/crates/spacetimedb-mcp/README.md b/crates/spacetimedb-mcp/README.md index 4e43bd2f0d7..878acd01389 100644 --- a/crates/spacetimedb-mcp/README.md +++ b/crates/spacetimedb-mcp/README.md @@ -23,6 +23,17 @@ 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 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 index 22da8fcba4a..6771b9c8780 100644 --- a/crates/spacetimedb-mcp/src/main.rs +++ b/crates/spacetimedb-mcp/src/main.rs @@ -4,6 +4,7 @@ //! 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::{ @@ -14,7 +15,6 @@ use rmcp::{ ServerHandler, ServiceExt, }; use serde::Deserialize; -use spacetimedb_lib::sats; #[derive(Clone)] struct SpacetimeDbMcp { @@ -66,7 +66,7 @@ impl SpacetimeDbMcp { .module_def(&p.database) .await .map_err(to_mcp_error)?; - serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(&def)).map_err(|e| to_mcp_error(e.into())) + introspect::schema_json(&def).map_err(|e| to_mcp_error(e.into())) } #[tool(description = "List the names of all tables defined in a database.")] @@ -75,8 +75,7 @@ impl SpacetimeDbMcp { .module_def(&p.database) .await .map_err(to_mcp_error)?; - let names: Vec = def.tables.iter().map(|t| t.name.to_string()).collect(); - serde_json::to_string_pretty(&names).map_err(|e| to_mcp_error(e.into())) + serde_json::to_string_pretty(&introspect::table_names(&def)).map_err(|e| to_mcp_error(e.into())) } #[tool( @@ -87,17 +86,7 @@ impl SpacetimeDbMcp { .module_def(&p.database) .await .map_err(to_mcp_error)?; - let reducers: Vec = def - .reducers - .iter() - .map(|r| { - serde_json::json!({ - "name": r.name.to_string(), - "lifecycle": r.lifecycle.map(|l| format!("{l:?}")), - }) - }) - .collect(); - serde_json::to_string_pretty(&reducers).map_err(|e| to_mcp_error(e.into())) + serde_json::to_string_pretty(&introspect::reducer_summaries(&def)).map_err(|e| to_mcp_error(e.into())) } } diff --git a/crates/spacetimedb-mcp/src/stdb.rs b/crates/spacetimedb-mcp/src/stdb.rs index 403ba4091b0..7d592fefc2b 100644 --- a/crates/spacetimedb-mcp/src/stdb.rs +++ b/crates/spacetimedb-mcp/src/stdb.rs @@ -5,13 +5,13 @@ //! `RawModuleDefV9` type. Reusing SpacetimeDB's own schema representation //! keeps this server in lockstep with the engine instead of reparsing text. -use anyhow::Context; +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) come from the -/// environment; the target database is supplied per request, so one server can -/// introspect any database on that host. +/// 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, @@ -19,6 +19,15 @@ pub struct 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`, @@ -26,31 +35,88 @@ impl Client { 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 { - host, - token, - http: reqwest::Client::new(), - } + 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 url = format!("{}/v1/database/{}/schema", self.host.trim_end_matches('/'), database); + 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!("requesting schema from {url}"))? - .error_for_status() - .with_context(|| format!("schema request to {url} failed"))?; + .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")?; + .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()]); + } +}