diff --git a/.gitignore b/.gitignore index 4b67b80..da54acd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ __pycache__/ /.playwright-cli/ /examples/*/dist/ + +# Harness integration build artifacts +integrations/*/node_modules/ +integrations/*/*.tgz +integrations/*/package-lock.json diff --git a/crates/cli/src/execution.rs b/crates/cli/src/execution.rs index 7698bf6..cabbf93 100644 --- a/crates/cli/src/execution.rs +++ b/crates/cli/src/execution.rs @@ -115,6 +115,28 @@ pub fn run( source: Datasource, action: Action, statements: Vec, +) -> Result { + run_to( + io::stdout().lock(), + root, + manifest, + local, + source, + action, + statements, + ) +} +/// Run an execution and write its JSON event stream to `output`. +/// +/// The CLI passes stdout; the MCP server passes a buffer so protocol output stays clean. +pub fn run_to( + mut output: W, + root: PathBuf, + manifest: String, + local: Option, + source: Datasource, + action: Action, + statements: Vec, ) -> Result { let id = source.id.clone(); let prepared = prepare(root, manifest, local, source, action, statements)?; @@ -128,7 +150,6 @@ pub fn run( let _ = tokio::signal::ctrl_c().await; signal.cancel(); }); - let mut output = io::stdout().lock(); write!( output, "{{\"protocol_version\":1,\"datasource_id\":{},\"events\":[", diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index e69a755..81b592d 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,5 +1,6 @@ pub mod components; pub mod execution; +pub mod mcp; pub mod plugins; pub mod prefetch; pub mod storage; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9741762..97cbc9a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,5 @@ mod skill; -use sqlx_core::{components, execution, plugins, prefetch, storage, ui, updates}; +use sqlx_core::{components, execution, mcp, plugins, prefetch, storage, ui, updates}; use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Parser, Subcommand}; @@ -39,6 +39,8 @@ struct Cli { #[derive(Subcommand)] enum Commands { Init, + /// Serve the Model Context Protocol over stdio so agent harnesses can call SQLX as a tool. + Mcp, /// Download database workers, the JDBC runtime and the browser UI before they are needed. Prefetch { /// Components to download: mysql, postgres, oracle, sqlserver, ui, skill or all. @@ -224,7 +226,7 @@ fn main() { && io::stdout().is_terminal() && !matches!( &cli.command, - Commands::Update { .. } | Commands::Init | Commands::Prefetch { .. } + Commands::Update { .. } | Commands::Init | Commands::Prefetch { .. } | Commands::Mcp ) && cli.worker_dir.is_none() && std::env::var("SQLX_NO_UPDATE_CHECK").ok().as_deref() != Some("1"); @@ -272,6 +274,10 @@ fn run(cli: Cli) -> Result { let store = Store::open(root)?; print(json!({"initialized":true,"identity":store.identity})); } + Commands::Mcp => { + mcp::serve(root, cli.manifest, cli.worker_dir)?; + return Ok(true); + } Commands::Prefetch { components } => { let (report, complete) = prefetch::run(&root, &cli.manifest, &components)?; print(report); diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs new file mode 100644 index 0000000..92b3768 --- /dev/null +++ b/crates/cli/src/mcp.rs @@ -0,0 +1,415 @@ +//! Minimal Model Context Protocol server over stdio, exposing SQLX tools to agent harnesses. +//! +//! The harness (Claude Code, Codex, or a thin native-tool shim) launches `sqlx mcp`; SQLX keeps +//! protocol output on stdout, so execution results are rendered into the tool response instead of +//! being streamed as CLI JSON. Downloads and worker diagnostics still go to stderr. +use crate::{ + execution, prefetch, + storage::{Datasource, Store}, + ui, +}; +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use sqlx_protocol::Action; +use std::{ + io::{BufRead, Write}, + path::{Path, PathBuf}, +}; + +/// Protocol revision used when the client does not request one. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; + +pub fn serve(root: PathBuf, manifest: String, local: Option) -> Result<()> { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let request: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(error) => { + respond( + &mut stdout, + &json!({ + "jsonrpc": "2.0", + "id": Value::Null, + "error": {"code": -32700, "message": format!("parse error: {error}")}, + }), + )?; + continue; + } + }; + if let Some(response) = handle(&request, &root, &manifest, &local) { + respond(&mut stdout, &response)?; + } + } + Ok(()) +} +fn respond(output: &mut impl Write, value: &Value) -> Result<()> { + serde_json::to_writer(&mut *output, value)?; + output.write_all(b"\n")?; + output.flush()?; + Ok(()) +} +/// Handle one JSON-RPC message. Notifications (no `id`) produce no response. +fn handle(request: &Value, root: &Path, manifest: &str, local: &Option) -> Option { + let id = request.get("id").cloned(); + let method = request["method"].as_str().unwrap_or_default(); + id.as_ref()?; + let id = id.unwrap_or(Value::Null); + let params = request.get("params").cloned().unwrap_or_else(|| json!({})); + let result = match method { + "initialize" => Ok(json!({ + "protocolVersion": params + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION), + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": { + "name": "sqlx", + "title": "OtterMind SQLX", + "version": env!("CARGO_PKG_VERSION"), + }, + })), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tools()})), + "tools/call" => call(params, root, manifest, local), + other => { + return Some(json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": format!("method not found: {other}")}, + })) + } + }; + Some(match result { + Ok(value) => json!({"jsonrpc": "2.0", "id": id, "result": value}), + Err(error) => json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{"type": "text", "text": format!("{error:#}")}], + "isError": true, + }, + }), + }) +} +fn call(params: Value, root: &Path, manifest: &str, local: &Option) -> Result { + let name = params["name"].as_str().context("missing tool name")?; + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + let value = match name { + "sqlx_datasource_list" => { + let store = Store::open(root.to_path_buf())?; + json!({"datasources": store.load()?.iter().map(Datasource::public).collect::>()}) + } + "sqlx_datasource_show" => { + let id = string_arg(&arguments, "id")?; + Store::open(root.to_path_buf())?.find(&id)?.public() + } + "sqlx_datasource_test" => { + let id = string_arg(&arguments, "id")?; + let source = Store::open(root.to_path_buf())?.find(&id)?; + run_action(root, manifest, local, source, Action::Test, vec![])? + } + "sqlx_sql_execute" => { + let source = datasource_arg(root, &arguments)?; + let statements = statements_arg(&arguments)?; + run_action(root, manifest, local, source, Action::Execute, statements)? + } + "sqlx_sql_view" => { + let source = datasource_arg(root, &arguments)?; + let statements = statements_arg(&arguments)?; + let client = + ui::UiClient::start(root.to_path_buf(), manifest.to_owned(), local.clone())?; + let mut result = client.post( + "/api/results", + &ui::ViewRequest { + request_id: uuid::Uuid::new_v4().to_string(), + datasource: source.id, + statements, + }, + )?; + result["url"] = client + .page( + &format!( + "/result/{}", + result["result_id"].as_str().context("missing result id")? + ), + false, + )? + .into(); + result + } + "sqlx_prefetch" => { + let components = arguments + .get("components") + .and_then(Value::as_array) + .context("components must be an array")? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .context("components must be strings") + }) + .collect::>>()?; + let (report, complete) = prefetch::run(root, manifest, &components)?; + json!({"complete": complete, "report": report}) + } + other => bail!("unknown tool {other}"), + }; + Ok(json!({ + "content": [{"type": "text", "text": serde_json::to_string_pretty(&value)?}], + "isError": false, + })) +} +fn run_action( + root: &Path, + manifest: &str, + local: &Option, + source: Datasource, + action: Action, + statements: Vec, +) -> Result { + let mut buffer = Vec::new(); + let success = execution::run_to( + &mut buffer, + root.to_path_buf(), + manifest.to_owned(), + local.clone(), + source, + action, + statements, + )?; + let events: Value = + serde_json::from_slice(&buffer).context("execution returned invalid JSON")?; + Ok(json!({"success": success, "execution": events})) +} +fn string_arg(arguments: &Value, name: &str) -> Result { + arguments + .get(name) + .and_then(Value::as_str) + .with_context(|| format!("missing string argument {name}")) + .map(str::to_owned) +} +fn statements_arg(arguments: &Value) -> Result> { + let statements = arguments + .get("statements") + .and_then(Value::as_array) + .context("statements must be an array")? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .context("statements must be strings") + }) + .collect::>>()?; + if statements + .iter() + .any(|statement| statement.trim().is_empty()) + { + bail!("SQL statements must not be empty"); + } + Ok(statements) +} +fn datasource_arg(root: &Path, arguments: &Value) -> Result { + let id = string_arg(arguments, "datasource")?; + Store::open(root.to_path_buf())?.find(&id) +} +fn tools() -> Vec { + vec![ + json!({ + "name": "sqlx_datasource_list", + "title": "List SQLX datasources", + "description": "List the saved SQLX datasources with their non-secret connection settings. Never returns usernames or passwords.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}, + "annotations": {"readOnlyHint": true, "openWorldHint": false}, + }), + json!({ + "name": "sqlx_datasource_show", + "title": "Show a SQLX datasource", + "description": "Show one saved datasource by its stable ID or unique name.", + "inputSchema": { + "type": "object", + "properties": {"id": {"type": "string", "description": "Datasource UUID or unique name"}}, + "required": ["id"], + "additionalProperties": false, + }, + "annotations": {"readOnlyHint": true, "openWorldHint": false}, + }), + json!({ + "name": "sqlx_datasource_test", + "title": "Test a SQLX connection", + "description": "Open one connection and report whether the datasource is reachable. Executes no SQL.", + "inputSchema": { + "type": "object", + "properties": {"id": {"type": "string", "description": "Datasource UUID or unique name"}}, + "required": ["id"], + "additionalProperties": false, + }, + "annotations": {"readOnlyHint": true, "openWorldHint": true}, + }), + json!({ + "name": "sqlx_sql_execute", + "title": "Execute SQL with SQLX", + "description": "Execute one or more complete SQL statements through SQLX and return the full structured result. Each statement is a separate driver statement and the batch stops at the first error. Statements may modify data: only call this after the user authorized that exact operation and scope. Results are never replayed automatically.", + "inputSchema": { + "type": "object", + "properties": { + "datasource": {"type": "string", "description": "Datasource UUID or unique name"}, + "statements": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "description": "Complete SQL statements, executed in order on one connection", + }, + }, + "required": ["datasource", "statements"], + "additionalProperties": false, + }, + "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": true}, + }), + json!({ + "name": "sqlx_sql_view", + "title": "Show SQL results in the local page", + "description": "Execute the statements once in the local SQLX service and return a local result-page URL for the user. The same authorization rule as sqlx_sql_execute applies, and the page's Refresh action reruns the batch.", + "inputSchema": { + "type": "object", + "properties": { + "datasource": {"type": "string", "description": "Datasource UUID or unique name"}, + "statements": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "description": "Complete SQL statements, executed in order on one connection", + }, + }, + "required": ["datasource", "statements"], + "additionalProperties": false, + }, + "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": true}, + }), + json!({ + "name": "sqlx_prefetch", + "title": "Prefetch SQLX components", + "description": "Download the components that would otherwise be fetched during a first query or page: mysql, postgres, oracle, sqlserver, ui, skill or all. Progress and speed are reported through the MCP client's server log.", + "inputSchema": { + "type": "object", + "properties": { + "components": { + "type": "array", + "items": { + "type": "string", + "enum": ["mysql", "postgres", "oracle", "sqlserver", "ui", "skill", "all"], + }, + "minItems": 1, + } + }, + "required": ["components"], + "additionalProperties": false, + }, + "annotations": {"readOnlyHint": false, "destructiveHint": false, "openWorldHint": true}, + }), + ] +} +#[cfg(test)] +mod tests { + use super::*; + fn request(id: u64, method: &str, params: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + } + #[test] + fn tools_declare_authorization_semantics() { + let tools = tools(); + let names: Vec<&str> = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + assert_eq!( + names, + [ + "sqlx_datasource_list", + "sqlx_datasource_show", + "sqlx_datasource_test", + "sqlx_sql_execute", + "sqlx_sql_view", + "sqlx_prefetch", + ] + ); + for tool in &tools { + let annotations = &tool["annotations"]; + assert!(annotations["readOnlyHint"].is_boolean(), "{tool}"); + assert_eq!(tool["inputSchema"]["type"], "object", "{tool}"); + } + let execute = &tools[3]; + assert_eq!(execute["annotations"]["readOnlyHint"], false); + assert_eq!(execute["annotations"]["destructiveHint"], true); + assert!( + execute["description"] + .as_str() + .unwrap() + .contains("authorized"), + "write tools must state the authorization requirement" + ); + } + #[test] + fn initialize_negotiates_the_client_protocol() { + let root = PathBuf::from("/tmp/sqlx-mcp-test"); + let response = handle( + &request(1, "initialize", json!({"protocolVersion": "2024-11-05"})), + &root, + "unused", + &None, + ) + .unwrap(); + assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); + assert_eq!(response["result"]["serverInfo"]["name"], "sqlx"); + assert_eq!( + response["result"]["capabilities"]["tools"]["listChanged"], + false + ); + } + #[test] + fn notifications_and_unknown_methods_are_handled() { + let root = PathBuf::from("/tmp/sqlx-mcp-test"); + assert!(handle( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + &root, + "unused", + &None + ) + .is_none()); + let response = handle( + &request(2, "resources/list", json!({})), + &root, + "unused", + &None, + ) + .unwrap(); + assert_eq!(response["error"]["code"], -32601); + } + #[test] + fn tool_failures_are_results_not_protocol_errors() { + let root = PathBuf::from("/tmp/sqlx-mcp-test"); + let response = handle( + &request( + 3, + "tools/call", + json!({"name": "sqlx_datasource_show", "arguments": {"id": "missing"}}), + ), + &root, + "unused", + &None, + ) + .unwrap(); + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"]["isError"], true); + assert!(response["result"]["content"][0]["text"].is_string()); + } +} diff --git a/integrations/README.md b/integrations/README.md new file mode 100644 index 0000000..d17d810 --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,65 @@ +# SQLX agent-harness integrations + +Native integrations that let an agent harness call SQLX as a tool instead of shelling out to the +CLI. All four share one tool surface: `sqlx mcp` (stdio MCP server) for MCP-capable harnesses, and +thin native-tool packages for harnesses that register tools themselves. + +| Harness | Directory | Tool transport | Install | +|---|---|---|---| +| Claude Code | `claude/` | plugin `.mcp.json` → `sqlx mcp` | `claude plugin marketplace add ` then `claude plugin install sqlx@ottermind` (or `--plugin-dir` while developing) | +| Codex | `codex/` | plugin `.mcp.json` → `sqlx mcp` | `codex plugin marketplace add ` then `codex plugin add sqlx@ottermind` | +| DeepSeek Harness | `dsh/` | native `defineTool` tools | `dsh plugin --profile add @ottermind/dsh-sqlx` | +| Pi | `pi/` | native `registerTool` extension | `pi install npm:@ottermind/pi-sqlx` | + +## Requirements + +The `sqlx` CLI (0.1.8 or later, which provides `sqlx mcp`) must be installed and on `PATH`. +`SQLX_BIN` selects a specific executable; otherwise the first `sqlx` on `PATH` is used. + +## Tools + +| Tool | Purpose | Authorization | +|---|---|---| +| `sqlx_datasource_list` | List saved datasources (no secrets) | read-only | +| `sqlx_datasource_show` | Show one datasource | read-only | +| `sqlx_datasource_test` | Test connectivity | read-only | +| `sqlx_sql_execute` | Execute statements, return the full result | may modify data; ask the user first | +| `sqlx_sql_view` | Execute once and return a local result-page URL | may modify data; ask the user first | +| `sqlx_prefetch` | Download workers/JDBC/UI components | downloads only | + +Write-capable tools are marked `destructiveHint` in MCP and say so in their descriptions, so a +harness that gates destructive tools (Codex does by default) asks the user before running them. + +## Credentials + +Store credentials in the SQLX data directory (`sqlx datasource add --connection-stdin`, or the +local `--ui` page). Datasources created with `--username-env`/`--password-env` read environment +variables at execution time, and a GUI-launched harness usually cannot see the user's shell +environment; MCP `env_vars` allowlists (Codex) make that explicit. + +## Verified end-to-end (2026-09-20) + +Each harness ran the same task headlessly: list datasources, then execute a read-only `SELECT` +against a PostgreSQL datasource and reply with the exact `big` value. All four returned +`9007199254740993` unchanged. Codex, Claude Code and Pi were re-verified after upgrading to +codex-cli 0.155.1, Claude Code 2.1.278 and Pi 0.86.1 respectively. + +| Harness | Command that was verified | +|---|---| +| Codex CLI 0.155.1 (latest) | `codex plugin marketplace add ` → `codex plugin add sqlx@ottermind` → `codex exec --skip-git-repo-check ""` | +| DeepSeek Harness 0.1.5-rc.1 | `dsh plugin --profile sqlxtest add ` → `dsh --profile sqlxtest ""` | +| Claude Code 2.1.278 | `claude --plugin-dir integrations/claude/plugins/sqlx --allowedTools "mcp__plugin_sqlx_sqlx__*" -p ""` | +| Pi 0.86.1 | `pi -e integrations/pi/extensions/sqlx.ts -e .ts --provider deepseek --model deepseek-flash --no-session -p ""` | + +Notes from the verification: + +- **Codex gates write tools.** Read-only tools ran without approval; `sqlx_sql_execute` was refused + with `MCP tool call requires approval, but approval policy is never` until approvals were granted. + That is the intended behaviour: keep the default and let the user approve. +- **Claude Code needs an explicit tool allowlist in headless mode**, e.g. + `--allowedTools "mcp__plugin_sqlx_sqlx__*"`, otherwise the MCP call returns without permission. +- **Pi needs a model provider.** Any OpenAI-compatible provider works; registering DeepSeek looks + like this in a second `-e` extension: + `pi.registerProvider("deepseek", { baseUrl: "https://api.deepseek.com", apiKey: "$DEEPSEEK_API_KEY", api: "openai-completions", models: [{ id: "deepseek-flash", name: "DeepSeek Flash", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 8192 }] })`. +- **Claude Code can use DeepSeek's Anthropic-compatible endpoint**: `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` + with `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_MODEL=deepseek-flash[1m]`. diff --git a/integrations/claude/.claude-plugin/marketplace.json b/integrations/claude/.claude-plugin/marketplace.json new file mode 100644 index 0000000..dc4c43e --- /dev/null +++ b/integrations/claude/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "ottermind", + "owner": { + "name": "OtterMind" + }, + "plugins": [ + { + "name": "sqlx", + "source": "./plugins/sqlx", + "description": "List saved OtterMind SQLX datasources, test connections, execute SQL against MySQL, PostgreSQL, Oracle and SQL Server, and open local result pages. Credentials stay in the SQLX data directory." + } + ], + "description": "OtterMind plugins for Claude Code. Currently ships SQLX for MySQL, PostgreSQL, Oracle and SQL Server." +} diff --git a/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json b/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json new file mode 100644 index 0000000..317ad91 --- /dev/null +++ b/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "sqlx", + "description": "List saved OtterMind SQLX datasources, test connections, execute SQL against MySQL, PostgreSQL, Oracle and SQL Server, and open local result pages. Credentials stay in the SQLX data directory. Requires the sqlx CLI (0.1.8+) on PATH.", + "version": "0.1.8", + "author": { + "name": "OtterMind" + }, + "homepage": "https://github.com/OtterMind/sqlx", + "repository": "https://github.com/OtterMind/sqlx", + "license": "LicenseRef-Chat2DB", + "keywords": [ + "database", + "sql", + "mysql", + "postgresql", + "oracle", + "sqlserver", + "mcp" + ] +} diff --git a/integrations/claude/plugins/sqlx/.mcp.json b/integrations/claude/plugins/sqlx/.mcp.json new file mode 100644 index 0000000..980e2bd --- /dev/null +++ b/integrations/claude/plugins/sqlx/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sqlx": { + "command": "${CLAUDE_PLUGIN_ROOT}/bin/sqlx-mcp", + "args": [] + } + } +} diff --git a/integrations/claude/plugins/sqlx/bin/sqlx-mcp b/integrations/claude/plugins/sqlx/bin/sqlx-mcp new file mode 100755 index 0000000..c7d9818 --- /dev/null +++ b/integrations/claude/plugins/sqlx/bin/sqlx-mcp @@ -0,0 +1,5 @@ +#!/bin/sh +# Launch the OtterMind SQLX CLI as an MCP stdio server. +# SQLX_BIN selects a specific executable; otherwise the first `sqlx` on PATH is used. +set -eu +exec "${SQLX_BIN:-sqlx}" mcp "$@" diff --git a/integrations/codex/.agents/plugins/marketplace.json b/integrations/codex/.agents/plugins/marketplace.json new file mode 100644 index 0000000..a08b071 --- /dev/null +++ b/integrations/codex/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "ottermind", + "interface": { + "displayName": "OtterMind" + }, + "plugins": [ + { + "name": "sqlx", + "source": { + "source": "local", + "path": "./plugins/sqlx" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_USE" + }, + "category": "Developer Tools" + } + ] +} diff --git a/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json b/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json new file mode 100644 index 0000000..309c36f --- /dev/null +++ b/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "sqlx", + "version": "0.1.8", + "description": "List saved OtterMind SQLX datasources, test connections, execute SQL against MySQL, PostgreSQL, Oracle and SQL Server, and open local result pages. Credentials stay in the SQLX data directory.", + "author": { + "name": "OtterMind" + }, + "homepage": "https://github.com/OtterMind/sqlx", + "repository": "https://github.com/OtterMind/sqlx", + "license": "LicenseRef-Chat2DB", + "keywords": [ + "database", + "sql", + "mysql", + "postgresql", + "oracle", + "sqlserver", + "mcp" + ], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "OtterMind SQLX", + "shortDescription": "Run SQL through saved encrypted connections", + "longDescription": "List saved OtterMind SQLX datasources, test connections, execute SQL against MySQL, PostgreSQL, Oracle and SQL Server, and open local result pages. Credentials stay in the SQLX data directory.", + "developerName": "OtterMind", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/OtterMind/sqlx", + "defaultPrompt": [ + "List my SQLX datasources.", + "Test the SQLX connection I name and run a read-only query on it." + ] + } +} diff --git a/integrations/codex/plugins/sqlx/.mcp.json b/integrations/codex/plugins/sqlx/.mcp.json new file mode 100644 index 0000000..f62486a --- /dev/null +++ b/integrations/codex/plugins/sqlx/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "sqlx": { + "command": "./bin/sqlx-mcp", + "args": [], + "cwd": ".", + "env_vars": [ + "SQLX_BIN", + "SQLX_DATA_DIR", + "SQLX_MANIFEST", + "SQLX_NO_UPDATE_CHECK", + "PATH" + ] + } + } +} diff --git a/integrations/codex/plugins/sqlx/bin/sqlx-mcp b/integrations/codex/plugins/sqlx/bin/sqlx-mcp new file mode 100755 index 0000000..c7d9818 --- /dev/null +++ b/integrations/codex/plugins/sqlx/bin/sqlx-mcp @@ -0,0 +1,5 @@ +#!/bin/sh +# Launch the OtterMind SQLX CLI as an MCP stdio server. +# SQLX_BIN selects a specific executable; otherwise the first `sqlx` on PATH is used. +set -eu +exec "${SQLX_BIN:-sqlx}" mcp "$@" diff --git a/integrations/dsh/cordis.patch.yml b/integrations/dsh/cordis.patch.yml new file mode 100644 index 0000000..a4bd6c2 --- /dev/null +++ b/integrations/dsh/cordis.patch.yml @@ -0,0 +1,8 @@ +# Bundle patch for @ottermind/dsh-sqlx. +# +# `dsh plugin --profile add @ottermind/dsh-sqlx` installs the package and, because this +# file is declared as `dsh.bundle.patch`, appends the package to the profile's bundle stack. +# The row below mounts the plugin module; no profile file edits are required. +- insert: + - id: sqlx + name: '@ottermind/dsh-sqlx' diff --git a/integrations/dsh/lib/index.js b/integrations/dsh/lib/index.js new file mode 100644 index 0000000..8ea6560 --- /dev/null +++ b/integrations/dsh/lib/index.js @@ -0,0 +1,83 @@ +// OtterMind SQLX tools for DeepSeek Harness. +// +// Every tool shells out to the `sqlx` CLI and returns its JSON result, so the plugin stays a thin +// adapter: credentials, TLS policy, worker downloads and result pages remain CLI responsibilities. +// SQLX_BIN selects a specific executable; otherwise the first `sqlx` on PATH is used. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { defineTool } from "@deepseek-ai/dsh-tools"; + +const execFileAsync = promisify(execFile); +const name = "dsh-sqlx"; +const inject = ["tools"]; + +async function run(args) { + const { stdout } = await execFileAsync(process.env.SQLX_BIN || "sqlx", args, { + maxBuffer: 64 * 1024 * 1024, + env: process.env, + }); + return JSON.parse(stdout); +} +function text(value) { + return [{ type: "text", text: JSON.stringify(value, null, 2) }]; +} +function apply(ctx) { + ctx.tools.register(defineTool({ + name: "sqlx_datasource_list", + description: "List the saved SQLX datasources with their non-secret connection settings.", + parameters: {}, + output: { schema: { type: "object", additionalProperties: true, properties: {} }, render: (_args, value) => text(value) }, + async execute() { + return (await run(["datasource", "list"])).data; + }, + })); + ctx.tools.register(defineTool({ + name: "sqlx_datasource_test", + description: "Open one connection through SQLX and report whether the datasource is reachable. Executes no SQL.", + parameters: { id: { type: "string", required: true, description: "Datasource UUID or unique name" } }, + output: { schema: { type: "object", additionalProperties: true, properties: {} }, render: (_args, value) => text(value) }, + async execute(args) { + return await run(["datasource", "test", "--id", args.id]); + }, + })); + ctx.tools.register(defineTool({ + name: "sqlx_sql_execute", + description: "Execute one or more complete SQL statements through SQLX and return the full structured result. Statements may modify data: call this only after the user authorized that exact operation and scope. Results are never replayed automatically.", + parameters: { + datasource: { type: "string", required: true, description: "Datasource UUID or unique name" }, + statements: { type: "array", items: { type: "string" }, required: true, description: "Complete SQL statements, executed in order on one connection" }, + }, + output: { schema: { type: "object", additionalProperties: true, properties: {} }, render: (_args, value) => text(value) }, + async execute(args) { + const command = ["sql", "execute", "--datasource", args.datasource]; + for (const statement of args.statements) command.push("--sql", statement); + return await run(command); + }, + })); + ctx.tools.register(defineTool({ + name: "sqlx_sql_view", + description: "Execute the statements once in the local SQLX service and return a local result-page URL for the user. The same authorization rule as sqlx_sql_execute applies, and the page's Refresh action reruns the batch.", + parameters: { + datasource: { type: "string", required: true, description: "Datasource UUID or unique name" }, + statements: { type: "array", items: { type: "string" }, required: true, description: "Complete SQL statements, executed in order on one connection" }, + }, + output: { schema: { type: "object", additionalProperties: true, properties: {} }, render: (_args, value) => text(value) }, + async execute(args) { + const command = ["sql", "execute", "--datasource", args.datasource, "--view", "--no-open"]; + for (const statement of args.statements) command.push("--sql", statement); + return await run(command); + }, + })); + ctx.tools.register(defineTool({ + name: "sqlx_prefetch", + description: "Download the SQLX components that would otherwise be fetched during a first query or page: mysql, postgres, oracle, sqlserver, ui, skill or all.", + parameters: { + components: { type: "array", items: { type: "string" }, required: true, description: "mysql, postgres, oracle, sqlserver, ui, skill or all" }, + }, + output: { schema: { type: "object", additionalProperties: true, properties: {} }, render: (_args, value) => text(value) }, + async execute(args) { + return await run(["prefetch", ...args.components]); + }, + })); +} +export { name, inject, apply }; diff --git a/integrations/dsh/package.json b/integrations/dsh/package.json new file mode 100644 index 0000000..be731d4 --- /dev/null +++ b/integrations/dsh/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ottermind/dsh-sqlx", + "version": "0.1.8", + "description": "OtterMind SQLX tools for DeepSeek Harness: list datasources, test connections, execute SQL and open local result pages.", + "type": "module", + "main": "lib/index.js", + "files": [ + "lib", + "cordis.patch.yml", + "README.md" + ], + "license": "LicenseRef-Chat2DB", + "repository": { + "type": "git", + "url": "git+https://github.com/OtterMind/sqlx.git", + "directory": "integrations/dsh" + }, + "engines": { + "dsh": ">=0.1.5" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "peerDependencies": { + "@deepseek-ai/dsh-tools": "^0.1.5-rc.1" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-tools": { + "optional": false + } + } +} diff --git a/integrations/pi/extensions/sqlx.ts b/integrations/pi/extensions/sqlx.ts new file mode 100644 index 0000000..9df0122 --- /dev/null +++ b/integrations/pi/extensions/sqlx.ts @@ -0,0 +1,99 @@ +/** + * OtterMind SQLX tools for Pi. + * + * Each tool shells out to the `sqlx` CLI and returns its JSON result, so this extension stays a + * thin adapter: credentials, TLS policy, worker downloads and result pages remain CLI concerns. + * SQLX_BIN selects a specific executable; otherwise the first `sqlx` on PATH is used. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const execFileAsync = promisify(execFile); + +async function sqlx(args: string[]): Promise { + const { stdout } = await execFileAsync(process.env.SQLX_BIN || "sqlx", args, { + maxBuffer: 64 * 1024 * 1024, + env: process.env, + }); + return JSON.parse(stdout); +} +function result(value: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "sqlx_datasource_list", + label: "SQLX datasources", + description: "List the saved OtterMind SQLX datasources with their non-secret connection settings.", + parameters: Type.Object({}), + async execute() { + return result((await sqlx(["datasource", "list"])).data); + }, + }); + + pi.registerTool({ + name: "sqlx_datasource_test", + label: "SQLX connection test", + description: "Open one connection through SQLX and report whether the datasource is reachable. Executes no SQL.", + parameters: Type.Object({ + id: Type.String({ description: "Datasource UUID or unique name" }), + }), + async execute(_toolCallId, params) { + return result(await sqlx(["datasource", "test", "--id", params.id])); + }, + }); + + pi.registerTool({ + name: "sqlx_sql_execute", + label: "SQLX SQL execution", + description: + "Execute one or more complete SQL statements through SQLX and return the full structured result. Statements may modify data: call this only after the user authorized that exact operation and scope. Results are never replayed automatically.", + parameters: Type.Object({ + datasource: Type.String({ description: "Datasource UUID or unique name" }), + statements: Type.Array(Type.String(), { + description: "Complete SQL statements, executed in order on one connection", + }), + }), + async execute(_toolCallId, params) { + const args = ["sql", "execute", "--datasource", params.datasource]; + for (const statement of params.statements) args.push("--sql", statement); + return result(await sqlx(args)); + }, + }); + + pi.registerTool({ + name: "sqlx_sql_view", + label: "SQLX result page", + description: + "Execute the statements once in the local SQLX service and return a local result-page URL for the user. The same authorization rule as sqlx_sql_execute applies, and the page's Refresh action reruns the batch.", + parameters: Type.Object({ + datasource: Type.String({ description: "Datasource UUID or unique name" }), + statements: Type.Array(Type.String(), { + description: "Complete SQL statements, executed in order on one connection", + }), + }), + async execute(_toolCallId, params) { + const args = ["sql", "execute", "--datasource", params.datasource, "--view", "--no-open"]; + for (const statement of params.statements) args.push("--sql", statement); + return result(await sqlx(args)); + }, + }); + + pi.registerTool({ + name: "sqlx_prefetch", + label: "SQLX prefetch", + description: + "Download the SQLX components that would otherwise be fetched during a first query or page: mysql, postgres, oracle, sqlserver, ui, skill or all.", + parameters: Type.Object({ + components: Type.Array(Type.String(), { + description: "mysql, postgres, oracle, sqlserver, ui, skill or all", + }), + }), + async execute(_toolCallId, params) { + return result(await sqlx(["prefetch", ...params.components])); + }, + }); +} diff --git a/integrations/pi/package.json b/integrations/pi/package.json new file mode 100644 index 0000000..6af6d72 --- /dev/null +++ b/integrations/pi/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ottermind/pi-sqlx", + "version": "0.1.8", + "description": "OtterMind SQLX tools for the Pi coding agent: list datasources, test connections, execute SQL and open local result pages.", + "keywords": [ + "pi-package", + "sqlx", + "database", + "sql" + ], + "license": "LicenseRef-Chat2DB", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/OtterMind/sqlx.git", + "directory": "integrations/pi" + }, + "pi": { + "extensions": [ + "./extensions" + ] + }, + "dependencies": { + "typebox": "^1.3.27" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +}