From 20d90008a5d824322fe89ed4a457ef19c01d22aa Mon Sep 17 00:00:00 2001 From: zgq Date: Sun, 20 Sep 2026 18:10:01 +0800 Subject: [PATCH 1/4] Add sqlx mcp and native harness integrations --- .gitignore | 4 + crates/cli/src/execution.rs | 23 +- crates/cli/src/lib.rs | 1 + crates/cli/src/main.rs | 10 +- crates/cli/src/mcp.rs | 415 ++++++++++++++++++ integrations/README.md | 49 +++ .../claude/.claude-plugin/marketplace.json | 14 + .../plugins/sqlx/.claude-plugin/plugin.json | 20 + integrations/claude/plugins/sqlx/.mcp.json | 8 + integrations/claude/plugins/sqlx/bin/sqlx-mcp | 5 + .../codex/.agents/plugins/marketplace.json | 20 + .../plugins/sqlx/.codex-plugin/plugin.json | 37 ++ integrations/codex/plugins/sqlx/.mcp.json | 16 + integrations/codex/plugins/sqlx/bin/sqlx-mcp | 5 + integrations/dsh/cordis.patch.yml | 8 + integrations/dsh/lib/index.js | 83 ++++ integrations/dsh/package.json | 34 ++ integrations/pi/extensions/sqlx.ts | 99 +++++ integrations/pi/package.json | 29 ++ 19 files changed, 877 insertions(+), 3 deletions(-) create mode 100644 crates/cli/src/mcp.rs create mode 100644 integrations/README.md create mode 100644 integrations/claude/.claude-plugin/marketplace.json create mode 100644 integrations/claude/plugins/sqlx/.claude-plugin/plugin.json create mode 100644 integrations/claude/plugins/sqlx/.mcp.json create mode 100755 integrations/claude/plugins/sqlx/bin/sqlx-mcp create mode 100644 integrations/codex/.agents/plugins/marketplace.json create mode 100644 integrations/codex/plugins/sqlx/.codex-plugin/plugin.json create mode 100644 integrations/codex/plugins/sqlx/.mcp.json create mode 100755 integrations/codex/plugins/sqlx/bin/sqlx-mcp create mode 100644 integrations/dsh/cordis.patch.yml create mode 100644 integrations/dsh/lib/index.js create mode 100644 integrations/dsh/package.json create mode 100644 integrations/pi/extensions/sqlx.ts create mode 100644 integrations/pi/package.json diff --git a/.gitignore b/.gitignore index 4b67b80..588db55 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ __pycache__/ /.playwright-cli/ /examples/*/dist/ + +# Harness integration build artifacts +integrations/*/node_modules/ +integrations/*/*.tgz 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..65598f2 --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,49 @@ +# 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.7 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 + +- Codex CLI 0.155.1: plugin installs from a local marketplace, read-only MCP tools run headlessly, + write tools require approval, `sqlx_sql_execute` returned `9007199254740993` exactly. +- DeepSeek Harness 0.1.5-rc.1: plugin installs into a scratch profile (`dsh plugin add `), + registers five native tools, and a headless run returned the same exact value. +- Claude Code 2.1.236: plugin and marketplace validate (`claude plugin validate --strict`); the MCP + server was verified directly over stdio. A full `claude -p` run needs an authenticated session. +- Pi 0.86.0: package installs (`pi install`), and the extension loads through Pi's own jiti loader + registering all five tools with valid schemas. A full `pi -p` run needs provider credentials. 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..d5e3242 --- /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.7+) on PATH.", + "version": "0.1.6", + "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..1ee7c6f --- /dev/null +++ b/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "sqlx", + "version": "0.1.6", + "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..1b74761 --- /dev/null +++ b/integrations/dsh/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ottermind/dsh-sqlx", + "version": "0.1.6", + "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..a696e9f --- /dev/null +++ b/integrations/pi/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ottermind/pi-sqlx", + "version": "0.1.6", + "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": "*" + } +} From 644e2ebf07878280130cc132a7bf4029a0cabc1b Mon Sep 17 00:00:00 2001 From: zgq Date: Sun, 20 Sep 2026 19:25:35 +0800 Subject: [PATCH 2/4] Record four-harness end-to-end verification --- .gitignore | 1 + integrations/README.md | 35 +++++++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 588db55..da54acd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__/ # Harness integration build artifacts integrations/*/node_modules/ integrations/*/*.tgz +integrations/*/package-lock.json diff --git a/integrations/README.md b/integrations/README.md index 65598f2..44a87c9 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -37,13 +37,28 @@ local `--ui` page). Datasources created with `--username-env`/`--password-env` r 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 - -- Codex CLI 0.155.1: plugin installs from a local marketplace, read-only MCP tools run headlessly, - write tools require approval, `sqlx_sql_execute` returned `9007199254740993` exactly. -- DeepSeek Harness 0.1.5-rc.1: plugin installs into a scratch profile (`dsh plugin add `), - registers five native tools, and a headless run returned the same exact value. -- Claude Code 2.1.236: plugin and marketplace validate (`claude plugin validate --strict`); the MCP - server was verified directly over stdio. A full `claude -p` run needs an authenticated session. -- Pi 0.86.0: package installs (`pi install`), and the extension loads through Pi's own jiti loader - registering all five tools with valid schemas. A full `pi -p` run needs provider credentials. +## Verified end-to-end (2026-09-20) + +Each harness ran the same task headlessly: list datasources, then `SELECT id, big, name FROM t_exact` +on a PostgreSQL datasource, and reply with the exact `big` value. All four returned +`9007199254740993` unchanged. + +| Harness | Command that was verified | +|---|---| +| Codex CLI 0.155.1 | `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.236 | `claude --plugin-dir integrations/claude/plugins/sqlx --allowedTools "mcp__plugin_sqlx_sqlx__*" -p ""` | +| Pi 0.86.0 | `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]`. From f11010c72a555ce35ce3a91394d316af29789000 Mon Sep 17 00:00:00 2001 From: zgq Date: Sun, 20 Sep 2026 19:34:32 +0800 Subject: [PATCH 3/4] Re-verify integrations on upgraded Codex, Claude Code and Pi --- integrations/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/integrations/README.md b/integrations/README.md index 44a87c9..88ad822 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -39,16 +39,17 @@ 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 `SELECT id, big, name FROM t_exact` -on a PostgreSQL datasource, and reply with the exact `big` value. All four returned -`9007199254740993` unchanged. +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 | `codex plugin marketplace add ` → `codex plugin add sqlx@ottermind` → `codex exec --skip-git-repo-check ""` | +| 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.236 | `claude --plugin-dir integrations/claude/plugins/sqlx --allowedTools "mcp__plugin_sqlx_sqlx__*" -p ""` | -| Pi 0.86.0 | `pi -e integrations/pi/extensions/sqlx.ts -e .ts --provider deepseek --model deepseek-flash --no-session -p ""` | +| 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: From 42463584e67d2948e290b69c93ba0f1672594599 Mon Sep 17 00:00:00 2001 From: zgq Date: Sun, 20 Sep 2026 19:59:39 +0800 Subject: [PATCH 4/4] Align integration versions with the repository release --- integrations/README.md | 2 +- integrations/claude/plugins/sqlx/.claude-plugin/plugin.json | 4 ++-- integrations/codex/plugins/sqlx/.codex-plugin/plugin.json | 2 +- integrations/dsh/package.json | 2 +- integrations/pi/package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/integrations/README.md b/integrations/README.md index 88ad822..d17d810 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -13,7 +13,7 @@ thin native-tool packages for harnesses that register tools themselves. ## Requirements -The `sqlx` CLI (0.1.7 or later, which provides `sqlx mcp`) must be installed and on `PATH`. +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 diff --git a/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json b/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json index d5e3242..317ad91 100644 --- a/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json +++ b/integrations/claude/plugins/sqlx/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "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.7+) on PATH.", - "version": "0.1.6", + "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" }, diff --git a/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json b/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json index 1ee7c6f..309c36f 100644 --- a/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json +++ b/integrations/codex/plugins/sqlx/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sqlx", - "version": "0.1.6", + "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" diff --git a/integrations/dsh/package.json b/integrations/dsh/package.json index 1b74761..be731d4 100644 --- a/integrations/dsh/package.json +++ b/integrations/dsh/package.json @@ -1,6 +1,6 @@ { "name": "@ottermind/dsh-sqlx", - "version": "0.1.6", + "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", diff --git a/integrations/pi/package.json b/integrations/pi/package.json index a696e9f..6af6d72 100644 --- a/integrations/pi/package.json +++ b/integrations/pi/package.json @@ -1,6 +1,6 @@ { "name": "@ottermind/pi-sqlx", - "version": "0.1.6", + "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",