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:
2026-02-12 13:24:25 +03:00
parent ded35d8c40
commit 32486b0524
10 changed files with 756 additions and 71 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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
}

View File

@@ -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,