sqlx-mcp 0.1.0

SQLx MCP Server - Secure multi-database CRUD operations via Model Context Protocol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! Initialization module for setting up MCP configurations
//!
//! Handles --init option for configuring multiple database connections
//! and MCP client integrations.

use crate::config::{
    ConnectionConfig, DatabaseEngine, DatabasesConfig, MySqlConnectionConfig,
    PostgresConnectionConfig, SslMode, SqliteConnectionConfig,
};
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;

/// Supported MCP clients
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Agent {
    ClaudeDesktop,
    ClaudeCode,
    Cursor,
}

impl Agent {
    fn name(&self) -> &'static str {
        match self {
            Agent::ClaudeDesktop => "Claude Desktop",
            Agent::ClaudeCode => "Claude Code",
            Agent::Cursor => "Cursor",
        }
    }

    fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "claude-desktop" | "claude_desktop" | "claudedesktop" => Some(Agent::ClaudeDesktop),
            "claude-code" | "claude_code" | "claudecode" | "claude" => Some(Agent::ClaudeCode),
            "cursor" => Some(Agent::Cursor),
            _ => None,
        }
    }

    fn config_path(&self) -> Option<PathBuf> {
        let home = dirs::home_dir()?;

        match self {
            Agent::ClaudeDesktop => {
                #[cfg(target_os = "macos")]
                {
                    Some(home.join("Library/Application Support/Claude/claude_desktop_config.json"))
                }
                #[cfg(target_os = "windows")]
                {
                    Some(home.join("AppData/Roaming/Claude/claude_desktop_config.json"))
                }
                #[cfg(target_os = "linux")]
                {
                    Some(home.join(".config/claude/claude_desktop_config.json"))
                }
            }
            Agent::ClaudeCode => Some(home.join(".claude.json")),
            Agent::Cursor => Some(home.join(".cursor/mcp.json")),
        }
    }

    fn skill_path(&self) -> Option<PathBuf> {
        if matches!(self, Agent::ClaudeCode) {
            let home = dirs::home_dir()?;
            Some(home.join(".claude/skills/sqlx/SKILL.md"))
        } else {
            None
        }
    }
}

/// Run the initialization wizard
pub fn run_init(agent_arg: Option<String>) -> anyhow::Result<()> {
    println!("\n🔧 SQLx MCP Server - Configuration Wizard\n");

    // Main menu
    let operations = vec![
        "Configure database connections",
        "Configure MCP clients",
        "Show current status",
    ];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("What would you like to do?")
        .items(&operations)
        .default(0)
        .interact()?;

    match selection {
        0 => configure_databases()?,
        1 => configure_agents(agent_arg)?,
        2 => show_status()?,
        _ => unreachable!(),
    }

    Ok(())
}

/// Configure database connections
fn configure_databases() -> anyhow::Result<()> {
    println!("\n📦 Database Connection Setup\n");

    // Load existing config or create new
    let mut config = DatabasesConfig::load().unwrap_or_default();

    // Show existing connections
    if !config.databases.is_empty() {
        println!("Existing connections:");
        for conn in &config.databases {
            let is_default = config.default_connection.as_ref() == Some(&conn.name().to_string());
            let default_marker = if is_default { " (default)" } else { "" };
            println!("{} ({}){}", conn.name(), conn.engine(), default_marker);
        }
        println!();
    }

    // Add databases loop
    loop {
        let add_more = if config.databases.is_empty() {
            true
        } else {
            Confirm::with_theme(&ColorfulTheme::default())
                .with_prompt("Add another database connection?")
                .default(false)
                .interact()?
        };

        if !add_more {
            break;
        }

        // Collect database configuration
        let conn = collect_database_config(&config)?;
        let conn_name = conn.name().to_string();

        // Check for duplicate name
        if config.databases.iter().any(|c| c.name() == conn_name) {
            println!("⚠️  Connection '{}' already exists. Replacing...", conn_name);
            config.databases.retain(|c| c.name() != conn_name);
        }

        config.databases.push(conn);

        // Set as default?
        if config.databases.len() == 1 {
            config.default_connection = Some(conn_name);
        } else {
            let set_default = Confirm::with_theme(&ColorfulTheme::default())
                .with_prompt("Set as default connection?")
                .default(false)
                .interact()?;

            if set_default {
                config.default_connection = Some(config.databases.last().unwrap().name().to_string());
            }
        }

        println!("✓ Connection added successfully\n");
    }

    // Save configuration
    if config.databases.is_empty() {
        println!("⚠️  No connections configured. Exiting without saving.");
        return Ok(());
    }

    let config_path = select_config_path()?;
    config.save(&config_path)?;
    println!("\n✅ Configuration saved to: {}", config_path.display());

    // Ask about AI agent configuration
    let configure_ai = Confirm::with_theme(&ColorfulTheme::default())
        .with_prompt("Would you like to configure MCP clients now?")
        .default(true)
        .interact()?;

    if configure_ai {
        configure_agents(None)?;
    }

    Ok(())
}

/// Collect database configuration from user input
fn collect_database_config(existing: &DatabasesConfig) -> anyhow::Result<ConnectionConfig> {
    // Select engine
    let engines = vec!["MySQL", "PostgreSQL", "SQLite"];
    let engine_selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Select database engine")
        .items(&engines)
        .default(0)
        .interact()?;

    let engine = match engine_selection {
        0 => DatabaseEngine::MySQL,
        1 => DatabaseEngine::Postgres,
        2 => DatabaseEngine::SQLite,
        _ => unreachable!(),
    };

    // Connection name
    let default_name = format!(
        "{}{}",
        engine.as_str(),
        if existing.databases.is_empty() {
            "".to_string()
        } else {
            format!("_{}", existing.databases.len() + 1)
        }
    );

    let name: String = Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Connection name (unique identifier)")
        .default(default_name)
        .interact_text()?;

    match engine {
        DatabaseEngine::MySQL => {
            let host: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("MySQL Host")
                .default("localhost".to_string())
                .interact_text()?;

            let port: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("MySQL Port")
                .default("3306".to_string())
                .interact_text()?;

            let username: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("MySQL Username")
                .interact_text()?;

            let password: String = Password::with_theme(&ColorfulTheme::default())
                .with_prompt("MySQL Password")
                .interact()?;

            let database: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("MySQL Database (optional, press Enter to skip)")
                .default("".to_string())
                .interact_text()?;

            Ok(ConnectionConfig::MySQL(MySqlConnectionConfig {
                name,
                host,
                port: port.parse().unwrap_or(3306),
                username,
                password,
                database: if database.is_empty() {
                    None
                } else {
                    Some(database)
                },
                ssl_mode: SslMode::Preferred,
                max_connections: 5,
                min_connections: 1,
                connect_timeout_secs: 30,
            }))
        }
        DatabaseEngine::Postgres => {
            let host: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("PostgreSQL Host")
                .default("localhost".to_string())
                .interact_text()?;

            let port: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("PostgreSQL Port")
                .default("5432".to_string())
                .interact_text()?;

            let username: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("PostgreSQL Username")
                .default("postgres".to_string())
                .interact_text()?;

            let password: String = Password::with_theme(&ColorfulTheme::default())
                .with_prompt("PostgreSQL Password")
                .interact()?;

            let database: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("PostgreSQL Database")
                .default("postgres".to_string())
                .interact_text()?;

            Ok(ConnectionConfig::Postgres(PostgresConnectionConfig {
                name,
                host,
                port: port.parse().unwrap_or(5432),
                username,
                password,
                database: if database.is_empty() {
                    None
                } else {
                    Some(database)
                },
                ssl_mode: SslMode::Preferred,
                max_connections: 5,
                min_connections: 1,
                connect_timeout_secs: 30,
            }))
        }
        DatabaseEngine::SQLite => {
            let path: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("SQLite database path (or ':memory:' for in-memory)")
                .interact_text()?;

            Ok(ConnectionConfig::SQLite(SqliteConnectionConfig {
                name,
                path,
                max_connections: 1,
            }))
        }
    }
}

/// Select where to save the configuration file
fn select_config_path() -> anyhow::Result<PathBuf> {
    let paths = DatabasesConfig::config_paths();
    let path_strings: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Where to save configuration?")
        .items(&path_strings)
        .default(0)
        .interact()?;

    Ok(paths[selection].clone())
}

/// Configure MCP clients
fn configure_agents(agent_arg: Option<String>) -> anyhow::Result<()> {
    println!("\n🤖 MCP Client Configuration\n");

    // Determine which agents to configure
    let selected_agents: Vec<Agent> = match agent_arg {
        Some(agent_name) => match Agent::from_str(&agent_name) {
            Some(agent) => vec![agent],
            None => {
                eprintln!("❌ Unknown agent: {}", agent_name);
                eprintln!();
                eprintln!("Available agents:");
                eprintln!("  claude-desktop  - Claude Desktop app");
                eprintln!("  claude-code     - Claude Code CLI");
                eprintln!("  cursor          - Cursor editor");
                std::process::exit(1);
            }
        },
        None => {
            let agents = vec!["Claude Desktop", "Claude Code", "Cursor", "All agents"];
            let selection = Select::with_theme(&ColorfulTheme::default())
                .with_prompt("Select MCP client to configure")
                .items(&agents)
                .default(0)
                .interact()?;

            match selection {
                0 => vec![Agent::ClaudeDesktop],
                1 => vec![Agent::ClaudeCode],
                2 => vec![Agent::Cursor],
                3 => vec![Agent::ClaudeDesktop, Agent::ClaudeCode, Agent::Cursor],
                _ => unreachable!(),
            }
        }
    };

    // Get binary path
    let binary_path = get_binary_path()?;

    // Configure each selected agent, collecting results
    let mut successful = Vec::new();
    let mut failed = Vec::new();

    for agent in &selected_agents {
        match configure_agent(*agent, &binary_path) {
            Ok(_) => successful.push(agent),
            Err(e) => {
                eprintln!("   ⚠️  Failed to configure {}: {}", agent.name(), e);
                failed.push((agent, e));
            }
        };
    }

    println!();

    // Show results
    if !successful.is_empty() {
        println!("✅ Successfully configured:");
        for agent in &successful {
            if let Some(path) = agent.config_path() {
                println!("{}{}", agent.name(), path.display());
            }
        }
    }

    if !failed.is_empty() {
        println!();
        println!("⚠️  Failed to configure:");
        for (agent, _) in &failed {
            println!("{}", agent.name());
        }
    }

    if failed.is_empty() {
        println!("\n✅ MCP client configuration complete!\n");
    } else {
        println!("\n⚠️  Configuration completed with {} error(s)\n", failed.len());
    }

    println!("Restart your MCP client to apply changes.\n");

    Ok(())
}

fn get_binary_path() -> anyhow::Result<String> {
    let current_exe = std::env::current_exe()?;
    let exe_str = current_exe.to_string_lossy().to_string();
    let default_path = if exe_str.contains("target/debug") {
        exe_str.replace("target/debug", "target/release")
    } else {
        exe_str
    };

    let path: String = Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Binary path")
        .default(default_path)
        .interact_text()?;

    Ok(path)
}

fn configure_agent(agent: Agent, binary_path: &str) -> anyhow::Result<()> {
    println!("\n📝 Configuring {}...", agent.name());

    let config_path = agent
        .config_path()
        .ok_or_else(|| anyhow::anyhow!("Could not determine config path for {}", agent.name()))?;

    // Create parent directories if needed
    if let Some(parent) = config_path.parent() {
        fs::create_dir_all(parent)?;
    }

    // Find .databases.json path
    let db_config_path = DatabasesConfig::config_paths()
        .into_iter()
        .find(|p| p.exists())
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| ".databases.json".to_string());

    // Build MCP server config - use .databases.json path as env variable
    let mcp_config = json!({
        "command": binary_path,
        "env": {
            "SQLX_MCP_CONFIG": db_config_path,
            "RUST_LOG": "info"
        }
    });

    // Read existing config or create new one
    let mut root_config: Value = if config_path.exists() {
        let content = fs::read_to_string(&config_path)?;
        serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
    } else {
        json!({})
    };

    // Merge MCP server config
    if root_config.get("mcpServers").is_none() {
        root_config["mcpServers"] = json!({});
    }
    root_config["mcpServers"]["sqlx"] = mcp_config;

    // Write config with pretty formatting
    let formatted = serde_json::to_string_pretty(&root_config)?;
    fs::write(&config_path, formatted)?;

    println!("   ✓ Config written to: {}", config_path.display());

    // Handle Claude Code skill file
    if let Some(skill_path) = agent.skill_path() {
        if let Some(parent) = skill_path.parent() {
            fs::create_dir_all(parent)?;
        }

        let skill_content = generate_skill_content();
        fs::write(&skill_path, skill_content)?;
        println!("   ✓ Skill written to: {}", skill_path.display());
    }

    Ok(())
}

/// Generate Claude Code skill content
fn generate_skill_content() -> String {
    r#"---
description: Execute database operations via MCP (MySQL, PostgreSQL, SQLite)
tags: [database, sql, crud, mcp]
---

# SQLx MCP

Multi-database operations via MCP tools (MySQL, PostgreSQL, SQLite).

## Tools

| Tool | Use For |
|------|---------|
| `db_query` | SELECT queries |
| `db_insert` | INSERT records |
| `db_update` | UPDATE records |
| `db_delete` | DELETE records |
| `db_list_tables` | List tables |
| `db_describe_table` | Table schema |
| `db_list_connections` | Show connections |
| `db_health_check` | Test connectivity |

## Examples

| Request | Tool | JSON |
|---------|------|------|
| Get active users from mysql_main | `db_query` | `{"query":"SELECT * FROM users WHERE status = ?","params":["active"],"connection":"mysql_main"}` |
| Show last 10 orders from postgres | `db_query` | `{"query":"SELECT * FROM orders ORDER BY created_at DESC LIMIT ?","params":[10],"connection":"postgres"}` |
| List tables in sqlite_cache | `db_list_tables` | `{"connection":"sqlite_cache"}` |
| Describe users table in mydb | `db_describe_table` | `{"table":"users","connection":"mydb"}` |
| Add user John to mysql_main | `db_insert` | `{"query":"INSERT INTO users (name) VALUES (?)","params":["John"],"connection":"mysql_main"}` |
| Update user id=5 to Jane in postgres | `db_update` | `{"query":"UPDATE users SET name = ? WHERE id = ?","params":["Jane",5],"connection":"postgres"}` |
| Delete order id=10 from mydb | `db_delete` | `{"query":"DELETE FROM orders WHERE id = ?","params":[10],"connection":"mydb"}` |

## Workflow

1. **Parse** → Extract connection name, operation, table, conditions
2. **Tool** → query/insert/update/delete based on intent
3. **Build** → Use `?` placeholders, pass values via `params` array
4. **Execute** → Report results or affected rows

## Connection Detection

Extract connection name from user request. If omitted, use default. Unclear? List connections first.

## Parameters

- **query/insert/update/delete**: `{"query":"SQL ? placeholders","params":[],"connection":"name"}`
- **list_tables/health_check**: `{"connection":"name"}`
- **describe_table**: `{"table":"name","connection":"name"}`

## Security

- `?` placeholders required for dynamic values
- Blocked: DROP, TRUNCATE, ALTER, CREATE, GRANT
- Single statement only (no semicolons)
"#
    .to_string()
}

/// Show current configuration status
pub fn show_status() -> anyhow::Result<()> {
    println!("\n📊 SQLx MCP Configuration Status\n");

    // Check databases.json
    println!("Database Configuration:");
    let paths = DatabasesConfig::config_paths();
    let mut found_config = false;

    for path in &paths {
        if path.exists() {
            found_config = true;
            println!("  ✅ Found: {}", path.display());

            if let Ok(config) = DatabasesConfig::load() {
                println!("     Connections: {}", config.databases.len());
                for conn in &config.databases {
                    let is_default =
                        config.default_connection.as_ref() == Some(&conn.name().to_string());
                    let default_marker = if is_default { " (default)" } else { "" };
                    println!("{} ({}){}", conn.name(), conn.engine(), default_marker);
                }
            }
            break;
        }
    }

    if !found_config {
        println!("  ❌ No configuration found");
        println!("     Expected locations:");
        for path in &paths {
            println!("{}", path.display());
        }
    }

    // Check AI agents
    println!("\nMCP Client Configuration:");
    for agent in [Agent::ClaudeDesktop, Agent::ClaudeCode, Agent::Cursor] {
        print!("  {} ", agent.name());

        let padding = 16 - agent.name().len();
        print!("{}", " ".repeat(padding));

        if let Some(path) = agent.config_path() {
            if path.exists() {
                if let Ok(content) = fs::read_to_string(&path) {
                    if let Ok(config) = serde_json::from_str::<Value>(&content) {
                        if config
                            .get("mcpServers")
                            .and_then(|s| s.get("sqlx"))
                            .is_some()
                        {
                            println!("✅ Configured");
                            println!("               └─ {}", path.display());
                            continue;
                        }
                    }
                }
                println!("⚠️  Config exists but SQLx not configured");
                println!("               └─ {}", path.display());
            } else {
                println!("❌ Not configured");
                println!("               └─ {}", path.display());
            }
        } else {
            println!("⚠️  Unknown config path");
        }
    }

    println!();
    println!("Run 'sqlx-mcp --init' to configure.");
    println!();
    Ok(())
}