feat: add embedded MCP server and Makefile
Add an MCP (Model Context Protocol) server that starts on 127.0.0.1:9427 at app launch, sharing connection pools with the Tauri IPC layer. This lets Claude (or any MCP client) query PostgreSQL through Tusk's existing connections. MCP tools: list_connections, execute_query, list_schemas, list_tables, describe_table. Also add a Makefile with targets for dev, build (cross-platform), install/uninstall, lint, and formatting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ use crate::state::AppState;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||
@@ -50,7 +51,7 @@ pub async fn save_connection(app: AppHandle, config: ConnectionConfig) -> TuskRe
|
||||
#[tauri::command]
|
||||
pub async fn delete_connection(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
id: String,
|
||||
) -> TuskResult<()> {
|
||||
let path = get_connections_path(&app)?;
|
||||
@@ -91,7 +92,7 @@ pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> TuskResult<()> {
|
||||
pub async fn connect(state: State<'_, Arc<AppState>>, config: ConnectionConfig) -> TuskResult<()> {
|
||||
let pool = PgPool::connect(&config.connection_url())
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
@@ -113,7 +114,7 @@ pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> Tu
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn switch_database(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
config: ConnectionConfig,
|
||||
database: String,
|
||||
) -> TuskResult<()> {
|
||||
@@ -139,7 +140,7 @@ pub async fn switch_database(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<()> {
|
||||
pub async fn disconnect(state: State<'_, Arc<AppState>>, id: String) -> TuskResult<()> {
|
||||
let mut pools = state.pools.write().await;
|
||||
if let Some(pool) = pools.remove(&id) {
|
||||
pool.close().await;
|
||||
@@ -153,7 +154,7 @@ pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<()
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_read_only(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
read_only: bool,
|
||||
) -> TuskResult<()> {
|
||||
@@ -164,7 +165,7 @@ pub async fn set_read_only(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_read_only(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<bool> {
|
||||
Ok(state.is_read_only(&connection_id).await)
|
||||
|
||||
@@ -5,12 +5,13 @@ use crate::state::AppState;
|
||||
use crate::utils::escape_ident;
|
||||
use serde_json::Value;
|
||||
use sqlx::{Column, Row, TypeInfo};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_data(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
@@ -95,7 +96,7 @@ pub async fn get_table_data(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_row(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
@@ -142,7 +143,7 @@ pub async fn update_row(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn insert_row(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
@@ -182,7 +183,7 @@ pub async fn insert_row(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_rows(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
|
||||
@@ -3,11 +3,12 @@ use crate::models::management::*;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::escape_ident;
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_database_info(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<DatabaseInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
@@ -54,7 +55,7 @@ pub async fn get_database_info(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_database(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
params: CreateDatabaseParams,
|
||||
) -> TuskResult<()> {
|
||||
@@ -95,7 +96,7 @@ pub async fn create_database(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn drop_database(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
name: String,
|
||||
) -> TuskResult<()> {
|
||||
@@ -129,7 +130,7 @@ pub async fn drop_database(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_roles(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<RoleInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
@@ -193,7 +194,7 @@ pub async fn list_roles(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_role(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
params: CreateRoleParams,
|
||||
) -> TuskResult<()> {
|
||||
@@ -271,7 +272,7 @@ pub async fn create_role(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn alter_role(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
params: AlterRoleParams,
|
||||
) -> TuskResult<()> {
|
||||
@@ -370,7 +371,7 @@ pub async fn alter_role(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn drop_role(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
name: String,
|
||||
) -> TuskResult<()> {
|
||||
@@ -394,7 +395,7 @@ pub async fn drop_role(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_privileges(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
@@ -433,7 +434,7 @@ pub async fn get_table_privileges(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn grant_revoke(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
params: GrantRevokeParams,
|
||||
) -> TuskResult<()> {
|
||||
@@ -478,7 +479,7 @@ pub async fn grant_revoke(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn manage_role_membership(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
params: RoleMembershipParams,
|
||||
) -> TuskResult<()> {
|
||||
@@ -510,7 +511,7 @@ pub async fn manage_role_membership(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sessions(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<SessionInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
@@ -550,7 +551,7 @@ pub async fn list_sessions(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_query(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
pid: i32,
|
||||
) -> TuskResult<bool> {
|
||||
@@ -570,7 +571,7 @@ pub async fn cancel_query(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn terminate_backend(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
pid: i32,
|
||||
) -> TuskResult<bool> {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::state::AppState;
|
||||
use serde_json::Value;
|
||||
use sqlx::postgres::PgRow;
|
||||
use sqlx::{Column, Row, TypeInfo};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
@@ -68,18 +69,17 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_query(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
sql: String,
|
||||
pub async fn execute_query_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
sql: &str,
|
||||
) -> TuskResult<QueryResult> {
|
||||
let read_only = state.is_read_only(&connection_id).await;
|
||||
let read_only = state.is_read_only(connection_id).await;
|
||||
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
.get(connection_id)
|
||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
||||
|
||||
let start = Instant::now();
|
||||
let rows = if read_only {
|
||||
@@ -88,14 +88,14 @@ pub async fn execute_query(
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
let result = sqlx::query(&sql)
|
||||
let result = sqlx::query(sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(TuskError::Database);
|
||||
tx.rollback().await.map_err(TuskError::Database)?;
|
||||
result?
|
||||
} else {
|
||||
sqlx::query(&sql)
|
||||
sqlx::query(sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?
|
||||
@@ -127,3 +127,12 @@ pub async fn execute_query(
|
||||
execution_time_ms,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_query(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
sql: String,
|
||||
) -> TuskResult<QueryResult> {
|
||||
execute_query_core(&state, &connection_id, &sql).await
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo,
|
||||
use crate::state::AppState;
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_databases(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<String>> {
|
||||
let pools = state.pools.read().await;
|
||||
@@ -27,15 +28,14 @@ pub async fn list_databases(
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_schemas(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
pub async fn list_schemas_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
) -> TuskResult<Vec<String>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
.get(connection_id)
|
||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
@@ -50,15 +50,22 @@ pub async fn list_schemas(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tables(
|
||||
state: State<'_, AppState>,
|
||||
pub async fn list_schemas(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<String>> {
|
||||
list_schemas_core(&state, &connection_id).await
|
||||
}
|
||||
|
||||
pub async fn list_tables_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
.get(connection_id)
|
||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT t.table_name, \
|
||||
@@ -70,7 +77,7 @@ pub async fn list_tables(
|
||||
WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' \
|
||||
ORDER BY t.table_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.bind(schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
@@ -80,16 +87,25 @@ pub async fn list_tables(
|
||||
.map(|r| SchemaObject {
|
||||
name: r.get(0),
|
||||
object_type: "table".to_string(),
|
||||
schema: schema.clone(),
|
||||
schema: schema.to_string(),
|
||||
row_count: r.get::<Option<i64>, _>(1),
|
||||
size_bytes: r.get::<Option<i64>, _>(2),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tables(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
list_tables_core(&state, &connection_id, &schema).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_views(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
@@ -122,7 +138,7 @@ pub async fn list_views(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_functions(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
@@ -155,7 +171,7 @@ pub async fn list_functions(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_indexes(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
@@ -188,7 +204,7 @@ pub async fn list_indexes(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sequences(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
) -> TuskResult<Vec<SchemaObject>> {
|
||||
@@ -219,17 +235,16 @@ pub async fn list_sequences(
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_columns(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
pub async fn get_table_columns_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> TuskResult<Vec<ColumnInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
.get(connection_id)
|
||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT \
|
||||
@@ -254,8 +269,8 @@ pub async fn get_table_columns(
|
||||
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||
ORDER BY c.ordinal_position",
|
||||
)
|
||||
.bind(&schema)
|
||||
.bind(&table)
|
||||
.bind(schema)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
@@ -274,9 +289,19 @@ pub async fn get_table_columns(
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_columns(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> TuskResult<Vec<ColumnInfo>> {
|
||||
get_table_columns_core(&state, &connection_id, &schema, &table).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_constraints(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
@@ -317,7 +342,7 @@ pub async fn get_table_constraints(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_indexes(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
@@ -359,7 +384,7 @@ pub async fn get_table_indexes(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_completion_schema(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
||||
let pools = state.pools.read().await;
|
||||
@@ -396,7 +421,7 @@ pub async fn get_completion_schema(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_column_details(
|
||||
state: State<'_, AppState>,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
mod commands;
|
||||
mod error;
|
||||
mod mcp;
|
||||
mod models;
|
||||
mod state;
|
||||
mod utils;
|
||||
|
||||
use state::AppState;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn run() {
|
||||
let shared_state = Arc::new(AppState::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState::new())
|
||||
.manage(shared_state)
|
||||
.setup(|app| {
|
||||
let state = app.state::<Arc<AppState>>().inner().clone();
|
||||
let connections_path = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve app data dir")
|
||||
.join("connections.json");
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = mcp::start_mcp_server(state, connections_path, 9427).await {
|
||||
log::error!("MCP server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// connections
|
||||
commands::connections::get_connections,
|
||||
|
||||
236
src-tauri/src/mcp/mod.rs
Normal file
236
src-tauri/src/mcp/mod.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use crate::commands::queries::execute_query_core;
|
||||
use crate::commands::schema::{get_table_columns_core, list_schemas_core, list_tables_core};
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::state::AppState;
|
||||
use rmcp::handler::server::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::*;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use rmcp::transport::streamable_http_server::StreamableHttpServerConfig;
|
||||
use rmcp::transport::streamable_http_server::StreamableHttpService;
|
||||
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
// --- Tool parameter types ---
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ConnectionIdParam {
|
||||
/// ID of the connection to use (from list_connections)
|
||||
connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ExecuteQueryParam {
|
||||
/// ID of the connection to use
|
||||
connection_id: String,
|
||||
/// SQL query to execute
|
||||
sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListTablesParam {
|
||||
/// ID of the connection to use
|
||||
connection_id: String,
|
||||
/// Schema name (e.g. "public")
|
||||
schema: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct DescribeTableParam {
|
||||
/// ID of the connection to use
|
||||
connection_id: String,
|
||||
/// Schema name (e.g. "public")
|
||||
schema: String,
|
||||
/// Table name
|
||||
table: String,
|
||||
}
|
||||
|
||||
// --- MCP Server ---
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TuskMcpServer {
|
||||
state: Arc<AppState>,
|
||||
connections_path: PathBuf,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ConnectionStatus {
|
||||
id: String,
|
||||
name: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
database: String,
|
||||
active: bool,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl TuskMcpServer {
|
||||
fn new(state: Arc<AppState>, connections_path: PathBuf) -> Self {
|
||||
Self {
|
||||
state,
|
||||
connections_path,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List all configured database connections with their active/read-only status")]
|
||||
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
|
||||
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
||||
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
||||
McpError::internal_error(format!("Failed to read connections file: {}", e), None)
|
||||
})?;
|
||||
serde_json::from_str(&data).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let pools = self.state.pools.read().await;
|
||||
let read_only_map = self.state.read_only.read().await;
|
||||
|
||||
let statuses: Vec<ConnectionStatus> = configs
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let active = pools.contains_key(&c.id);
|
||||
let read_only = read_only_map.get(&c.id).copied().unwrap_or(true);
|
||||
ConnectionStatus {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
database: c.database,
|
||||
active,
|
||||
read_only,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let json = serde_json::to_string_pretty(&statuses).map_err(|e| {
|
||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
||||
})?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
|
||||
#[tool(description = "Execute a SQL query on a database connection. Respects read-only mode.")]
|
||||
async fn execute_query(
|
||||
&self,
|
||||
Parameters(params): Parameters<ExecuteQueryParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match execute_query_core(&self.state, ¶ms.connection_id, ¶ms.sql).await {
|
||||
Ok(result) => {
|
||||
let json = serde_json::to_string_pretty(&result).map_err(|e| {
|
||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List all schemas in the current database (excludes system schemas)")]
|
||||
async fn list_schemas(
|
||||
&self,
|
||||
Parameters(params): Parameters<ConnectionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match list_schemas_core(&self.state, ¶ms.connection_id).await {
|
||||
Ok(schemas) => {
|
||||
let json = serde_json::to_string_pretty(&schemas).map_err(|e| {
|
||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List all tables in a schema with row count and size information")]
|
||||
async fn list_tables(
|
||||
&self,
|
||||
Parameters(params): Parameters<ListTablesParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match list_tables_core(&self.state, ¶ms.connection_id, ¶ms.schema).await {
|
||||
Ok(tables) => {
|
||||
let json = serde_json::to_string_pretty(&tables).map_err(|e| {
|
||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Describe table columns: name, type, nullable, primary key, default value")]
|
||||
async fn describe_table(
|
||||
&self,
|
||||
Parameters(params): Parameters<DescribeTableParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match get_table_columns_core(
|
||||
&self.state,
|
||||
¶ms.connection_id,
|
||||
¶ms.schema,
|
||||
¶ms.table,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(columns) => {
|
||||
let json = serde_json::to_string_pretty(&columns).map_err(|e| {
|
||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
||||
})?;
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for TuskMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::V_2025_03_26,
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation {
|
||||
name: "tusk-mcp".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
title: Some("Tusk PostgreSQL MCP Server".to_string()),
|
||||
description: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
instructions: Some(
|
||||
"Tusk MCP server provides access to PostgreSQL databases through the Tusk GUI app. \
|
||||
Use list_connections to see available connections (must be connected in the Tusk GUI first). \
|
||||
Use the connection_id from list_connections in other tools. \
|
||||
Read-only mode is respected — write queries will fail if a connection is in read-only mode."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_mcp_server(
|
||||
state: Arc<AppState>,
|
||||
connections_path: PathBuf,
|
||||
port: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(TuskMcpServer::new(state.clone(), connections_path.clone())),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/mcp", service);
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
log::info!("MCP server listening on http://{}/mcp", addr);
|
||||
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user