diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01de575 --- /dev/null +++ b/Makefile @@ -0,0 +1,138 @@ +APP_NAME := tusk +VERSION := $(shell grep '"version"' src-tauri/tauri.conf.json | head -1 | sed 's/.*: "//;s/".*//') +PREFIX := /usr/local +BINDIR := $(PREFIX)/bin +DATADIR := $(PREFIX)/share +BUNDLE_DIR := src-tauri/target/release/bundle + +# Rust cross-compilation target (override: make build TARGET=aarch64-unknown-linux-gnu) +TARGET := +TARGET_FLAG := $(if $(TARGET),--target $(TARGET),) +TARGET_DIR := $(if $(TARGET),src-tauri/target/$(TARGET)/release,src-tauri/target/release) + +# ────────────────────────────────────────────── +# Development +# ────────────────────────────────────────────── + +.PHONY: dev +dev: node_modules ## Run app in dev mode (Vite HMR + Rust backend) + npm run tauri dev + +.PHONY: dev-frontend +dev-frontend: node_modules ## Run only the Vite frontend dev server + npm run dev + +.PHONY: check +check: ## Check Rust compilation without building + cd src-tauri && cargo check $(TARGET_FLAG) + +.PHONY: lint +lint: node_modules ## Run ESLint + cargo clippy + npm run lint + cd src-tauri && cargo clippy $(TARGET_FLAG) -- -D warnings + +.PHONY: fmt +fmt: ## Format Rust code + cd src-tauri && cargo fmt + +.PHONY: fmt-check +fmt-check: ## Check Rust formatting + cd src-tauri && cargo fmt --check + +# ────────────────────────────────────────────── +# Build +# ────────────────────────────────────────────── + +.PHONY: build +build: node_modules ## Build release binary + bundles for current platform + npm run tauri build $(TARGET_FLAG) + +.PHONY: build-debug +build-debug: node_modules ## Build debug binary + npm run tauri build -- --debug $(TARGET_FLAG) + +.PHONY: build-linux +build-linux: node_modules ## Build for Linux (deb + AppImage + rpm) + npm run tauri build + +.PHONY: build-macos +build-macos: node_modules ## Build for macOS (dmg + app bundle) — run on macOS + npm run tauri build + +.PHONY: build-macos-universal +build-macos-universal: node_modules ## Build universal macOS binary (x86_64 + aarch64) + npm run tauri build -- --target universal-apple-darwin + +.PHONY: build-windows +build-windows: node_modules ## Build for Windows (msi + nsis) — run on Windows + npm run tauri build + +# ────────────────────────────────────────────── +# Install / Uninstall (Linux) +# ────────────────────────────────────────────── + +define DESKTOP_ENTRY +[Desktop Entry] +Name=Tusk +Comment=PostgreSQL Database Management GUI +Exec=$(BINDIR)/$(APP_NAME) +Icon=$(APP_NAME) +Terminal=false +Type=Application +Categories=Development;Database; +Keywords=postgresql;database;sql; +endef +export DESKTOP_ENTRY + +.PHONY: install +install: build ## Build release and install to system (PREFIX=/usr/local) + install -Dm755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME) + @mkdir -p $(DESTDIR)$(DATADIR)/applications + @echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop + @for size in 32x32 128x128; do \ + if [ -f src-tauri/icons/$$size.png ]; then \ + install -Dm644 src-tauri/icons/$$size.png \ + $(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$(APP_NAME).png; \ + fi; \ + done + @echo "Installed $(APP_NAME) $(VERSION) to $(DESTDIR)$(BINDIR)/$(APP_NAME)" + +.PHONY: uninstall +uninstall: ## Remove installed files + rm -f $(DESTDIR)$(BINDIR)/$(APP_NAME) + rm -f $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop + rm -f $(DESTDIR)$(DATADIR)/icons/hicolor/32x32/apps/$(APP_NAME).png + rm -f $(DESTDIR)$(DATADIR)/icons/hicolor/128x128/apps/$(APP_NAME).png + @echo "Uninstalled $(APP_NAME)" + +# ────────────────────────────────────────────── +# Utilities +# ────────────────────────────────────────────── + +.PHONY: clean +clean: ## Remove build artifacts + rm -rf dist + cd src-tauri && cargo clean + +.PHONY: clean-frontend +clean-frontend: ## Remove only frontend build output + rm -rf dist node_modules/.vite + +.PHONY: deps +deps: node_modules ## Install all dependencies + @echo "Dependencies ready" + +node_modules: package.json + npm install + @touch node_modules + +.PHONY: version +version: ## Show app version + @echo "$(APP_NAME) $(VERSION)" + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' + +.DEFAULT_GOAL := help diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e718e0f..3f3371f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -53,6 +53,17 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "atk" version = "0.18.2" @@ -97,6 +108,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -546,8 +609,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]] @@ -564,13 +637,37 @@ dependencies = [ "syn 2.0.114", ] +[[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", + "syn 2.0.114", +] + [[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.114", +] + +[[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.114", ] @@ -927,6 +1024,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1000,6 +1112,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1439,6 +1552,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1452,6 +1571,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1932,6 +2052,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -2431,6 +2557,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2798,6 +2930,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2818,6 +2960,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2836,6 +2988,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3010,6 +3171,51 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.9.2", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.114", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3102,7 +3308,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -3127,8 +3333,10 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -3145,6 +3353,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3245,6 +3465,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3311,7 +3542,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.114", @@ -3741,6 +3972,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4479,6 +4723,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4575,11 +4820,14 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" name = "tusk" version = "0.1.0" dependencies = [ + "axum", "bigdecimal", "chrono", "csv", "hex", "log", + "rmcp", + "schemars 1.2.1", "serde", "serde_json", "sqlx", @@ -4589,6 +4837,7 @@ dependencies = [ "tauri-plugin-shell", "thiserror 2.0.18", "tokio", + "tokio-util", "uuid", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d016e6a..fa5c050 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,3 +30,7 @@ csv = "1" log = "0.4" hex = "0.4" bigdecimal = { version = "0.4", features = ["serde"] } +rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable-http-server"] } +axum = "0.8" +schemars = "1" +tokio-util = "0.7" diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs index 9556708..f90c637 100644 --- a/src-tauri/src/commands/connections.rs +++ b/src-tauri/src/commands/connections.rs @@ -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 { @@ -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>, id: String, ) -> TuskResult<()> { let path = get_connections_path(&app)?; @@ -91,7 +92,7 @@ pub async fn test_connection(config: ConnectionConfig) -> TuskResult { } #[tauri::command] -pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> TuskResult<()> { +pub async fn connect(state: State<'_, Arc>, 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>, 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>, 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>, 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>, connection_id: String, ) -> TuskResult { Ok(state.is_read_only(&connection_id).await) diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index a46eb73..1395302 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -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>, 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>, 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>, 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>, connection_id: String, schema: String, table: String, diff --git a/src-tauri/src/commands/management.rs b/src-tauri/src/commands/management.rs index 0f93eaa..dfb7adb 100644 --- a/src-tauri/src/commands/management.rs +++ b/src-tauri/src/commands/management.rs @@ -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>, connection_id: String, ) -> TuskResult> { 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>, 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>, 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>, connection_id: String, ) -> TuskResult> { 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>, 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>, 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>, 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>, 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>, 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>, 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>, connection_id: String, ) -> TuskResult> { 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>, connection_id: String, pid: i32, ) -> TuskResult { @@ -570,7 +571,7 @@ pub async fn cancel_query( #[tauri::command] pub async fn terminate_backend( - state: State<'_, AppState>, + state: State<'_, Arc>, connection_id: String, pid: i32, ) -> TuskResult { diff --git a/src-tauri/src/commands/queries.rs b/src-tauri/src/commands/queries.rs index e481d4b..72488f6 100644 --- a/src-tauri/src/commands/queries.rs +++ b/src-tauri/src/commands/queries.rs @@ -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 { - 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>, + connection_id: String, + sql: String, +) -> TuskResult { + execute_query_core(&state, &connection_id, &sql).await +} diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 16318a8..0646c20 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -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>, connection_id: String, ) -> TuskResult> { let pools = state.pools.read().await; @@ -27,15 +28,14 @@ pub async fn list_databases( Ok(rows.iter().map(|r| r.get::(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> { 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>, connection_id: String, - schema: String, +) -> TuskResult> { + list_schemas_core(&state, &connection_id).await +} + +pub async fn list_tables_core( + state: &AppState, + connection_id: &str, + schema: &str, ) -> TuskResult> { 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::, _>(1), size_bytes: r.get::, _>(2), }) .collect()) } +#[tauri::command] +pub async fn list_tables( + state: State<'_, Arc>, + connection_id: String, + schema: String, +) -> TuskResult> { + list_tables_core(&state, &connection_id, &schema).await +} + #[tauri::command] pub async fn list_views( - state: State<'_, AppState>, + state: State<'_, Arc>, connection_id: String, schema: String, ) -> TuskResult> { @@ -122,7 +138,7 @@ pub async fn list_views( #[tauri::command] pub async fn list_functions( - state: State<'_, AppState>, + state: State<'_, Arc>, connection_id: String, schema: String, ) -> TuskResult> { @@ -155,7 +171,7 @@ pub async fn list_functions( #[tauri::command] pub async fn list_indexes( - state: State<'_, AppState>, + state: State<'_, Arc>, connection_id: String, schema: String, ) -> TuskResult> { @@ -188,7 +204,7 @@ pub async fn list_indexes( #[tauri::command] pub async fn list_sequences( - state: State<'_, AppState>, + state: State<'_, Arc>, connection_id: String, schema: String, ) -> TuskResult> { @@ -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> { 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>, + connection_id: String, + schema: String, + table: String, +) -> TuskResult> { + get_table_columns_core(&state, &connection_id, &schema, &table).await +} + #[tauri::command] pub async fn get_table_constraints( - state: State<'_, AppState>, + state: State<'_, Arc>, 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>, 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>, connection_id: String, ) -> TuskResult>>> { 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>, connection_id: String, schema: String, table: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60f1bc7..8167f5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::>().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, diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs new file mode 100644 index 0000000..aaae5bb --- /dev/null +++ b/src-tauri/src/mcp/mod.rs @@ -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, + connections_path: PathBuf, + tool_router: ToolRouter, +} + +#[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, 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 { + let configs: Vec = 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 = 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + connections_path: PathBuf, + port: u16, +) -> Result<(), Box> { + 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(()) +}