diff --git a/.cargo/config.toml b/.cargo/config.toml index 753ee7dd2..4808933e8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -14,3 +14,8 @@ [env] TS_RS_EXPORT_DIR = { value = "./crates/assets/js/bindings", relative = true } +LIBCLANG_PATH = { value = "./.dev-tools/libclang-18/usr/lib/llvm-18/lib", relative = true } +CLANG_PATH = { value = "./.dev-tools/libclang-18/usr/bin/clang-18", relative = true } +PKG_CONFIG_PATH = { value = "./.dev-tools/geos/usr/lib/x86_64-linux-gnu/pkgconfig", relative = true } +PKG_CONFIG_SYSROOT_DIR = { value = "./.dev-tools/geos", relative = true } +PROTOC = { value = "./.cargo/protoc-wrapper.sh", relative = true } diff --git a/.cargo/protoc-wrapper.sh b/.cargo/protoc-wrapper.sh new file mode 100755 index 000000000..49652bdb4 --- /dev/null +++ b/.cargo/protoc-wrapper.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +PROTOBUF_DIR="${REPO_ROOT}/.dev-tools/protobuf" + +export LD_LIBRARY_PATH="${PROTOBUF_DIR}/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" +exec "${PROTOBUF_DIR}/usr/bin/protoc" \ + -I"${PROTOBUF_DIR}/usr/include" \ + "$@" diff --git a/.gitignore b/.gitignore index f7c55395a..f6e6f85de 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ target/ node_modules/ dist/ +.dev-tools/ +.venv*/ +.python-*/ +__pycache__/ +.pytest_cache/ # Dart workspace artifacts .dart_tool diff --git a/Cargo.lock b/Cargo.lock index eaf3d2e30..e5e392feb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1887,6 +1887,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core 0.24.0", + "darling_macro 0.24.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1914,6 +1924,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1936,6 +1959,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core 0.24.0", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -5093,6 +5127,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" @@ -6399,6 +6439,50 @@ dependencies = [ "syn 2.0.119", ] +[[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", + "pin-project-lite", + "rand 0.10.2", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521" +dependencies = [ + "darling 0.24.0", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rquickjs" version = "0.12.2" @@ -6812,6 +6896,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", @@ -7345,6 +7430,19 @@ dependencies = [ "recursive", ] +[[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" @@ -8228,6 +8326,7 @@ dependencies = [ "rcgen", "regex", "reqwest 0.13.4", + "rmcp", "rusqlite", "rust-embed", "schemars", diff --git a/Cargo.toml b/Cargo.toml index f8ed2a157..0d7fbdaba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ minijinja = { version = "2.1.2", default-features = false } parking_lot = { version = "0.12.3", default-features = false, features = ["send_guard", "arc_lock"] } rand = "^0.10.0" reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "json"] } +rmcp = { version = "3.1.2", features = ["transport-streamable-http-server"] } rusqlite = { version = "0.40.0", default-features = false, features = ["bundled", "cache", "column_decltype", "functions", "backup", "preupdate_hook"] } rust-embed = { version = "8.4.0", default-features = false, features = ["mime-guess"] } serde = { version = "^1.0.203", features = ["derive", "rc"] } diff --git a/README.md b/README.md index f23eadeb9..71e74acd1 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,20 @@ trail components add trailbase/auth_ui endpoints, e.g. [http://localhost:4000/\_/auth/login](http://localhost:4000/_/auth/login). +## MCP + +This fork includes an optional native MCP server in the main TrailBase binary. +It uses TrailBase administrator login through OAuth and runs on the same port as +TrailBase—no sidecar container or copied bearer token is required. + +```sh +trail --public-url https://trailbase.example.com run --mcp +``` + +Connect an OAuth-capable MCP client to +`https://trailbase.example.com/mcp`. See the [MCP guide](mcp/README.md) for IDE, +Docker, Portainer, reverse-proxy, authentication, and security configuration. + ## Building If you have all the necessary build dependencies (Rust, node.js, geos, @@ -162,6 +176,15 @@ pnpm install cargo build --bin trail ``` +On Debian/Ubuntu-style systems without sudo access to install build packages, +this fork can populate the repo-local `.dev-tools/` cache used by +`.cargo/config.toml`: + +```sh +scripts/bootstrap-local-dev-tools.sh +cargo check --workspace --all-targets +``` + Alternatively, if you want to build a Docker image or don't want to deal with build dependencies, you can simply run: diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 2a8c3e47a..3213e5faa 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -131,6 +131,10 @@ pub struct ServerArgs { #[arg(long)] pub demo: bool, + /// Enable the authenticated MCP endpoint at /mcp on the admin server. + #[arg(long, env, default_value_t = false)] + pub mcp: bool, + #[arg(long, default_value_t = false)] pub stderr_logging: bool, diff --git a/crates/cli/src/bin/trail.rs b/crates/cli/src/bin/trail.rs index 046b34d5c..b78a749d5 100644 --- a/crates/cli/src/bin/trail.rs +++ b/crates/cli/src/bin/trail.rs @@ -93,6 +93,7 @@ async fn async_main( tls_key: None, tls_cert: None, custom_router: None, + enable_mcp: cmd.mcp, }, ) .await?; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 008285630..4ff91cffc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -81,6 +81,7 @@ quick_cache = "0.7.0" rand = { workspace = true } regex = "1.11.0" reqwest = { workspace = true } +rmcp = { workspace = true } rusqlite = { workspace = true } rust-embed = { workspace = true } serde = { workspace = true } diff --git a/crates/core/src/auth/api/login.rs b/crates/core/src/auth/api/login.rs index 4f757c1fa..a9053d0df 100644 --- a/crates/core/src/auth/api/login.rs +++ b/crates/core/src/auth/api/login.rs @@ -356,7 +356,7 @@ pub(crate) async fn build_auth_token_flow_response( /// /// An example using the two-step "authentication code flow" with PKCE can be found in /// `/examples/blog/flutter`. -async fn build_authorization_code_flow_and_pkce_response( +pub(crate) async fn build_authorization_code_flow_and_pkce_response( state: &AppState, db_user: &DbUser, redirect: String, diff --git a/crates/core/src/auth/api/mod.rs b/crates/core/src/auth/api/mod.rs index 67a5031c6..868109e72 100644 --- a/crates/core/src/auth/api/mod.rs +++ b/crates/core/src/auth/api/mod.rs @@ -3,15 +3,15 @@ pub(super) mod change_email; pub(super) mod change_password; pub(super) mod change_username; pub(super) mod delete; -pub(super) mod login; +pub(crate) mod login; pub(super) mod login_anonymous; pub(super) mod logout; pub(super) mod otp; pub(super) mod promote_anonymous; -pub(super) mod refresh; +pub(crate) mod refresh; pub(super) mod register; pub(super) mod reset_password; pub(super) mod status; -pub(super) mod token; +pub(crate) mod token; pub(super) mod totp; pub(super) mod verify_email; diff --git a/crates/core/src/auth/jwt.rs b/crates/core/src/auth/jwt.rs index 8fca78636..c7c610ff4 100644 --- a/crates/core/src/auth/jwt.rs +++ b/crates/core/src/auth/jwt.rs @@ -311,6 +311,17 @@ impl JwtHelper { .map(|data| data.claims); } + pub(crate) fn decode_with_audience( + &self, + token: &str, + audience: &str, + ) -> Result { + let mut validation = self.validation.clone(); + validation.set_audience(&[audience]); + return jsonwebtoken::decode::(token, &self.decoding_key, &validation) + .map(|data| data.claims); + } + pub fn encode(&self, claims: &T) -> Result { return jsonwebtoken::encode::(&self.header, claims, &self.encoding_key); } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0773d8f53..5242c3745 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -23,6 +23,7 @@ mod encryption; mod extract; mod init_error; mod listing; +mod mcp; mod migrations; mod scheduler; mod schema_metadata; diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs new file mode 100644 index 000000000..55143ef19 --- /dev/null +++ b/crates/core/src/mcp.rs @@ -0,0 +1,907 @@ +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::extract::{Form, Json as AxumJson, Path, Query, Request, State}; +use axum::http::{HeaderValue, Method, StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use http_body_util::BodyExt; +use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; +use rmcp::model::{ErrorData as McpError, Implementation, ServerCapabilities, ServerInfo}; +use rmcp::transport::{ + StreamableHttpServerConfig, + streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, +}; +use rmcp::{Json, ServerHandler, schemars, tool, tool_handler, tool_router}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::admin; +use crate::app_state::AppState; +use crate::auth::util::is_admin; +use crate::auth::{AuthError, AuthTokenClaims, User}; + +const MCP_SCOPE: &str = "mcp"; +const MCP_PATH: &str = "/mcp"; + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct AdminRequest { + /// HTTP method accepted by the TrailBase admin API. + method: String, + /// Admin API path relative to /api/_admin, including an optional query string. + path: String, + /// Optional JSON request body. + #[serde(default)] + body: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SqlRequest { + /// One or more SQLite statements. Schema-changing statements refresh TrailBase metadata. + query: String, + /// Optional configured attached database names. + #[serde(default)] + attached_databases: Option>, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ConfigUpdateRequest { + /// Complete TrailBase config in protobuf text format, as returned by get_config. + config: String, +} + +#[derive(Clone)] +struct TrailBaseMcp { + state: AppState, + #[allow(dead_code)] + tool_router: ToolRouter, +} + +impl TrailBaseMcp { + fn new(state: AppState) -> Self { + Self { + state, + tool_router: Self::tool_router(), + } + } + + async fn dispatch_admin(&self, request: AdminRequest) -> Result, McpError> { + let method = Method::from_bytes(request.method.as_bytes()) + .map_err(|_| McpError::invalid_params("invalid HTTP method", None))?; + let path = normalize_admin_path(&request.path)?; + let body = request + .body + .map(|value| serde_json::to_vec(&value)) + .transpose() + .map_err(|err| McpError::invalid_params(err.to_string(), None))? + .unwrap_or_default(); + + let request = Request::builder() + .method(method) + .uri(path) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + + let response = admin::router() + .with_state(self.state.clone()) + .oneshot(request) + .await + .map_err(|never| match never {})?; + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .map_err(|err| McpError::internal_error(err.to_string(), None))? + .to_bytes(); + + let response_body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned())) + }; + + if !status.is_success() { + return Err(McpError::internal_error( + format!("TrailBase admin API returned {status}: {response_body}"), + Some(json!({ "status": status.as_u16(), "body": response_body })), + )); + } + + Ok(Json(json!({ + "status": status.as_u16(), + "body": response_body + }))) + } +} + +#[tool_router] +impl TrailBaseMcp { + #[tool( + description = "Call a TrailBase admin API in-process. Paths are relative to /api/_admin. This exposes the same table, index, row, config, schema, query, user, log, backup, job, and WASM operations as the admin dashboard." + )] + async fn call_admin_api( + &self, + Parameters(request): Parameters, + ) -> Result, McpError> { + self.dispatch_admin(request).await + } + + #[tool(description = "List TrailBase tables, views, columns, indexes, triggers, and metadata.")] + async fn list_tables(&self) -> Result, McpError> { + self + .dispatch_admin(AdminRequest { + method: "GET".to_string(), + path: "tables".to_string(), + body: None, + }) + .await + } + + #[tool( + description = "Execute SQL using TrailBase's admin query handler. Supports reads and writes; schema changes refresh cached metadata." + )] + async fn execute_sql( + &self, + Parameters(request): Parameters, + ) -> Result, McpError> { + self + .dispatch_admin(AdminRequest { + method: "POST".to_string(), + path: "query".to_string(), + body: Some(json!({ + "query": request.query, + "attached_databases": request.attached_databases, + })), + }) + .await + } + + #[tool( + description = "Get the complete TrailBase configuration as protobuf text. Secret values are redacted." + )] + fn get_config(&self) -> Result { + let (config, _) = crate::config::redact_secrets(&self.state.get_config()) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + config + .to_text() + .map_err(|err| McpError::internal_error(err.to_string(), None)) + } + + #[tool( + description = "Validate and replace the TrailBase configuration using protobuf text from get_config. Existing secret values are preserved." + )] + async fn update_config( + &self, + Parameters(request): Parameters, + ) -> Result { + if self.state.demo_mode() { + return Err(McpError::invalid_request( + "config updates are disabled in demo mode", + None, + )); + } + + let config = crate::config::proto::Config::from_text(&request.config) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?; + let current = self.state.get_config(); + let hash = crate::config::proto::hash_config(¤t); + let (_, secrets) = crate::config::redact_secrets(¤t) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + let config = + crate::config::merge_vault_and_env(config, crate::config::proto::Vault { secrets }) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?; + self + .state + .validate_and_update_config(config, Some(hash)) + .await + .map_err(|err| McpError::invalid_params(err.to_string(), None))?; + Ok("Config updated".to_string()) + } +} + +#[tool_handler] +impl ServerHandler for TrailBaseMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info( + Implementation::new("trailbase", env!("CARGO_PKG_VERSION")) + .with_title("TrailBase MCP") + .with_description("Native administrative MCP server for TrailBase") + .with_website_url("https://trailbase.io"), + ) + .with_instructions( + "TrailBase's native administrative MCP server. Use call_admin_api to perform the same operations as the admin dashboard. Destructive operations modify the active TrailBase depot.", + ) + } +} + +pub(crate) fn router(state: &AppState) -> Router { + let state_for_service = state.clone(); + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(TrailBaseMcp::new(state_for_service.clone())), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default() + .with_json_response(true) + // TrailBase supports operator-configured reverse proxies. Admin authentication below is + // the security boundary, so the MCP transport must accept the proxy's Host header. + .disable_allowed_hosts(), + ); + + let protected_mcp = Router::new() + .nest_service("/mcp", service) + // Keep MCP authentication scoped to the MCP route. A normal layer also wraps this + // router's fallback and would intercept auth UI routes after this router is merged. + .route_layer(middleware::from_fn_with_state( + state.clone(), + assert_mcp_access, + )); + + Router::new() + .merge(protected_mcp) + .route( + "/.well-known/oauth-protected-resource", + get(protected_resource_metadata), + ) + .route( + "/.well-known/oauth-protected-resource/mcp", + get(protected_resource_metadata), + ) + .route( + "/.well-known/oauth-authorization-server", + get(authorization_server_metadata), + ) + .route("/_/mcp/authorize", get(authorize)) + .route("/_/mcp/callback/{flow}", get(authorization_callback)) + .route("/_/mcp/register", post(register_client)) + .route("/_/mcp/token", post(oauth_token)) +} + +async fn assert_mcp_access( + State(state): State, + request: Request, + next: Next, +) -> Response { + let user_id = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .and_then(|token| mcp_user_id(&state, token)); + let authorized = match user_id { + Some(user_id) => is_admin(&state, &user_id).await, + None => false, + }; + if authorized { + return next.run(request).await; + } + + let metadata_url = external_url(&state, "/.well-known/oauth-protected-resource"); + let mut response = AuthError::Unauthorized.into_response(); + if let Ok(value) = HeaderValue::from_str(&format!( + "Bearer resource_metadata=\"{metadata_url}\", scope=\"{MCP_SCOPE}\"" + )) { + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, value); + } + response +} + +#[derive(Clone, Serialize, Deserialize)] +struct McpAccessTokenClaims { + #[serde(flatten)] + auth: AuthTokenClaims, + aud: String, + scope: String, +} + +#[derive(Clone, Deserialize)] +struct CompatibilityAccessTokenClaims { + #[serde(flatten)] + auth: AuthTokenClaims, + #[serde(default)] + aud: Option, +} + +fn mcp_user_id(state: &AppState, token: &str) -> Option { + let resource = external_url(state, MCP_PATH); + if let Ok(claims) = state + .jwt() + .decode_with_audience::(token, &resource) + { + if !claims.scope.split(' ').any(|scope| scope == MCP_SCOPE) { + return None; + } + return crate::util::b64_to_uuid(&claims.auth.sub).ok(); + } + + // Compatibility mode for callers that explicitly supply a normal TrailBase admin token. + let claims = state + .jwt() + .decode::(token) + .ok()?; + if claims.aud.is_some() { + return None; + } + crate::util::b64_to_uuid(&claims.auth.sub).ok() +} + +#[derive(Serialize)] +struct ProtectedResourceMetadata { + resource: String, + authorization_servers: Vec, + scopes_supported: Vec<&'static str>, + bearer_methods_supported: Vec<&'static str>, +} + +async fn protected_resource_metadata( + State(state): State, +) -> AxumJson { + let issuer = external_url(&state, ""); + AxumJson(ProtectedResourceMetadata { + resource: external_url(&state, MCP_PATH), + authorization_servers: vec![issuer], + scopes_supported: vec![MCP_SCOPE], + bearer_methods_supported: vec!["header"], + }) +} + +#[derive(Serialize)] +struct AuthorizationServerMetadata { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + registration_endpoint: String, + response_types_supported: Vec<&'static str>, + grant_types_supported: Vec<&'static str>, + code_challenge_methods_supported: Vec<&'static str>, + token_endpoint_auth_methods_supported: Vec<&'static str>, + scopes_supported: Vec<&'static str>, +} + +async fn authorization_server_metadata( + State(state): State, +) -> AxumJson { + AxumJson(AuthorizationServerMetadata { + issuer: external_url(&state, ""), + authorization_endpoint: external_url(&state, "/_/mcp/authorize"), + token_endpoint: external_url(&state, "/_/mcp/token"), + registration_endpoint: external_url(&state, "/_/mcp/register"), + response_types_supported: vec!["code"], + grant_types_supported: vec!["authorization_code", "refresh_token"], + code_challenge_methods_supported: vec!["S256"], + token_endpoint_auth_methods_supported: vec!["none"], + scopes_supported: vec![MCP_SCOPE], + }) +} + +#[derive(Debug, Deserialize, Serialize)] +struct ClientRegistration { + #[serde(default)] + redirect_uris: Vec, + #[serde(default)] + client_name: Option, + #[serde(default)] + scope: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ClientClaims { + exp: i64, + iat: i64, + redirect_uris: Vec, + client_name: Option, +} + +#[derive(Serialize)] +struct ClientRegistrationResponse { + client_id: String, + client_id_issued_at: i64, + redirect_uris: Vec, + client_name: Option, + token_endpoint_auth_method: &'static str, + grant_types: Vec<&'static str>, + response_types: Vec<&'static str>, + scope: &'static str, +} + +async fn register_client( + State(state): State, + AxumJson(request): AxumJson, +) -> Result, OAuthError> { + if request.redirect_uris.is_empty() + || request + .redirect_uris + .iter() + .any(|uri| !valid_client_redirect(uri)) + || request + .scope + .as_deref() + .is_some_and(|scope| !scope.split(' ').any(|value| value == MCP_SCOPE)) + { + return Err(OAuthError::invalid_client_metadata("invalid redirect_uris")); + } + + let now = chrono::Utc::now().timestamp(); + let claims = ClientClaims { + iat: now, + exp: now + chrono::Duration::days(30).num_seconds(), + redirect_uris: request.redirect_uris.clone(), + client_name: request.client_name.clone(), + }; + let client_id = state + .jwt() + .encode(&claims) + .map_err(|err| OAuthError::server(err.to_string()))?; + + Ok(AxumJson(ClientRegistrationResponse { + client_id, + client_id_issued_at: now, + redirect_uris: request.redirect_uris, + client_name: request.client_name, + token_endpoint_auth_method: "none", + grant_types: vec!["authorization_code", "refresh_token"], + response_types: vec!["code"], + scope: MCP_SCOPE, + })) +} + +#[derive(Deserialize)] +struct AuthorizeQuery { + client_id: String, + redirect_uri: String, + response_type: String, + code_challenge: String, + code_challenge_method: String, + state: Option, + scope: Option, + resource: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +struct FlowClaims { + exp: i64, + redirect_uri: String, + client_id: String, + state: Option, +} + +async fn authorize( + State(state): State, + user: Option, + Query(query): Query, +) -> Result { + let client: ClientClaims = state + .jwt() + .decode(&query.client_id) + .map_err(|_| OAuthError::invalid_request("unknown or expired client_id"))?; + if query.response_type != "code" + || query.code_challenge_method != "S256" + || !client.redirect_uris.contains(&query.redirect_uri) + || query + .scope + .as_deref() + .is_some_and(|scope| !scope.split(' ').any(|s| s == MCP_SCOPE)) + || query + .resource + .as_deref() + .is_some_and(|resource| resource != external_url(&state, MCP_PATH)) + { + return Err(OAuthError::invalid_request("invalid authorization request")); + } + + let flow = state + .jwt() + .encode(&FlowClaims { + exp: chrono::Utc::now().timestamp() + chrono::Duration::minutes(10).num_seconds(), + redirect_uri: query.redirect_uri, + client_id: query.client_id, + state: query.state, + }) + .map_err(|err| OAuthError::server(err.to_string()))?; + let callback = format!("/_/mcp/callback/{flow}"); + let login_query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("redirect_uri", &callback) + .append_pair("response_type", "code") + .append_pair("pkce_code_challenge", &query.code_challenge) + .finish(); + + if let Some(user) = user { + if !is_admin(&state, &user.uuid).await { + return Err(OAuthError::access_denied( + "MCP access requires an administrator", + )); + } + let db_user = crate::auth::util::user_by_id(&state, &user.uuid) + .await + .map_err(OAuthError::from_auth)?; + return crate::auth::api::login::build_authorization_code_flow_and_pkce_response( + &state, + &db_user, + callback, + query.code_challenge, + ) + .await + .map_err(OAuthError::from_auth); + } + + Ok(Redirect::to(&format!("/_/auth/login?{login_query}")).into_response()) +} + +#[derive(Deserialize)] +struct CallbackQuery { + code: String, +} + +async fn authorization_callback( + State(state): State, + Path(flow): Path, + Query(query): Query, +) -> Result { + let flow: FlowClaims = state + .jwt() + .decode(&flow) + .map_err(|_| OAuthError::invalid_request("unknown or expired authorization flow"))?; + let mut redirect = url::Url::parse(&flow.redirect_uri) + .map_err(|_| OAuthError::invalid_request("invalid redirect_uri"))?; + redirect.query_pairs_mut().append_pair("code", &query.code); + if let Some(state) = flow.state { + redirect.query_pairs_mut().append_pair("state", &state); + } + Ok(Redirect::to(redirect.as_str())) +} + +#[derive(Deserialize)] +struct TokenRequest { + grant_type: String, + code: Option, + code_verifier: Option, + refresh_token: Option, + client_id: Option, + redirect_uri: Option, + resource: Option, +} + +#[derive(Serialize)] +struct TokenResponse { + access_token: String, + token_type: &'static str, + expires_in: i64, + refresh_token: Option, + scope: &'static str, +} + +async fn oauth_token( + State(state): State, + Form(request): Form, +) -> Result, OAuthError> { + if request + .resource + .as_deref() + .is_some_and(|resource| resource != external_url(&state, MCP_PATH)) + { + return Err(OAuthError::invalid_grant("invalid resource")); + } + + let client_id = request + .client_id + .as_deref() + .ok_or_else(|| OAuthError::invalid_grant("missing client_id"))?; + let client: ClientClaims = state + .jwt() + .decode(client_id) + .map_err(|_| OAuthError::invalid_grant("unknown or expired client_id"))?; + + let (access_token, refresh_token) = match request.grant_type.as_str() { + "authorization_code" => { + let redirect_uri = request + .redirect_uri + .as_deref() + .ok_or_else(|| OAuthError::invalid_grant("missing redirect_uri"))?; + if !client.redirect_uris.iter().any(|uri| uri == redirect_uri) { + return Err(OAuthError::invalid_grant( + "redirect_uri does not match client", + )); + } + let code = request + .code + .ok_or_else(|| OAuthError::invalid_grant("missing code"))?; + let verifier = request + .code_verifier + .ok_or_else(|| OAuthError::invalid_grant("missing code_verifier"))?; + let AxumJson(tokens) = crate::auth::api::token::auth_code_to_token_handler( + State(state.clone()), + AxumJson(crate::auth::api::token::AuthCodeToTokenRequest { + authorization_code: Some(code), + pkce_code_verifier: Some(verifier), + }), + ) + .await + .map_err(OAuthError::from_auth)?; + (tokens.auth_token, Some(tokens.refresh_token)) + } + "refresh_token" => { + let refresh_token = request + .refresh_token + .ok_or_else(|| OAuthError::invalid_grant("missing refresh_token"))?; + let AxumJson(tokens) = crate::auth::api::refresh::refresh_handler( + State(state.clone()), + AxumJson(crate::auth::api::refresh::RefreshRequest { refresh_token }), + ) + .await + .map_err(OAuthError::from_auth)?; + (tokens.auth_token, None) + } + _ => return Err(OAuthError::invalid_grant("unsupported grant_type")), + }; + let claims = AuthTokenClaims::from_auth_token(state.jwt(), &access_token) + .map_err(|_| OAuthError::server("failed to decode issued access token"))?; + let expires_in = (claims.exp - chrono::Utc::now().timestamp()).max(0); + let access_token = state + .jwt() + .encode(&McpAccessTokenClaims { + auth: claims, + aud: external_url(&state, MCP_PATH), + scope: MCP_SCOPE.to_string(), + }) + .map_err(|err| OAuthError::server(err.to_string()))?; + + Ok(AxumJson(TokenResponse { + access_token, + token_type: "Bearer", + expires_in, + refresh_token, + scope: MCP_SCOPE, + })) +} + +#[derive(Debug)] +struct OAuthError { + status: StatusCode, + code: &'static str, + description: String, +} + +impl OAuthError { + fn invalid_request(description: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_request", + description: description.into(), + } + } + + fn invalid_client_metadata(description: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_client_metadata", + description: description.into(), + } + } + + fn invalid_grant(description: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_grant", + description: description.into(), + } + } + + fn access_denied(description: impl Into) -> Self { + Self { + status: StatusCode::FORBIDDEN, + code: "access_denied", + description: description.into(), + } + } + + fn server(description: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "server_error", + description: description.into(), + } + } + + fn from_auth(error: AuthError) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_grant", + description: error.to_string(), + } + } +} + +impl IntoResponse for OAuthError { + fn into_response(self) -> Response { + ( + self.status, + AxumJson(json!({ "error": self.code, "error_description": self.description })), + ) + .into_response() + } +} + +fn valid_client_redirect(uri: &str) -> bool { + let Ok(uri) = url::Url::parse(uri) else { + return false; + }; + uri.scheme() == "https" + || (uri.scheme() == "http" && matches!(uri.host_str(), Some("localhost" | "127.0.0.1" | "::1"))) +} + +fn external_url(state: &AppState, path: &str) -> String { + let mut base = state + .site_url() + .as_ref() + .clone() + .unwrap_or_else(|| url::Url::parse("http://localhost:4000").expect("constant URL")); + base.set_path(path); + base.set_query(None); + base.set_fragment(None); + base.to_string().trim_end_matches('/').to_string() +} + +fn normalize_admin_path(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() || path.contains("://") || path.starts_with("//") { + return Err(McpError::invalid_params("invalid admin API path", None)); + } + + let path = path + .strip_prefix("/api/_admin") + .unwrap_or(path) + .trim_start_matches('/'); + Ok(format!("/{path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_state::test_state; + + #[test] + fn normalizes_admin_paths() { + assert_eq!(normalize_admin_path("tables").unwrap(), "/tables"); + assert_eq!( + normalize_admin_path("/api/_admin/logs/list?limit=5").unwrap(), + "/logs/list?limit=5" + ); + assert!(normalize_admin_path("https://example.com").is_err()); + } + + #[tokio::test] + async fn registers_client_and_builds_pkce_login_redirect() { + let state = test_state(None).await.unwrap(); + let callback = "http://127.0.0.1:3334/oauth/callback".to_string(); + let AxumJson(registration) = register_client( + State(state.clone()), + AxumJson(ClientRegistration { + redirect_uris: vec![callback.clone()], + client_name: Some("test client".to_string()), + scope: Some(MCP_SCOPE.to_string()), + }), + ) + .await + .unwrap(); + + let redirect = authorize( + State(state), + None, + Query(AuthorizeQuery { + client_id: registration.client_id, + redirect_uri: callback, + response_type: "code".to_string(), + code_challenge: "ZmFrZS1jaGFsbGVuZ2U".to_string(), + code_challenge_method: "S256".to_string(), + state: Some("client-state".to_string()), + scope: Some(MCP_SCOPE.to_string()), + resource: None, + }), + ) + .await + .unwrap() + .into_response(); + + let location = redirect + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(); + assert!(location.starts_with("/_/auth/login?")); + assert!(location.contains("response_type=code")); + assert!(location.contains("pkce_code_challenge=")); + } + + #[tokio::test] + async fn native_tools_use_live_admin_state() { + let state = test_state(None).await.unwrap(); + let server = TrailBaseMcp::new(state); + let tool_names: Vec<_> = server + .tool_router + .list_all() + .into_iter() + .map(|tool| tool.name.to_string()) + .collect(); + assert_eq!( + tool_names, + [ + "call_admin_api", + "execute_sql", + "get_config", + "list_tables", + "update_config" + ] + ); + + let config = server.get_config().unwrap(); + assert!(config.contains("auth")); + assert_eq!( + server + .update_config(Parameters(ConfigUpdateRequest { config })) + .await + .unwrap(), + "Config updated" + ); + + server + .execute_sql(Parameters(SqlRequest { + query: "CREATE TABLE mcp_native_tool_test (id INTEGER PRIMARY KEY)".to_string(), + attached_databases: None, + })) + .await + .unwrap(); + let tables = server.list_tables().await.unwrap(); + assert!(tables.0.to_string().contains("mcp_native_tool_test")); + } + + #[tokio::test] + async fn mcp_tokens_are_bound_to_the_mcp_resource() { + let state = test_state(None).await.unwrap(); + let user_id = uuid::Uuid::new_v4(); + let now = chrono::Utc::now().timestamp(); + let claims = AuthTokenClaims { + sub: crate::util::uuid_to_b64(&user_id), + iat: now, + exp: now + 60, + r#type: 1, + admin: true, + mfa: false, + provider: 0, + email: Some("admin@localhost".to_string()), + username: Some("admin".to_string()), + csrf_token: "csrf".to_string(), + }; + + let scoped = state + .jwt() + .encode(&McpAccessTokenClaims { + auth: claims.clone(), + aud: external_url(&state, MCP_PATH), + scope: MCP_SCOPE.to_string(), + }) + .unwrap(); + assert_eq!(mcp_user_id(&state, &scoped), Some(user_id)); + + let wrong_audience = state + .jwt() + .encode(&McpAccessTokenClaims { + auth: claims.clone(), + aud: "https://other.example/mcp".to_string(), + scope: MCP_SCOPE.to_string(), + }) + .unwrap(); + assert_eq!(mcp_user_id(&state, &wrong_audience), None); + + let legacy = state.jwt().encode(&claims).unwrap(); + assert_eq!(mcp_user_id(&state, &legacy), Some(user_id)); + } +} diff --git a/crates/core/src/server/mod.rs b/crates/core/src/server/mod.rs index f3002fd77..4ca9e2716 100644 --- a/crates/core/src/server/mod.rs +++ b/crates/core/src/server/mod.rs @@ -75,6 +75,9 @@ pub struct ServerOptions { /// Custom axum router. pub custom_router: Option>, + + /// Expose the MCP endpoint on the admin server. + pub enable_mcp: bool, } pub struct Server { @@ -108,6 +111,7 @@ impl Server { tls_cert, tls_key, custom_router, + enable_mcp, } = opts; let version_info = trailbase_build::get_version_info!(); @@ -174,7 +178,7 @@ impl Server { None }; - let admin_router = Self::build_admin_router(&state); + let admin_router = Self::build_admin_router(&state, enable_mcp); let independent_admin_router = if let Some(admin_address) = admin_address && admin_address != address { @@ -365,8 +369,8 @@ impl Server { return Ok(()); } - fn build_admin_router(state: &AppState) -> Router { - return Router::new() + fn build_admin_router(state: &AppState, enable_mcp: bool) -> Router { + let mut router = Router::new() .nest( &format!("/{ADMIN_API_PATH}/"), admin::router().layer(middleware::from_fn_with_state( @@ -391,6 +395,12 @@ impl Server { }, ), ); + + if enable_mcp { + router = router.merge(crate::mcp::router(state)); + } + + return router; } async fn build_main_router( diff --git a/docker-compose.yml b/docker-compose.yml index 5ab74cfce..13fb164d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,4 +14,6 @@ services: - ${DATA_DIR:-.}/traildepot:/app/traildepot environment: RUST_BACKTRACE: "1" - # command: "/app/trail --data-dir /app/traildepot run --address 0.0.0.0:4000" + # Add --mcp to expose the authenticated native MCP endpoint at /mcp. + # Also set --public-url to the external HTTPS origin used by MCP clients. + # command: "/app/trail --depot /app/traildepot --public-url https://trailbase.example.com run --address 0.0.0.0:4000 --mcp" diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..450b4413c --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,304 @@ +# TrailBase MCP + +TrailBase includes an optional native MCP server in the main `trail` binary. It +runs in the same process and container as TrailBase, on the same HTTP port: + +```text +TrailBase admin UI: https://trailbase.example.com/_/admin/ +TrailBase MCP: https://trailbase.example.com/mcp +``` + +There is no MCP sidecar, second Docker image, second port, shared depot mount, +or manually copied bearer token. MCP clients use OAuth to open TrailBase's own +login UI. After an administrator signs in, TrailBase issues and refreshes the +tokens used by the client. + +## Enable MCP + +MCP is disabled by default because it exposes privileged development and +administration tools. Enable it with `--mcp`: + +```sh +trail --public-url https://trailbase.example.com run \ + --address 0.0.0.0:4000 \ + --mcp +``` + +`--public-url` must be the external HTTPS origin clients use. TrailBase uses it +in OAuth discovery metadata and validates the MCP resource audience against it. +Use HTTPS outside localhost. + +The auth UI must also be installed so the browser login page is available: + +```sh +trail components add trailbase/auth_ui +``` + +The official Docker image already contains the auth UI component. + +## IDE configuration + +Clients with native remote-MCP and OAuth support can connect directly to either +the local or public MCP URL. For IDEs that accept only local command-based MCP +servers, use the matching `mcp-remote` configuration below. + +### Localhost + +Use this while TrailBase is running on the same machine as the IDE: + +```text +http://localhost:4000/mcp +``` + +```json +{ + "mcpServers": { + "trailbase-local": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://localhost:4000/mcp", + "--allow-http", + "--static-oauth-client-metadata", + "{\"scope\":\"mcp\"}" + ] + } + } +} +``` + +`localhost` can use any port on which TrailBase is exposed, for example +`http://localhost:4100/mcp`. `--allow-http` is required for a plain HTTP URL and +is intended only for local development. + +### Public HTTPS URL + +Use this for Cloudflare Tunnel, a reverse proxy, or another deployed TrailBase +instance: + +```text +https://trailbase.example.com/mcp +``` + +```json +{ + "mcpServers": { + "trailbase": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://trailbase.example.com/mcp", + "--static-oauth-client-metadata", + "{\"scope\":\"mcp\"}" + ] + } + } +} +``` + +Replace `trailbase.example.com` with the deployment's public hostname. Do not +add `--allow-http` for HTTPS. Configure TrailBase's `--public-url` as +`https://trailbase.example.com` without the `/mcp` suffix so OAuth discovery, +redirects, and token audience validation agree. + +The name directly below `"mcpServers"` is chosen by the user and can describe +the connection. For example, `"trailbase"` can be changed to +`"production-database"`, `"my-trailbase-server"`, or `"trailbase-local"`: + +```json +{ + "mcpServers": { + "production-database": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://trailbase.example.com/mcp", + "--static-oauth-client-metadata", + "{\"scope\":\"mcp\"}" + ] + } + } +} +``` + +Do not rename `"mcp-remote"` in the arguments when using this configuration; +it is the npm package that `npx` downloads and runs. IDEs with native remote +HTTP MCP and OAuth support can use the `/mcp` URL directly without `npx` or +`mcp-remote`. + +These command-based configurations assume Node.js 20.18.1 or newer, as required +by the HTTP client currently used by `mcp-remote`. They do not require +`NODE_OPTIONS`, a polyfill, a TrailBase bearer token, or any machine-specific +paths. Upgrade the Node.js runtime selected by the IDE if an older runtime +reports that `File` is not defined. + +On the first connection, the client opens a browser at TrailBase's login page. +Sign in with a TrailBase administrator account. Credentials are submitted only +to that TrailBase instance; they are not stored in the IDE configuration or +sent through MCP tool arguments. Existing TrailBase MFA and external identity +provider flows continue to apply. + +If the client caches an old failed registration or token, clear its MCP OAuth +cache and reconnect. + +## Docker and Portainer + +The native server uses the normal `trailbase/trailbase` image. A single-service +Portainer stack is sufficient: + +```yaml +services: + trail: + image: docker.io/trailbase/trailbase:latest + ports: + - "4000:4000" # HOST_PORT:CONTAINER_PORT + restart: unless-stopped + volumes: + - /mnt/traildepot:/app/traildepot # Persistent databases, files, and config. + environment: + RUST_BACKTRACE: "1" + command: + - /app/trail # TrailBase executable. + - --depot # Persistent TrailBase data directory. + - /app/traildepot # Must match the container volume path above. + - --public-url # External origin used for OAuth and MCP tokens. + - https://trailbase.example.com # No /mcp suffix; include a non-default public port. + - run # Start the TrailBase HTTP server. + - --address # Listen inside the container on all interfaces. + - 0.0.0.0:4000 # Must match CONTAINER_PORT above. + - --mcp # Enable native MCP at /mcp on the same server. +``` + +The command is explicit so the deployment does not depend on image defaults. +`--depot` replaces the deprecated `--data-dir`. `--public-url` is the origin a +browser and MCP client actually use; it must not include `/mcp` or an admin UI +path. `--mcp` is the only MCP-specific startup flag. + +The port mapping does not need to be `4000:4000`. For example, to expose host +port `5000` while TrailBase continues listening on container port `4000`, use: + +```yaml +ports: + - "5000:4000" # Host port 5000 forwards to container port 4000. +``` + +Keep `--address 0.0.0.0:4000`. When connecting directly on localhost, set +`--public-url` to `http://localhost:5000`; the MCP URL is then +`http://localhost:5000/mcp`. + +Point the existing Cloudflare Tunnel or reverse proxy at port `4000`. The same +hostname serves both TrailBase and `/mcp`; no public port `4001` or `8000` is +needed. For example, the tunnel can forward `https://trailbase.example.com` to +`http://trail:4000`; keep `--public-url https://trailbase.example.com`, and use +`https://trailbase.example.com/mcp` in the MCP client. Do not place Cloudflare +Access or another interactive login layer in front of only `/mcp`, because MCP +clients also need to reach TrailBase's OAuth discovery and authorization +endpoints. TLS termination at Cloudflare or the reverse proxy is expected. + +## Authentication and security + +The native MCP implementation follows the HTTP MCP authorization flow: + +- OAuth Protected Resource Metadata (RFC 9728). +- OAuth Authorization Server Metadata (RFC 8414). +- Dynamic Client Registration (RFC 7591). +- Authorization Code flow with PKCE S256. +- MCP access tokens scoped to `mcp` and audience-bound to the instance's + public `/mcp` resource URL. +- Access-token refresh. +- `WWW-Authenticate` discovery on unauthenticated MCP requests. + +Only a currently valid TrailBase administrator can use MCP. TrailBase verifies +administrator status against the database for every MCP HTTP request rather +than trusting the potentially stale `admin` claim in a token. Registering an +OAuth client does not grant access. + +Treat MCP as an administrative surface: + +- Enable it only when needed. +- Require HTTPS on remote deployments. +- Keep the TrailBase admin login protected with a strong password and MFA. +- Restrict the hostname at the firewall, VPN, Cloudflare policy, or reverse + proxy when broad internet access is unnecessary. +- Review tool calls before approving destructive schema or data changes. + +## Tools + +The native server provides focused tools for common work: + +- `list_tables`: table, view, column, index, trigger, and metadata discovery. +- `execute_sql`: read or mutate the main database and refresh metadata after + recognized schema changes. +- `get_config`: return redacted TrailBase protobuf-text configuration. +- `update_config`: validate and save configuration while preserving existing + secrets. +- `call_admin_api`: reach all remaining admin dashboard operations. + +`call_admin_api(method, path, body?)` dispatches directly to TrailBase's +in-process admin router. `path` is relative to `/api/_admin`; it may also be the +full `/api/_admin/...` path. This means MCP and the dashboard use the same Rust +handlers and cannot drift into separate API implementations. + +Examples: + +```json +{ + "method": "GET", + "path": "tables" +} +``` + +```json +{ + "method": "POST", + "path": "query", + "body": { + "query": "CREATE TABLE candy (id INTEGER PRIMARY KEY, name TEXT NOT NULL)" + } +} +``` + +Available paths are the same ones used by the admin dashboard, including table, +index, row, file, configuration, JSON Schema, SQL query, user, log, job, backup, +OAuth-provider, and WASM-component operations. TrailBase's normal demo-mode and +handler-level safety checks still apply. + +Schema-changing dashboard handlers write migrations and rebuild metadata in the +same way when called through MCP. Raw SQL through `query` also rebuilds schema +metadata for recognized table/view changes. + +## Direct bearer-token clients + +OAuth is recommended. A client that can explicitly set HTTP headers may instead +send an existing TrailBase administrator access token: + +```text +Authorization: Bearer +``` + +The access token returned by `/api/auth/v1/login` is short-lived. Clients using +this mode must manage `/api/auth/v1/refresh` themselves. Do not put an admin +password or long-lived refresh token in a shared project configuration. This +compatibility mode accepts only ordinary TrailBase tokens without an OAuth +audience; an MCP token minted for another TrailBase resource is rejected. + +## Development validation + +Run the native MCP unit tests with: + +```sh +cargo test -p trailbase --lib mcp::tests +``` + +For an isolated manual test: + +```sh +trail --depot "$(mktemp -d)" \ + --public-url http://127.0.0.1:4100 \ + run --address 127.0.0.1:4100 --dev --mcp +``` + +Use only a disposable depot for destructive integration tests. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..5d56faf9a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/scripts/bootstrap-local-dev-tools.sh b/scripts/bootstrap-local-dev-tools.sh new file mode 100755 index 000000000..30df278c0 --- /dev/null +++ b/scripts/bootstrap-local-dev-tools.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +DEV_TOOLS_DIR="${ROOT_DIR}/.dev-tools" +DEB_CACHE_DIR="${DEV_TOOLS_DIR}/debs" + +if ! command -v apt-get >/dev/null 2>&1 || ! command -v dpkg-deb >/dev/null 2>&1; then + echo "This bootstrap script currently supports Debian/Ubuntu-style systems with apt-get and dpkg-deb." >&2 + exit 1 +fi + +mkdir -p "${DEB_CACHE_DIR}" + +download_and_extract() { + local target_dir="$1" + shift + + mkdir -p "${target_dir}" + ( + cd "${DEB_CACHE_DIR}" + apt-get download "$@" + for package in "$@"; do + for deb in "${package}"_*.deb; do + dpkg-deb -x "${deb}" "${target_dir}" + done + done + ) +} + +download_and_extract \ + "${DEV_TOOLS_DIR}/libclang-18" \ + libclang-18-dev \ + libclang1-18 \ + libclang-common-18-dev \ + clang-18 + +download_and_extract \ + "${DEV_TOOLS_DIR}/geos" \ + libgeos-dev \ + libgeos-c1t64 \ + libgeos3.12.1t64 + +download_and_extract \ + "${DEV_TOOLS_DIR}/protobuf" \ + protobuf-compiler \ + libprotobuf32t64 \ + libprotoc32t64 \ + libprotobuf-dev + +if command -v corepack >/dev/null 2>&1; then + corepack enable pnpm +fi + +if command -v pnpm >/dev/null 2>&1; then + ( + cd "${ROOT_DIR}" + pnpm install --prefer-frozen-lockfile + ) +else + echo "pnpm not found. Install pnpm or enable it through corepack before running cargo." >&2 +fi + +echo "Local dev tools bootstrapped in ${DEV_TOOLS_DIR}."