From 34dff1d4969e8f2c1e513b0141b64225545776fe Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 20 Oct 2026 00:42:41 +0530 Subject: [PATCH 1/8] feat(mcp): ChatGPT app support via OpenAI Apps SDK and MCP Apps Embed an analyze_sql report widget (ui://gosqlx/analysis-v1.html), return structuredContent from all tools with unchanged text output, add server instructions, noauth securitySchemes and invocation status metadata, accept mariadb/clickhouse in validate_sql, report gosqlx.Version and the real tool count, serve the optional OpenAI apps domain challenge, and sweep idle MCP sessions after 30 minutes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w --- cmd/gosqlx-mcp/main.go | 54 +++- cmd/gosqlx-mcp/main_test.go | 98 ++++++++ docs/CHATGPT_APP.md | 200 +++++++++++++++ integrations/chatgpt/README.md | 153 +++++++++++ pkg/mcp/apps.go | 154 ++++++++++++ pkg/mcp/apps_test.go | 428 +++++++++++++++++++++++++++++++ pkg/mcp/config.go | 12 + pkg/mcp/config_test.go | 23 ++ pkg/mcp/server.go | 168 ++++++++----- pkg/mcp/tools.go | 6 +- pkg/mcp/widget/analysis.html | 446 +++++++++++++++++++++++++++++++++ render.yaml | 5 + 12 files changed, 1677 insertions(+), 70 deletions(-) create mode 100644 cmd/gosqlx-mcp/main_test.go create mode 100644 docs/CHATGPT_APP.md create mode 100644 integrations/chatgpt/README.md create mode 100644 pkg/mcp/apps.go create mode 100644 pkg/mcp/apps_test.go create mode 100644 pkg/mcp/widget/analysis.html diff --git a/cmd/gosqlx-mcp/main.go b/cmd/gosqlx-mcp/main.go index 82d70021..0756875c 100644 --- a/cmd/gosqlx-mcp/main.go +++ b/cmd/gosqlx-mcp/main.go @@ -22,6 +22,9 @@ // GOSQLX_MCP_HOST bind host (default: 127.0.0.1) // GOSQLX_MCP_PORT bind port (default: 8080) // GOSQLX_MCP_AUTH_TOKEN bearer token; empty disables auth +// GOSQLX_MCP_OPENAI_APPS_CHALLENGE ChatGPT app domain-verification token +// +// Pass --stdio to serve MCP over stdin/stdout instead of HTTP. // // # Usage // @@ -32,6 +35,7 @@ package main import ( "context" + "encoding/json" "fmt" "log" "net/http" @@ -88,20 +92,10 @@ func runHTTP() error { srv := gosqlxmcp.New(cfg) - // Build handler chain: MCP → auth → rate limiter - handler := gosqlxmcp.RateLimitMiddleware(srv.Handler()) - - mux := http.NewServeMux() - mux.Handle("/mcp", handler) - mux.Handle("/mcp/", handler) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"status":"ok","version":"%s","tools":7}`, gosqlx.Version) - }) - httpSrv := &http.Server{ - Addr: cfg.Addr(), - Handler: mux, + Addr: cfg.Addr(), + Handler: newMux(cfg, srv), + ReadHeaderTimeout: 10 * time.Second, } go func() { @@ -117,3 +111,37 @@ func runHTTP() error { } return nil } + +// newMux builds the HTTP routes: +// +// /mcp, /mcp/ MCP streamable HTTP (rate limit → auth → MCP) +// /health liveness probe with version and tool count +// /.well-known/openai-apps-challenge ChatGPT app domain verification (only when configured) +// +// Every other path (including OAuth discovery probes from connector wizards) +// returns 404, which tells clients that no OAuth is configured. +func newMux(cfg *gosqlxmcp.Config, srv *gosqlxmcp.Server) *http.ServeMux { + handler := gosqlxmcp.RateLimitMiddleware(srv.Handler()) + + mux := http.NewServeMux() + mux.Handle("/mcp", handler) + mux.Handle("/mcp/", handler) + + health, _ := json.Marshal(map[string]any{ + "status": "ok", + "version": gosqlx.Version, + "tools": srv.ToolCount(), + }) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(health) + }) + + if token := cfg.OpenAIAppsChallenge; token != "" { + mux.HandleFunc("/.well-known/openai-apps-challenge", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(token)) + }) + } + return mux +} diff --git a/cmd/gosqlx-mcp/main_test.go b/cmd/gosqlx-mcp/main_test.go new file mode 100644 index 00000000..6191b406 --- /dev/null +++ b/cmd/gosqlx-mcp/main_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + gosqlxmcp "github.com/ajitpratap0/GoSQLX/pkg/mcp" +) + +func TestNewMux_Health(t *testing.T) { + cfg := gosqlxmcp.DefaultConfig() + srv := gosqlxmcp.New(cfg) + ts := httptest.NewServer(newMux(cfg, srv)) + t.Cleanup(ts.Close) + + resp, err := http.Get(ts.URL + "/health") + if err != nil { + t.Fatalf("GET /health: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if got, ok := body["status"].(string); !ok || got != "ok" { + t.Errorf("status = %v, want ok", body["status"]) + } + if got, ok := body["version"].(string); !ok || got != gosqlx.Version { + t.Errorf("version = %v, want %q", body["version"], gosqlx.Version) + } + tools, ok := body["tools"].(float64) + if !ok || int(tools) != srv.ToolCount() || srv.ToolCount() == 0 { + t.Errorf("tools = %v, want %d", body["tools"], srv.ToolCount()) + } +} + +func TestNewMux_OpenAIAppsChallenge(t *testing.T) { + tests := []struct { + name string + token string + wantStatus int + wantBody string + }{ + {name: "configured", token: "abc123-token", wantStatus: http.StatusOK, wantBody: "abc123-token"}, + {name: "not configured", token: "", wantStatus: http.StatusNotFound}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := gosqlxmcp.DefaultConfig() + cfg.OpenAIAppsChallenge = tt.token + ts := httptest.NewServer(newMux(cfg, gosqlxmcp.New(cfg))) + t.Cleanup(ts.Close) + + resp, err := http.Get(ts.URL + "/.well-known/openai-apps-challenge") + if err != nil { + t.Fatalf("GET challenge: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != tt.wantStatus { + t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus) + } + if tt.wantBody == "" { + return + } + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(b) != tt.wantBody { + t.Errorf("body = %q, want %q", b, tt.wantBody) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" { + t.Errorf("Content-Type = %q, want text/plain", ct) + } + }) + } +} diff --git a/docs/CHATGPT_APP.md b/docs/CHATGPT_APP.md new file mode 100644 index 00000000..7ebfc1ea --- /dev/null +++ b/docs/CHATGPT_APP.md @@ -0,0 +1,200 @@ +# GoSQLX as a ChatGPT App (Apps SDK / MCP Apps) + +`gosqlx-mcp` is one MCP server that works in three kinds of host: + +- **Plain MCP clients** (Claude Code, Cursor, MCP Inspector, …). They see seven + tools that return JSON text, exactly as before. +- **MCP Apps hosts** (extension `io.modelcontextprotocol/ui`, spec 2026-01-26, such as + Claude). They also render the interactive `analyze_sql` report. +- **ChatGPT** (OpenAI Apps SDK). It reads the MCP Apps keys and the legacy `openai/*` + aliases, and renders the same report. + +Hosts ignore `_meta` keys they do not understand. That is why one tool list works +in all three without negotiating capabilities per host. + +User-facing setup is in [integrations/chatgpt/README.md](../integrations/chatgpt/README.md). +General server documentation is in [MCP_GUIDE.md](MCP_GUIDE.md). + +## Source layout + +| File | Role | +|---|---| +| `pkg/mcp/server.go` | Server construction: version (`gosqlx.Version`), `WithInstructions`, resource capability, tool registration with shared annotations | +| `pkg/mcp/apps.go` | Widget URI and MIME type, `go:embed` of the widget, tool and resource `_meta` builders, resource registration | +| `pkg/mcp/widget/analysis.html` | Self-contained report widget (inline CSS/JS, no network) | +| `pkg/mcp/tools.go` | Tool handlers; `toolResult` returns text plus `structuredContent` | +| `cmd/gosqlx-mcp/main.go` | HTTP routes: `/mcp`, `/health`, optional `/.well-known/openai-apps-challenge` | + +## Initialize + +- `serverInfo.version` is `gosqlx.Version`. It is no longer hard-coded. +- `capabilities` advertises `tools` and `resources`. +- `instructions` describes when to use each tool. It does not steer the model toward + GoSQLX for unrelated requests, which would break the OpenAI submission guidelines. + +## Tools + +All seven tools (`validate_sql`, `format_sql`, `parse_sql`, `extract_metadata`, +`security_scan`, `lint_sql`, `analyze_sql`) share these annotations: + +```json +{"title": "...", "readOnlyHint": true, "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false} +``` + +ChatGPT requires `readOnlyHint`, `destructiveHint` and `openWorldHint` to be set +explicitly. When they are missing, it assumes the worst case and asks the user to +confirm every call. + +Every tool's `_meta` contains: + +| Key | Value | Consumer | +|---|---|---| +| `openai/toolInvocation/invoking` | for example `"Validating SQL…"` | ChatGPT status line (≤ 64 chars) | +| `openai/toolInvocation/invoked` | for example `"Validated SQL"` | ChatGPT status line | +| `securitySchemes` | `[{"type":"noauth"}]`, only when `GOSQLX_MCP_AUTH_TOKEN` is unset | ChatGPT auth (back-compat `_meta` mirror) | + +`analyze_sql` also carries the widget binding: + +```json +"_meta": { + "ui": {"resourceUri": "ui://gosqlx/analysis-v1.html", "visibility": ["model", "app"]}, + "openai/outputTemplate": "ui://gosqlx/analysis-v1.html", + "openai/widgetAccessible": true +} +``` + +- `ui.resourceUri` is the MCP Apps source of truth. The deprecated flat key + `ui/resourceUri` is not emitted. +- `openai/outputTemplate` is set to the same URI because some ChatGPT builds only + render with the legacy key. +- `ui.visibility: ["model","app"]` and `openai/widgetAccessible: true` let the widget + call the tool again if a later version adds interactivity. The current widget only + displays results. + +Only `analyze_sql` gets the widget. The widget renders the composite report shape +(`validate`, `parse`, `metadata`, `security`, `lint`, `format`, `errors`). The other +tools return different shapes and stay text and JSON only. + +The `validate_sql` `dialect` enum is `generic, postgresql, mysql, mariadb, sqlserver, +oracle, sqlite, snowflake, clickhouse`. All nine are accepted by +`gosqlx.ParseWithDialect`, and a test checks each one. + +`lint_sql` applies the 10 rules that `newFullLinter` registers. They are the same set +`gosqlx lint` uses: L001–L010, with a line-length limit of 100. + +## UI resource + +`resources/list`: + +```json +{"uri": "ui://gosqlx/analysis-v1.html", "name": "gosqlx_analysis_report", + "mimeType": "text/html;profile=mcp-app", "description": "...", "_meta": { ... }} +``` + +`resources/read` returns exactly one `TextResourceContents` whose `text` is the +embedded HTML. It carries the following `_meta`, which is also copied onto the +`resources/list` entry: + +```json +{ + "ui": { + "csp": {"connectDomains": [], "resourceDomains": []}, + "prefersBorder": true, + "permissions": {"clipboardWrite": {}} + }, + "openai/widgetCSP": {"connect_domains": [], "resource_domains": []}, + "openai/widgetPrefersBorder": true, + "openai/widgetDescription": "Interactive GoSQLX analysis report: ..." +} +``` + +- **MIME type:** `text/html;profile=mcp-app` (ext-apps `RESOURCE_MIME_TYPE`). ChatGPT + accepts it in place of the legacy `text/html+skybridge`. +- **CSP:** empty on purpose. The widget loads no remote scripts, styles, fonts or + images and makes no `fetch`/XHR calls, so the MCP Apps default CSP is enough. + Submission still requires a CSP to be declared. +- **`clipboardWrite`:** requested for the Copy button. When the host does not grant + it, the widget falls back to `document.execCommand("copy")`. OpenAI documents that + ChatGPT widgets cannot use `navigator.clipboard`, and the sandbox may also block + `execCommand`, in which case the button shows "Copy failed" and the user copies + from the code block. +- **Versioned URI:** ChatGPT caches templates by URI. Bump `-v1` in + `AnalysisWidgetURI` whenever the widget changes in a way that is incompatible with + older results. +- `openai/widgetDomain` is not set. The widget does not need its own origin. + +## Tool results + +`toolResult` returns: + +```json +{ + "content": [{"type": "text", "text": ""}], + "structuredContent": { ...same object... } +} +``` + +- The `text` content is byte-for-byte what earlier versions returned, so existing + clients and tests keep working. A test checks this. +- `structuredContent` is the same map. The model sees it, and the widget receives it + as `params.structuredContent` in `ui/notifications/tool-result`, or as + `window.openai.toolOutput` in ChatGPT. +- No result-level `_meta` is set. Results contain no request IDs, timestamps or user + data (response minimisation). +- Protocol-level errors (missing `sql`, parse failure in `parse_sql`, …) behave as + before. + +## Widget runtime + +`pkg/mcp/widget/analysis.html` implements a minimal MCP Apps view by hand, without a +build step or SDK: + +1. Posts JSON-RPC `ui/initialize` (with `appInfo`/`clientInfo`, `appCapabilities`, + `protocolVersion: "2026-01-26"`) to `window.parent`. It applies + `hostContext.theme` and the font variables, then sends + `ui/notifications/initialized`. +2. Handles `ui/notifications/tool-input`, `tool-result`, `tool-cancelled` and + `host-context-changed`, and answers `ping` and `ui/resource-teardown`. +3. Sends `ui/notifications/size-changed` from a `ResizeObserver` on the content + root (so the reported height can shrink as well as grow). In ChatGPT it also + calls `window.openai.notifyIntrinsicHeight`. +4. ChatGPT fallback: reads `window.openai.toolOutput`, `toolInput` and `theme`, and + listens for `openai:set_globals`. +5. Theme: follows the host theme (`data-theme`) and otherwise `prefers-color-scheme`. + +It renders all data with `textContent`, so SQL text and error messages are never parsed +as HTML. The test `TestApps_ResourcesRead_ReturnsSelfContainedWidget` fails if the +HTML gains remote `src`/`href` URLs, `@import`, `fetch(`, `XMLHttpRequest` or +`innerHTML`. + +## HTTP endpoints (`cmd/gosqlx-mcp`) + +| Path | Purpose | +|---|---| +| `/mcp` | MCP streamable HTTP: rate limit → optional bearer auth → MCP | +| `/health` | `{"status":"ok","version":,"tools":}` | +| `/.well-known/openai-apps-challenge` | Plain-text domain-verification token. Served only when `GOSQLX_MCP_OPENAI_APPS_CHALLENGE` is set; returns 404 otherwise | + +The server sends no CORS headers. ChatGPT and Claude connect to MCP servers server to +server, and nothing in the Apps SDK or MCP Apps requirements asks for CORS on the MCP +endpoint. Browser-based tools such as MCP Inspector go through their own proxy. + +## Not implemented (optional) + +- `search`/`fetch` tools for ChatGPT company knowledge and deep research. They only + make sense over documentation, not over SQL analysis. +- OAuth. The public server is anonymous and read-only. +- `openai/widgetDomain` and fullscreen display modes. + +## Verifying locally + +```bash +GOSQLX_MCP_PORT=8765 go run ./cmd/gosqlx-mcp & +H='-H Content-Type:application/json -H Accept:application/json,text/event-stream' +curl -si $H localhost:8765/mcp -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' +# copy the Mcp-Session-Id response header into SID, then: +curl -s $H -H "Mcp-Session-Id: $SID" localhost:8765/mcp -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' +curl -s $H -H "Mcp-Session-Id: $SID" localhost:8765/mcp -d '{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"ui://gosqlx/analysis-v1.html"}}' +curl -s $H -H "Mcp-Session-Id: $SID" localhost:8765/mcp -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"analyze_sql","arguments":{"sql":"SELECT id FROM users WHERE id = 1 OR 1=1"}}}' +``` diff --git a/integrations/chatgpt/README.md b/integrations/chatgpt/README.md new file mode 100644 index 00000000..8c85ce93 --- /dev/null +++ b/integrations/chatgpt/README.md @@ -0,0 +1,153 @@ +# GoSQLX for ChatGPT + +GoSQLX runs in ChatGPT as an app built on the MCP server in +[`cmd/gosqlx-mcp`](../../cmd/gosqlx-mcp). This is the same server that Claude, +Cursor and other MCP clients use. ChatGPT gets the same seven read-only SQL tools +plus an interactive report for `analyze_sql`. + +- **Endpoint:** `https://mcp.gosqlx.dev/mcp` (MCP streamable HTTP) +- **Authentication:** none +- **What it does:** parses, validates, formats, lints and security-scans SQL text. + It never connects to a database and never executes SQL. + +Technical details (metadata keys, resource URI, result shapes) are in +[docs/CHATGPT_APP.md](../../docs/CHATGPT_APP.md). + +## Connect in ChatGPT (developer mode) + +1. In ChatGPT, open **Settings → Apps & Connectors → Advanced settings** and turn on + **Developer mode**. On Business, Enterprise and Edu workspaces an admin has to + allow it first. +2. Back in **Settings → Apps & Connectors**, choose **Create**. +3. Fill in the form: + - **Name:** `GoSQLX` + - **Description:** `Parse, validate, format, lint and security-scan SQL across 8 dialects. Read-only; never runs queries.` + - **MCP server URL:** `https://mcp.gosqlx.dev/mcp` + - **Authentication:** `No authentication` +4. Tick the trust checkbox and click **Create**. The seven tools are listed. +5. In a chat, click **+ → More**, pick **GoSQLX**, and ask a question about SQL. + +If you change the server (new tools, new descriptions or a new widget), go to +**Settings → Apps & Connectors → GoSQLX → Refresh** so ChatGPT re-reads the tool list. + +Menu labels in ChatGPT change from time to time. If a step above does not match +what you see, look for "Developer mode" and "Create connector" under Apps or Connectors. + +### Other ways to test + +- **MCP Inspector:** `npx @modelcontextprotocol/inspector@latest`, choose + transport "Streamable HTTP" and URL `https://mcp.gosqlx.dev/mcp`. +- **OpenAI API Playground:** Tools → Add → MCP Server → the same URL, no auth. + +## Example prompts + +| Prompt | Tool ChatGPT should pick | +|---|---| +| "Review this query: `SELECT * FROM orders o JOIN users u ON o.user_id = u.id WHERE u.email = 'a' OR 1=1`" | `analyze_sql` (renders the report) | +| "Is `SELECT TOP 10 * FROM t` valid SQL Server syntax?" | `validate_sql` with `dialect: sqlserver` | +| "Format this SQL with 4-space indentation and uppercase keywords: …" | `format_sql` | +| "Which tables and columns does this query touch?" | `extract_metadata` | +| "Does this WHERE clause look like SQL injection? `id = '' OR '1'='1' --`" | `security_scan` | +| "Lint this SQL for style problems." | `lint_sql` | +| "How many statements are in this script, and what kinds?" | `parse_sql` | + +Prompts where GoSQLX should *not* be used, because it neither runs SQL nor knows your +schema: + +- "How many rows are in my `users` table?" +- "Write me a migration that adds a column." (GoSQLX can validate the result, but it does not write SQL.) +- "Connect to my Postgres database." + +## What the widget shows + +When ChatGPT, or any other host that supports MCP Apps, calls `analyze_sql`, it renders +an inline report next to the answer: + +- a **Valid / Invalid** badge and the parser error, if there is one +- counts of statements, tables, security findings and lint violations +- **Statements:** the type of each statement (SELECT, INSERT, …) +- **Tables, columns & functions** the query references +- **Security:** injection findings sorted by severity (critical, high, medium, low), + each with its risk and a suggested fix +- **Lint:** violations of rules L001–L010, with line:column and a suggestion +- **Formatted SQL**, with a **Copy** button +- **Skipped checks:** sub-checks that could not run (for example, formatting invalid SQL) + +The widget is one self-contained HTML file with inline CSS and JS. It makes no network +requests, follows ChatGPT's light or dark theme, and renders all data as text. + +## Self-hosting + +The server is one static Go binary. + +```bash +# Docker (repository root) +docker build -t gosqlx-mcp . +docker run -p 8080:8080 -e GOSQLX_MCP_HOST=0.0.0.0 gosqlx-mcp + +# or from source +go install github.com/ajitpratap0/GoSQLX/cmd/gosqlx-mcp@latest +GOSQLX_MCP_HOST=0.0.0.0 gosqlx-mcp +``` + +To deploy on Render, use the [`render.yaml`](../../render.yaml) blueprint at the +repository root. The health check is `GET /health`. + +ChatGPT has to reach the server over **public HTTPS**. For local development you can +use a tunnel such as `ngrok http 8080` or `cloudflared tunnel --url http://localhost:8080` +and give ChatGPT `https:///mcp`. Tunnels are fine in developer mode but +are not accepted for directory submission. + +| Variable | Purpose | +|---|---| +| `GOSQLX_MCP_HOST` / `GOSQLX_MCP_PORT` | Bind address (default `127.0.0.1:8080`). | +| `GOSQLX_MCP_AUTH_TOKEN` | Optional bearer token. Leave it empty for ChatGPT "No authentication". Once a token is set, tools no longer advertise `securitySchemes: noauth`, and ChatGPT cannot send a static bearer token. | +| `GOSQLX_MCP_OPENAI_APPS_CHALLENGE` | Domain-verification token from the OpenAI Platform, served as plain text at `/.well-known/openai-apps-challenge`. | + +## Privacy + +- All tools run locally inside the server process on the SQL text you send. GoSQLX + never connects to a database and never executes SQL. +- The hosted server at `mcp.gosqlx.dev` does not log, store or forward the SQL it + processes. Its only state is in memory: MCP session state (swept after 30 minutes of inactivity) + and per-IP rate-limit counters (dropped after 10 minutes of inactivity). Render and + Cloudflare, which host and front it, see connection metadata such as IP addresses. + See . +- Tool results contain only analysis of the SQL you sent: no user identifiers, request + IDs or timestamps. ChatGPT request metadata such as locale or user agent is not used. +- Do not paste secrets or personal data into SQL you send to *any* hosted service. For + sensitive SQL, self-host the server. + +## Directory submission checklist + +Submit through the OpenAI Platform dashboard (`platform.openai.com/apps-manage`). The +organization must be verified, and you need the Owner role. Submission is not +available to projects with EU data residency. + +- [ ] Public, stable HTTPS endpoint: `https://mcp.gosqlx.dev/mcp` (not a tunnel). +- [ ] Domain verification: set `GOSQLX_MCP_OPENAI_APPS_CHALLENGE` in the Render + dashboard (`render.yaml` declares it with `sync: false`), redeploy, and confirm `curl https://mcp.gosqlx.dev/.well-known/openai-apps-challenge` + returns the token. +- [ ] CSP declared: the widget resource declares empty `ui.csp` and `openai/widgetCSP` + (no remote domains are needed). +- [ ] Tool annotations and their justifications. Every tool is + `readOnlyHint: true`, `destructiveHint: false`, `openWorldHint: false` + (`idempotentHint: true`), because each one is a pure function of its input + SQL: no writes, no deletes, no network or database access. +- [ ] Authentication: "No authentication" (`securitySchemes: [{type: "noauth"}]`). + No test account is needed. +- [ ] Privacy policy URL: . It must cover the data + categories, purposes, recipients and user controls. +- [ ] Terms of service URL: . +- [ ] Support contact: (if the form + insists on an email address, use a maintainer-controlled inbox). +- [ ] Logo (light and dark), at least 3 screenshots of the `analyze_sql` widget, and a + short demo video. +- [ ] Test cases: use the positive and negative example prompts above + (reportedly 5 positive and 3 negative). +- [ ] Review the guidelines: no tool descriptions that push the model to prefer + GoSQLX, minimal data collection, no restricted data. +- [ ] Once the app is published, tool names, signatures and descriptions are locked, + and any change needs resubmission. The widget URI carries a version + (`ui://gosqlx/analysis-v1.html`); bump it whenever the widget changes in a + breaking way. diff --git a/pkg/mcp/apps.go b/pkg/mcp/apps.go new file mode 100644 index 00000000..9066fa0d --- /dev/null +++ b/pkg/mcp/apps.go @@ -0,0 +1,154 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + _ "embed" + + "github.com/mark3labs/mcp-go/mcp" +) + +// This file wires the interactive UI ("MCP Apps" / OpenAI Apps SDK) layer. +// +// The same metadata serves two host families: +// +// - MCP Apps hosts (extension io.modelcontextprotocol/ui, spec 2026-01-26), +// e.g. Claude: they read _meta.ui.resourceUri on the tool and the +// _meta.ui.* keys on the resource contents. +// - ChatGPT (OpenAI Apps SDK): it understands the MCP Apps keys and also +// the legacy openai/* aliases, which are set to the same values. +// +// Hosts that know neither simply ignore the _meta keys, so plain MCP clients +// (Cursor, Claude Code, MCP Inspector, ...) see no behavioural change. + +// AnalysisWidgetURI is the ui:// resource URI of the analyze_sql report widget. +// +// The version suffix is part of the URI on purpose: ChatGPT caches templates +// by URI, so a breaking widget change should bump it (v2, v3, ...). +const AnalysisWidgetURI = "ui://gosqlx/analysis-v1.html" + +// AppMIMEType is the MCP Apps HTML profile MIME type (ext-apps RESOURCE_MIME_TYPE). +// ChatGPT accepts it as the successor of the legacy "text/html+skybridge". +const AppMIMEType = "text/html;profile=mcp-app" + +// analysisWidgetDescription is shown to the model by ChatGPT alongside the +// rendered widget so it does not need to re-describe what the user can see. +const analysisWidgetDescription = "Interactive GoSQLX analysis report: validity, statement types, " + + "referenced tables/columns/functions, security findings by severity, lint violations, " + + "and the formatted SQL with a copy button." + +//go:embed widget/analysis.html +var analysisWidgetHTML string + +// AnalysisWidgetHTML returns the embedded, self-contained HTML of the +// analyze_sql report widget (inline CSS/JS, no external requests). +func AnalysisWidgetHTML() string { + return analysisWidgetHTML +} + +// widgetResourceMeta is the _meta attached to the widget resource. An empty +// CSP is declared explicitly: the widget fetches nothing and loads no remote +// assets, and app submission requires a declared CSP. +func widgetResourceMeta() map[string]any { + return map[string]any{ + "ui": map[string]any{ + "csp": map[string]any{ + "connectDomains": []string{}, + "resourceDomains": []string{}, + }, + "prefersBorder": true, + // The "Copy" button uses the async clipboard API when the host + // grants it, and falls back to execCommand otherwise. + "permissions": map[string]any{ + "clipboardWrite": map[string]any{}, + }, + }, + "openai/widgetCSP": map[string]any{ + "connect_domains": []string{}, + "resource_domains": []string{}, + }, + "openai/widgetPrefersBorder": true, + "openai/widgetDescription": analysisWidgetDescription, + } +} + +// widgetToolMeta returns the tool-descriptor _meta that binds a tool to the +// analysis widget, in both the MCP Apps form and the OpenAI alias form. +func widgetToolMeta() map[string]any { + return map[string]any{ + "ui": map[string]any{ + "resourceUri": AnalysisWidgetURI, + "visibility": []string{"model", "app"}, + }, + "openai/outputTemplate": AnalysisWidgetURI, + "openai/widgetAccessible": true, + } +} + +// toolMeta builds the _meta for a tool descriptor. +// +// invoking/invoked are ChatGPT status strings (kept well under 64 chars). +// When withWidget is true the analysis widget is attached. When the server +// runs without bearer auth the tool is advertised as needing no auth, which +// is what ChatGPT's connector dialog and app review expect. +func toolMeta(cfg *Config, invoking, invoked string, withWidget bool) *mcp.Meta { + m := map[string]any{ + "openai/toolInvocation/invoking": invoking, + "openai/toolInvocation/invoked": invoked, + } + if cfg == nil || !cfg.AuthEnabled() { + m["securitySchemes"] = []map[string]any{{"type": "noauth"}} + } + if withWidget { + for k, v := range widgetToolMeta() { + m[k] = v + } + } + return mcp.NewMetaFromMap(m) +} + +// withToolMeta is a ToolOption that sets the tool's _meta. +func withToolMeta(meta *mcp.Meta) mcp.ToolOption { + return func(t *mcp.Tool) { + t.Meta = meta + } +} + +// registerResources registers the UI widget resource. +func (s *Server) registerResources() { + res := mcp.NewResource( + AnalysisWidgetURI, + "gosqlx_analysis_report", + mcp.WithResourceDescription(analysisWidgetDescription), + mcp.WithMIMEType(AppMIMEType), + ) + res.Meta = mcp.NewMetaFromMap(widgetResourceMeta()) + + s.mcpSrv.AddResource(res, handleReadAnalysisWidget) +} + +// handleReadAnalysisWidget serves the widget HTML. resources/read must return +// exactly one content item for a UI resource. +func handleReadAnalysisWidget(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: AnalysisWidgetURI, + MIMEType: AppMIMEType, + Text: analysisWidgetHTML, + Meta: widgetResourceMeta(), + }, + }, nil +} diff --git a/pkg/mcp/apps_test.go b/pkg/mcp/apps_test.go new file mode 100644 index 00000000..74bcf6b6 --- /dev/null +++ b/pkg/mcp/apps_test.go @@ -0,0 +1,428 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/client" + mcpmcp "github.com/mark3labs/mcp-go/mcp" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" +) + +// newAppsClient starts an in-process client against a server built from cfg +// and returns it together with the initialize result. +func newAppsClient(t *testing.T, cfg *Config) (*client.Client, *mcpmcp.InitializeResult) { + t.Helper() + srv := New(cfg) + c, err := client.NewInProcessClient(srv.mcpSrv) + if err != nil { + t.Fatalf("NewInProcessClient: %v", err) + } + ctx := context.Background() + if err := c.Start(ctx); err != nil { + t.Fatalf("client.Start: %v", err) + } + initReq := mcpmcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcpmcp.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcpmcp.Implementation{Name: "apps-test", Version: "0.0.1"} + res, err := c.Initialize(ctx, initReq) + if err != nil { + t.Fatalf("client.Initialize: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + return c, res +} + +// metaMap round-trips a value through JSON so nested _meta can be inspected +// as plain maps regardless of the concrete Go types used to build it. +func metaMap(t *testing.T, v any) map[string]any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal meta: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal meta: %v", err) + } + return out +} + +func mustMap(t *testing.T, v any, what string) map[string]any { + t.Helper() + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("%s: expected object, got %T (%v)", what, v, v) + } + return m +} + +func mustEmptyList(t *testing.T, v any, what string) { + t.Helper() + l, ok := v.([]any) + if !ok { + t.Fatalf("%s: expected array, got %T (%v)", what, v, v) + } + if len(l) != 0 { + t.Errorf("%s: expected empty array, got %v", what, l) + } +} + +// checkWidgetResourceMeta asserts the dual MCP Apps / OpenAI resource metadata. +func checkWidgetResourceMeta(t *testing.T, meta map[string]any) { + t.Helper() + ui := mustMap(t, meta["ui"], "_meta.ui") + csp := mustMap(t, ui["csp"], "_meta.ui.csp") + mustEmptyList(t, csp["connectDomains"], "ui.csp.connectDomains") + mustEmptyList(t, csp["resourceDomains"], "ui.csp.resourceDomains") + if ui["prefersBorder"] != true { + t.Errorf("ui.prefersBorder = %v, want true", ui["prefersBorder"]) + } + perms := mustMap(t, ui["permissions"], "ui.permissions") + if _, ok := perms["clipboardWrite"]; !ok { + t.Error("ui.permissions.clipboardWrite missing") + } + oaCSP := mustMap(t, meta["openai/widgetCSP"], "openai/widgetCSP") + mustEmptyList(t, oaCSP["connect_domains"], "openai/widgetCSP.connect_domains") + mustEmptyList(t, oaCSP["resource_domains"], "openai/widgetCSP.resource_domains") + if desc, ok := meta["openai/widgetDescription"].(string); !ok || desc == "" { + t.Errorf("openai/widgetDescription missing or empty: %v", meta["openai/widgetDescription"]) + } +} + +func TestApps_Initialize_InstructionsVersionCapabilities(t *testing.T) { + _, res := newAppsClient(t, DefaultConfig()) + + if res.ServerInfo.Version != gosqlx.Version { + t.Errorf("server version = %q, want gosqlx.Version %q", res.ServerInfo.Version, gosqlx.Version) + } + if res.Capabilities.Resources == nil { + t.Error("resources capability not advertised") + } + if res.Capabilities.Tools == nil { + t.Error("tools capability not advertised") + } + for _, tool := range []string{"analyze_sql", "validate_sql", "format_sql", "parse_sql", "extract_metadata", "security_scan", "lint_sql"} { + if !strings.Contains(res.Instructions, tool) { + t.Errorf("instructions do not mention %s", tool) + } + } +} + +func TestApps_ResourcesList_ExposesWidget(t *testing.T) { + c, _ := newAppsClient(t, DefaultConfig()) + + res, err := c.ListResources(context.Background(), mcpmcp.ListResourcesRequest{}) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if len(res.Resources) != 1 { + t.Fatalf("expected 1 resource, got %d", len(res.Resources)) + } + r := res.Resources[0] + if r.URI != AnalysisWidgetURI { + t.Errorf("URI = %q, want %q", r.URI, AnalysisWidgetURI) + } + if !strings.HasPrefix(r.URI, "ui://") { + t.Errorf("widget URI must use the ui:// scheme, got %q", r.URI) + } + if r.MIMEType != AppMIMEType { + t.Errorf("mimeType = %q, want %q", r.MIMEType, AppMIMEType) + } + if r.Meta == nil { + t.Fatal("resource _meta missing from resources/list") + } + checkWidgetResourceMeta(t, metaMap(t, r.Meta)) +} + +func TestApps_ResourcesRead_ReturnsSelfContainedWidget(t *testing.T) { + c, _ := newAppsClient(t, DefaultConfig()) + + req := mcpmcp.ReadResourceRequest{} + req.Params.URI = AnalysisWidgetURI + res, err := c.ReadResource(context.Background(), req) + if err != nil { + t.Fatalf("ReadResource: %v", err) + } + if len(res.Contents) != 1 { + t.Fatalf("UI resource must return exactly one content item, got %d", len(res.Contents)) + } + tc, ok := res.Contents[0].(mcpmcp.TextResourceContents) + if !ok { + t.Fatalf("expected TextResourceContents, got %T", res.Contents[0]) + } + if tc.URI != AnalysisWidgetURI { + t.Errorf("URI = %q, want %q", tc.URI, AnalysisWidgetURI) + } + if tc.MIMEType != AppMIMEType { + t.Errorf("mimeType = %q, want %q", tc.MIMEType, AppMIMEType) + } + if tc.Text != AnalysisWidgetHTML() { + t.Error("resource text differs from embedded widget HTML") + } + checkWidgetResourceMeta(t, metaMap(t, tc.Meta)) + + lower := strings.ToLower(tc.Text) + if !strings.HasPrefix(lower, "") { + t.Error("widget must be an HTML5 document") + } + // Self-contained: the declared CSP has no domains, so nothing remote may load. + for _, forbidden := range []string{ + `src="http`, `src='http`, `href="http`, `href='http`, "@import", "fetch(", "xmlhttprequest", "innerhtml", + } { + if strings.Contains(lower, forbidden) { + t.Errorf("widget HTML contains %q; it must be self-contained and render via textContent", forbidden) + } + } + // The widget must speak the MCP Apps bridge. + for _, want := range []string{"ui/initialize", "ui/notifications/initialized", "ui/notifications/tool-result", "structuredContent"} { + if !strings.Contains(tc.Text, want) { + t.Errorf("widget HTML does not reference %q", want) + } + } +} + +func TestApps_ToolsList_MetaAndAnnotations(t *testing.T) { + tests := []struct { + name string + authToken string + wantNoAuth bool + }{ + {name: "no auth advertises noauth security scheme", authToken: "", wantNoAuth: true}, + {name: "bearer auth omits noauth security scheme", authToken: "secret", wantNoAuth: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = tt.authToken + c, _ := newAppsClient(t, cfg) + + res, err := c.ListTools(context.Background(), mcpmcp.ListToolsRequest{}) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(res.Tools) != 7 { + t.Fatalf("expected 7 tools, got %d", len(res.Tools)) + } + for _, tool := range res.Tools { + a := tool.Annotations + if a.ReadOnlyHint == nil || !*a.ReadOnlyHint { + t.Errorf("%s: readOnlyHint must be true", tool.Name) + } + if a.DestructiveHint == nil || *a.DestructiveHint { + t.Errorf("%s: destructiveHint must be false", tool.Name) + } + if a.OpenWorldHint == nil || *a.OpenWorldHint { + t.Errorf("%s: openWorldHint must be false", tool.Name) + } + if a.Title == "" { + t.Errorf("%s: title annotation missing", tool.Name) + } + if tool.Meta == nil { + t.Fatalf("%s: _meta missing", tool.Name) + } + meta := metaMap(t, tool.Meta) + + for _, k := range []string{"openai/toolInvocation/invoking", "openai/toolInvocation/invoked"} { + s, ok := meta[k].(string) + if !ok || s == "" || len([]rune(s)) > 64 { + t.Errorf("%s: %s = %v, want non-empty string <= 64 chars", tool.Name, k, meta[k]) + } + } + + schemes, hasSchemes := meta["securitySchemes"] + if tt.wantNoAuth { + list, ok := schemes.([]any) + if !ok || len(list) != 1 { + t.Fatalf("%s: securitySchemes = %v, want one noauth entry", tool.Name, schemes) + } + if mustMap(t, list[0], "securitySchemes[0]")["type"] != "noauth" { + t.Errorf("%s: securitySchemes[0].type != noauth", tool.Name) + } + } else if hasSchemes { + t.Errorf("%s: securitySchemes must be omitted when bearer auth is enabled", tool.Name) + } + + _, hasTemplate := meta["openai/outputTemplate"] + _, hasUI := meta["ui"] + if tool.Name != "analyze_sql" { + if hasTemplate || hasUI { + t.Errorf("%s: only analyze_sql should be bound to the widget", tool.Name) + } + continue + } + ui := mustMap(t, meta["ui"], "analyze_sql _meta.ui") + if ui["resourceUri"] != AnalysisWidgetURI { + t.Errorf("analyze_sql ui.resourceUri = %v, want %q", ui["resourceUri"], AnalysisWidgetURI) + } + if meta["openai/outputTemplate"] != AnalysisWidgetURI { + t.Errorf("analyze_sql openai/outputTemplate = %v, want %q", meta["openai/outputTemplate"], AnalysisWidgetURI) + } + if meta["openai/widgetAccessible"] != true { + t.Errorf("analyze_sql openai/widgetAccessible = %v, want true", meta["openai/widgetAccessible"]) + } + if _, deprecated := meta["ui/resourceUri"]; deprecated { + t.Error("analyze_sql uses deprecated flat ui/resourceUri key") + } + } + }) + } +} + +func TestApps_ToolsList_ValidateDialectEnum(t *testing.T) { + c, _ := newAppsClient(t, DefaultConfig()) + res, err := c.ListTools(context.Background(), mcpmcp.ListToolsRequest{}) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + for _, tool := range res.Tools { + if tool.Name != "validate_sql" { + continue + } + dialect := mustMap(t, tool.InputSchema.Properties["dialect"], "dialect schema") + enum, ok := dialect["enum"].([]any) + if !ok { + b, _ := json.Marshal(dialect["enum"]) + if err := json.Unmarshal(b, &enum); err != nil { + t.Fatalf("dialect enum has unexpected type %T", dialect["enum"]) + } + } + got := map[string]bool{} + for _, v := range enum { + s, ok := v.(string) + if !ok { + t.Fatalf("enum value %v is %T, want string", v, v) + } + got[s] = true + } + for _, want := range []string{"generic", "postgresql", "mysql", "mariadb", "sqlserver", "oracle", "sqlite", "snowflake", "clickhouse"} { + if !got[want] { + t.Errorf("dialect enum missing %q", want) + } + } + return + } + t.Fatal("validate_sql not found") +} + +func TestValidateSQLInternal_AllAdvertisedDialects(t *testing.T) { + for _, d := range supportedDialects { + t.Run(d, func(t *testing.T) { + res, err := validateSQLInternal("SELECT id FROM users WHERE id = 1", d) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res["valid"] != true { + t.Errorf("dialect %q: valid = %v, error = %v", d, res["valid"], res["error"]) + } + }) + } +} + +func TestApps_CallTool_StructuredContentMatchesText(t *testing.T) { + c, _ := newAppsClient(t, DefaultConfig()) + const q = "SELECT u.id, COUNT(o.id) FROM users u JOIN orders o ON o.user_id = u.id WHERE u.name = 'x' OR 1=1 GROUP BY u.id" + + tests := []struct { + tool string + args map[string]any + }{ + {"validate_sql", map[string]any{"sql": q}}, + {"validate_sql", map[string]any{"sql": "SELEC broken", "dialect": "postgresql"}}, + {"format_sql", map[string]any{"sql": q, "uppercase_keywords": true}}, + {"parse_sql", map[string]any{"sql": q}}, + {"extract_metadata", map[string]any{"sql": q}}, + {"security_scan", map[string]any{"sql": q}}, + {"lint_sql", map[string]any{"sql": q}}, + {"analyze_sql", map[string]any{"sql": q}}, + {"analyze_sql", map[string]any{"sql": "SELEC broken"}}, + } + for _, tt := range tests { + t.Run(tt.tool, func(t *testing.T) { + req := mcpmcp.CallToolRequest{} + req.Params.Name = tt.tool + req.Params.Arguments = tt.args + res, err := c.CallTool(context.Background(), req) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %+v", res.Content) + } + if res.StructuredContent == nil { + t.Fatal("structuredContent missing") + } + if len(res.Content) != 1 { + t.Fatalf("expected exactly 1 content item, got %d", len(res.Content)) + } + tc, ok := res.Content[0].(mcpmcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", res.Content[0]) + } + var fromText any + if err := json.Unmarshal([]byte(tc.Text), &fromText); err != nil { + t.Fatalf("text content is not JSON: %v", err) + } + fromStructured := metaMap(t, res.StructuredContent) + if !reflect.DeepEqual(fromText, any(fromStructured)) { + t.Errorf("structuredContent != text JSON\ntext: %s\nstructured: %v", tc.Text, fromStructured) + } + }) + } +} + +// TestToolResult_TextIsByteIdentical guards backward compatibility: the text +// content must stay exactly what json.Marshal produced before structured +// content was added. +func TestToolResult_TextIsByteIdentical(t *testing.T) { + data := map[string]any{"valid": true, "dialect": "postgresql", "n": 3, "list": []string{"a", "b"}} + want, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal: %v", err) + } + res, err := toolResult(data) + if err != nil { + t.Fatalf("toolResult: %v", err) + } + tc, ok := res.Content[0].(mcpmcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", res.Content[0]) + } + if tc.Text != string(want) { + t.Errorf("text = %s, want %s", tc.Text, want) + } + if tc.Type != "text" { + t.Errorf("content type = %q, want text", tc.Type) + } + sc, ok := res.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structuredContent is %T, want map[string]any", res.StructuredContent) + } + if !reflect.DeepEqual(sc, data) { + t.Errorf("structuredContent = %v, want %v", sc, data) + } +} + +func TestServer_ToolCount(t *testing.T) { + if got := New(DefaultConfig()).ToolCount(); got != 7 { + t.Errorf("ToolCount() = %d, want 7", got) + } +} diff --git a/pkg/mcp/config.go b/pkg/mcp/config.go index 5a29f30a..0b56ec57 100644 --- a/pkg/mcp/config.go +++ b/pkg/mcp/config.go @@ -30,6 +30,8 @@ // GOSQLX_MCP_HOST bind host (default: 127.0.0.1) // GOSQLX_MCP_PORT bind port (default: 8080) // GOSQLX_MCP_AUTH_TOKEN bearer token; empty disables auth +// GOSQLX_MCP_OPENAI_APPS_CHALLENGE ChatGPT app domain-verification token, +// served at /.well-known/openai-apps-challenge package mcp import ( @@ -54,6 +56,12 @@ type Config struct { // When non-empty every request must carry "Authorization: Bearer ". // Source: GOSQLX_MCP_AUTH_TOKEN (default: "" - auth disabled) AuthToken string + + // OpenAIAppsChallenge is the domain-verification token issued by the + // OpenAI Platform when submitting a ChatGPT app. When non-empty it is + // served as plain text at /.well-known/openai-apps-challenge. + // Source: GOSQLX_MCP_OPENAI_APPS_CHALLENGE (default: "" - not served) + OpenAIAppsChallenge string } // LoadConfig reads configuration from environment variables, applying defaults @@ -80,6 +88,10 @@ func LoadConfig() (*Config, error) { cfg.AuthToken = v } + if v := strings.TrimSpace(os.Getenv("GOSQLX_MCP_OPENAI_APPS_CHALLENGE")); v != "" { + cfg.OpenAIAppsChallenge = v + } + return cfg, nil } diff --git a/pkg/mcp/config_test.go b/pkg/mcp/config_test.go index 09ed8320..96d8e286 100644 --- a/pkg/mcp/config_test.go +++ b/pkg/mcp/config_test.go @@ -119,3 +119,26 @@ func TestDefaultConfig(t *testing.T) { t.Error("AuthEnabled() = true, want false") } } + +func TestLoadConfig_OpenAIAppsChallenge(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "unset", env: "", want: ""}, + {name: "trimmed", env: " tok-123\n", want: "tok-123"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GOSQLX_MCP_OPENAI_APPS_CHALLENGE", tt.env) + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.OpenAIAppsChallenge != tt.want { + t.Errorf("OpenAIAppsChallenge = %q, want %q", cfg.OpenAIAppsChallenge, tt.want) + } + }) + } +} diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index ed288fdc..0b304c5b 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -19,37 +19,78 @@ import ( "fmt" "log" "net/http" + "time" "github.com/mark3labs/mcp-go/mcp" mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" ) -// Server wraps the MCP server with all GoSQLX tools registered. +// Server wraps the MCP server with all GoSQLX tools and UI resources registered. type Server struct { cfg *Config mcpSrv *mcpserver.MCPServer } -// New creates a Server with all 7 GoSQLX tools registered. +// serverInstructions is returned in the initialize result so the client +// model knows when to reach for each tool. +const serverInstructions = `GoSQLX parses, validates, formats, lints, and security-scans SQL locally. ` + + `All tools are read-only, deterministic, and never connect to a database or execute SQL. + +Pick the narrowest tool for the request: +- analyze_sql: one-shot review of a query (validity, statement types, tables/columns/functions, security findings, lint violations, formatted SQL). Prefer it when the user asks to "review", "check", or "analyze" SQL; hosts that support MCP Apps render it as an interactive report. +- validate_sql: is the SQL syntactically valid, optionally for a specific dialect (postgresql, mysql, mariadb, sqlserver, oracle, sqlite, snowflake, clickhouse). +- format_sql: pretty-print SQL (indent size, keyword casing, trailing semicolon). +- parse_sql: statement count and AST statement types. +- extract_metadata: tables, columns, and functions referenced. +- security_scan: SQL-injection pattern detection (tautologies, UNION-based, stacked queries, comment bypass, ...), with severities. +- lint_sql: style rules L001-L010 (whitespace, indentation, line length, alignment, keyword case, comma placement, aliasing). + +Pass the SQL text exactly as the user wrote it. Results are JSON; summarise them for the user rather than repeating them verbatim.` + +// New creates a Server with all GoSQLX tools and the analysis widget resource registered. func New(cfg *Config) *Server { s := &Server{cfg: cfg} s.mcpSrv = mcpserver.NewMCPServer( "gosqlx-mcp", - "1.14.0", + gosqlx.Version, mcpserver.WithToolCapabilities(false), + mcpserver.WithResourceCapabilities(false, false), + mcpserver.WithInstructions(serverInstructions), ) s.registerTools() + s.registerResources() return s } +// ToolCount reports how many tools are registered. +func (s *Server) ToolCount() int { + return len(s.mcpSrv.ListTools()) +} + // Handler returns the HTTP handler chain (MCP server + auth middleware) // without starting an HTTP server. Use this when you need to wrap // the handler with additional middleware (e.g., rate limiting). func (s *Server) Handler() http.Handler { - streamSrv := mcpserver.NewStreamableHTTPServer(s.mcpSrv) + streamSrv := s.newStreamableHTTPServer() return BearerAuthMiddleware(s.cfg, streamSrv) } +// SessionIdleTTL is how long an idle MCP session's transport state is kept. +// Clients that disconnect without sending DELETE (the common case for hosted +// assistants) would otherwise leave their session state in memory until the +// process restarts. The privacy policy on gosqlx.dev cites this value. +const SessionIdleTTL = 30 * time.Minute + +// newStreamableHTTPServer builds the streamable HTTP transport with idle +// session sweeping enabled. +func (s *Server) newStreamableHTTPServer() *mcpserver.StreamableHTTPServer { + return mcpserver.NewStreamableHTTPServer(s.mcpSrv, + mcpserver.WithSessionIdleTTL(SessionIdleTTL), + ) +} + // Cfg returns the server configuration. func (s *Server) Cfg() *Config { return s.cfg @@ -64,7 +105,7 @@ func (s *Server) MCPServer() *mcpserver.MCPServer { // Start binds to cfg.Addr() and serves using streamable HTTP transport. // It blocks until ctx is cancelled or a fatal error occurs. func (s *Server) Start(ctx context.Context) error { - streamSrv := mcpserver.NewStreamableHTTPServer(s.mcpSrv) + streamSrv := s.newStreamableHTTPServer() wrapped := BearerAuthMiddleware(s.cfg, streamSrv) httpSrv := &http.Server{ @@ -84,36 +125,62 @@ func (s *Server) Start(ctx context.Context) error { return nil } +// readOnlyAnnotations are shared by every GoSQLX tool: pure local computation +// on the SQL text, no side effects, no network or database access. ChatGPT +// requires readOnly/destructive/openWorld to be set explicitly. +func readOnlyAnnotations(title string) []mcp.ToolOption { + return []mcp.ToolOption{ + mcp.WithTitleAnnotation(title), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + } +} + +// newTool assembles a tool from shared annotations, _meta and its own options. +func newTool(name, title, description string, meta *mcp.Meta, opts ...mcp.ToolOption) mcp.Tool { + all := make([]mcp.ToolOption, 0, len(opts)+7) + all = append(all, mcp.WithDescription(description)) + all = append(all, readOnlyAnnotations(title)...) + all = append(all, withToolMeta(meta)) + all = append(all, opts...) + return mcp.NewTool(name, all...) +} + +// supportedDialects lists the dialect values accepted by validate_sql. Each +// one is accepted by gosqlx.ParseWithDialect. +var supportedDialects = []string{ + "generic", "postgresql", "mysql", "mariadb", "sqlserver", + "oracle", "sqlite", "snowflake", "clickhouse", +} + // registerTools adds all 7 GoSQLX tools with their JSON Schema definitions. func (s *Server) registerTools() { - // validate_sql + cfg := s.cfg + s.mcpSrv.AddTool( - mcp.NewTool("validate_sql", - mcp.WithDescription("Validate SQL syntax. Returns {valid: bool, error?: string, dialect?: string}."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("validate_sql", "Validate SQL", + "Use this when the user wants to know whether a SQL statement is syntactically valid, "+ + "optionally for a specific dialect. Returns {valid: bool, error?: string, dialect?: string}.", + toolMeta(cfg, "Validating SQL…", "Validated SQL", false), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to validate"), ), mcp.WithString("dialect", - mcp.Description("SQL dialect: generic, mysql, postgresql, sqlite, sqlserver, oracle, snowflake"), - mcp.Enum("generic", "mysql", "postgresql", "sqlite", "sqlserver", "oracle", "snowflake"), + mcp.Description("SQL dialect: generic, postgresql, mysql, mariadb, sqlserver, oracle, sqlite, snowflake, clickhouse"), + mcp.Enum(supportedDialects...), ), ), handleValidateSQL, ) - // format_sql s.mcpSrv.AddTool( - mcp.NewTool("format_sql", - mcp.WithDescription("Format SQL with configurable indentation and keyword casing."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("format_sql", "Format SQL", + "Use this when the user wants SQL pretty-printed or reformatted, with configurable "+ + "indentation, keyword casing, and trailing semicolon. Returns {formatted_sql, options}.", + toolMeta(cfg, "Formatting SQL…", "Formatted SQL", false), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to format"), @@ -131,14 +198,11 @@ func (s *Server) registerTools() { handleFormatSQL, ) - // parse_sql s.mcpSrv.AddTool( - mcp.NewTool("parse_sql", - mcp.WithDescription("Parse SQL and return an AST summary: statement count and types."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("parse_sql", "Parse SQL", + "Use this when the user wants to know how SQL parses: returns an AST summary with "+ + "the statement count and the type of each statement.", + toolMeta(cfg, "Parsing SQL…", "Parsed SQL", false), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to parse"), @@ -147,14 +211,11 @@ func (s *Server) registerTools() { handleParseSQL, ) - // extract_metadata s.mcpSrv.AddTool( - mcp.NewTool("extract_metadata", - mcp.WithDescription("Extract tables, columns, and functions referenced in SQL."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("extract_metadata", "Extract SQL metadata", + "Use this when the user wants the tables, columns, and functions a SQL query references "+ + "(e.g. for lineage or impact analysis). Returns {tables, columns, functions}.", + toolMeta(cfg, "Extracting tables and columns…", "Extracted SQL metadata", false), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to analyze"), @@ -163,14 +224,11 @@ func (s *Server) registerTools() { handleExtractMetadata, ) - // security_scan s.mcpSrv.AddTool( - mcp.NewTool("security_scan", - mcp.WithDescription("Scan SQL for injection patterns: tautologies, UNION attacks, stacked queries, comment bypasses, and more."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("security_scan", "Scan SQL for injection", + "Use this when the user wants SQL checked for injection patterns: tautologies, UNION attacks, "+ + "stacked queries, comment bypasses, and more. Returns findings with severity counts.", + toolMeta(cfg, "Scanning SQL for injection patterns…", "Scanned SQL", false), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to scan"), @@ -179,14 +237,14 @@ func (s *Server) registerTools() { handleSecurityScan, ) - // lint_sql s.mcpSrv.AddTool( - mcp.NewTool("lint_sql", - mcp.WithDescription("Lint SQL against all 10 GoSQLX style rules (L001–L010)."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("lint_sql", "Lint SQL", + "Use this when the user wants SQL style feedback. Applies the same 10 rules as `gosqlx lint`: "+ + "L001 trailing whitespace, L002 mixed indentation, L003 consecutive blank lines, "+ + "L004 indentation depth, L005 line length (>100), L006 column alignment, L007 keyword case (upper), "+ + "L008 comma placement (trailing), L009 aliasing consistency, L010 redundant whitespace. "+ + "Returns {violation_count, violations}.", + toolMeta(cfg, "Linting SQL…", "Linted SQL", false), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to lint"), @@ -195,14 +253,12 @@ func (s *Server) registerTools() { handleLintSQL, ) - // analyze_sql s.mcpSrv.AddTool( - mcp.NewTool("analyze_sql", - mcp.WithDescription("Run all 6 analysis tools concurrently and return a composite report (validate, parse, metadata, security, lint, format)."), - mcp.WithReadOnlyHintAnnotation(true), - mcp.WithDestructiveHintAnnotation(false), - mcp.WithIdempotentHintAnnotation(true), - mcp.WithOpenWorldHintAnnotation(false), + newTool("analyze_sql", "Analyze SQL", + "Use this when the user wants a complete review of a SQL query. Runs validate, parse, metadata, "+ + "security, lint, and format concurrently and returns one composite report; "+ + "hosts that support MCP Apps render it as an interactive report.", + toolMeta(cfg, "Analyzing SQL…", "Analyzed SQL", true), mcp.WithString("sql", mcp.Required(), mcp.Description("The SQL string to analyze"), diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 481b4417..dcec3acd 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -239,12 +239,16 @@ func newFullLinter() *linter.Linter { } // toolResult marshals a map[string]any result into an MCP CallToolResult. +// +// The JSON is returned twice: as the text content (unchanged, for clients that +// only read content) and as structuredContent, which MCP Apps hosts and +// ChatGPT hand to both the model and the UI widget. func toolResult(data map[string]any) (*mcpmcp.CallToolResult, error) { b, err := json.Marshal(data) if err != nil { return nil, fmt.Errorf("failed to marshal result: %w", err) } - return mcpmcp.NewToolResultText(string(b)), nil + return mcpmcp.NewToolResultStructured(data, string(b)), nil } // --------------------------------------------------------------------------- diff --git a/pkg/mcp/widget/analysis.html b/pkg/mcp/widget/analysis.html new file mode 100644 index 00000000..81b5a7b1 --- /dev/null +++ b/pkg/mcp/widget/analysis.html @@ -0,0 +1,446 @@ + + + + + +GoSQLX analysis + + + + +
Waiting for the GoSQLX analysis…
+ + + diff --git a/render.yaml b/render.yaml index 6010b31c..87e195ec 100644 --- a/render.yaml +++ b/render.yaml @@ -10,4 +10,9 @@ services: value: "0.0.0.0" - key: GOSQLX_MCP_PORT value: "8080" + # Domain-verification token for the ChatGPT app directory, issued by the + # OpenAI Platform during submission. Set the value in the Render + # dashboard; it is served at /.well-known/openai-apps-challenge. + - key: GOSQLX_MCP_OPENAI_APPS_CHALLENGE + sync: false healthCheckPath: /health From ed67de5525f43bfb2380868d43d184658d80b3d8 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 20 Oct 2026 00:42:56 +0530 Subject: [PATCH 2/8] feat(plugin): add GoSQLX Claude Code plugin and marketplace Install with /plugin marketplace add ajitpratap0/GoSQLX and /plugin install gosqlx@gosqlx: hosted MCP server, five SQL skills, four slash commands, a read-only sql-reviewer agent and an opt-out .sql validation hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w --- .claude-plugin/marketplace.json | 21 +++ plugins/gosqlx/.claude-plugin/plugin.json | 16 ++ plugins/gosqlx/.mcp.json | 8 + plugins/gosqlx/README.md | 155 ++++++++++++++++++ plugins/gosqlx/agents/sql-reviewer.md | 30 ++++ plugins/gosqlx/commands/analyze.md | 11 ++ plugins/gosqlx/commands/format.md | 11 ++ plugins/gosqlx/commands/lint.md | 11 ++ plugins/gosqlx/commands/security.md | 13 ++ plugins/gosqlx/hooks/hooks.json | 16 ++ plugins/gosqlx/hooks/validate-sql.sh | 45 +++++ plugins/gosqlx/skills/sql-analysis/SKILL.md | 37 +++++ plugins/gosqlx/skills/sql-dialects/SKILL.md | 37 +++++ plugins/gosqlx/skills/sql-formatting/SKILL.md | 36 ++++ plugins/gosqlx/skills/sql-linting/SKILL.md | 73 +++++++++ .../skills/sql-security-review/SKILL.md | 46 ++++++ 16 files changed, 566 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/gosqlx/.claude-plugin/plugin.json create mode 100644 plugins/gosqlx/.mcp.json create mode 100644 plugins/gosqlx/README.md create mode 100644 plugins/gosqlx/agents/sql-reviewer.md create mode 100644 plugins/gosqlx/commands/analyze.md create mode 100644 plugins/gosqlx/commands/format.md create mode 100644 plugins/gosqlx/commands/lint.md create mode 100644 plugins/gosqlx/commands/security.md create mode 100644 plugins/gosqlx/hooks/hooks.json create mode 100755 plugins/gosqlx/hooks/validate-sql.sh create mode 100644 plugins/gosqlx/skills/sql-analysis/SKILL.md create mode 100644 plugins/gosqlx/skills/sql-dialects/SKILL.md create mode 100644 plugins/gosqlx/skills/sql-formatting/SKILL.md create mode 100644 plugins/gosqlx/skills/sql-linting/SKILL.md create mode 100644 plugins/gosqlx/skills/sql-security-review/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..dd5c9f2a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "gosqlx", + "description": "Official GoSQLX plugins for Claude Code: SQL validation, formatting, linting, security scanning and analysis.", + "owner": { + "name": "Ajit Pratap Singh", + "url": "https://github.com/ajitpratap0" + }, + "plugins": [ + { + "name": "gosqlx", + "source": "./plugins/gosqlx", + "description": "Validate, format, lint, analyze and security-scan SQL with GoSQLX (MCP tools, skills, slash commands, a SQL reviewer agent and a .sql validation hook).", + "version": "1.15.0", + "category": "development", + "tags": ["sql", "database", "linting", "formatting", "security", "mcp"], + "homepage": "https://gosqlx.dev", + "repository": "https://github.com/ajitpratap0/GoSQLX", + "license": "Apache-2.0" + } + ] +} diff --git a/plugins/gosqlx/.claude-plugin/plugin.json b/plugins/gosqlx/.claude-plugin/plugin.json new file mode 100644 index 00000000..f3db6b0f --- /dev/null +++ b/plugins/gosqlx/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "gosqlx", + "displayName": "GoSQLX", + "version": "1.15.0", + "description": "SQL validation, formatting, linting, injection scanning and analysis for Claude Code, powered by the GoSQLX parser (multi-dialect).", + "author": { + "name": "Ajit Pratap Singh", + "url": "https://github.com/ajitpratap0" + }, + "homepage": "https://gosqlx.dev", + "repository": "https://github.com/ajitpratap0/GoSQLX", + "documentationUrl": "https://github.com/ajitpratap0/GoSQLX/blob/main/plugins/gosqlx/README.md", + "supportUrl": "https://github.com/ajitpratap0/GoSQLX/issues", + "license": "Apache-2.0", + "keywords": ["sql", "parser", "linter", "formatter", "sql-injection", "postgresql", "mysql", "snowflake", "mcp"] +} diff --git a/plugins/gosqlx/.mcp.json b/plugins/gosqlx/.mcp.json new file mode 100644 index 00000000..caa434fe --- /dev/null +++ b/plugins/gosqlx/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "gosqlx": { + "type": "http", + "url": "${GOSQLX_MCP_URL:-https://mcp.gosqlx.dev/mcp}" + } + } +} diff --git a/plugins/gosqlx/README.md b/plugins/gosqlx/README.md new file mode 100644 index 00000000..7044242f --- /dev/null +++ b/plugins/gosqlx/README.md @@ -0,0 +1,155 @@ +# GoSQLX plugin for Claude Code + +The official [GoSQLX](https://gosqlx.dev) plugin for Claude Code. It gives Claude SQL validation, formatting, linting, SQL injection scanning and analysis, backed by the GoSQLX parser. + +## Install + +In Claude Code: + +```text +/plugin marketplace add ajitpratap0/GoSQLX +/plugin install gosqlx@gosqlx +``` + +From a shell: + +```sh +claude plugin marketplace add ajitpratap0/GoSQLX +claude plugin install gosqlx@gosqlx +``` + +The marketplace lives at the root of the GoSQLX repository. To avoid checking out the whole repository, add it with a sparse checkout: `claude plugin marketplace add ajitpratap0/GoSQLX --sparse .claude-plugin plugins`. + +Run `/reload-plugins` (or restart Claude Code) and check `/mcp`. The server appears as `plugin:gosqlx:gosqlx`. + +## What's included + +| Component | Name | What it does | +|---|---|---| +| MCP server | `gosqlx` | Remote GoSQLX MCP server at `https://mcp.gosqlx.dev/mcp`. No install, no API key | +| Skill | `sql-analysis` | Full review via `analyze_sql`, explained in plain language | +| Skill | `sql-formatting` | Formatting via `format_sql` | +| Skill | `sql-security-review` | Injection scan via `security_scan`, plus review of code that builds SQL strings | +| Skill | `sql-linting` | Lint via `lint_sql`; explains rules L001-L030 | +| Skill | `sql-dialects` | Dialect validation via `validate_sql` | +| Command | `/gosqlx:analyze` | Analyze a SQL string or `.sql` file | +| Command | `/gosqlx:format` | Format a SQL string or `.sql` file | +| Command | `/gosqlx:lint` | Lint a SQL string or `.sql` file | +| Command | `/gosqlx:security` | Injection review of SQL or a source file | +| Agent | `sql-reviewer` | Read-only reviewer for SQL files, migrations and SQL-building code | +| Hook | `PostToolUse` | After Claude writes or edits a `.sql` file, validates it with the local `gosqlx` CLI if it is installed | + +MCP tools (all read-only): `validate_sql`, `format_sql`, `parse_sql`, `extract_metadata`, `security_scan`, `lint_sql`, `analyze_sql`. In Claude Code they are named `mcp__plugin_gosqlx_gosqlx__`. + +## Usage + +```text +/gosqlx:analyze SELECT * FROM users WHERE id = 1 OR 1=1 +/gosqlx:format db/queries/report.sql +/gosqlx:lint "select a,b from t where x=1" +/gosqlx:security internal/store/users.go +@agent-gosqlx:sql-reviewer review the migrations in this branch +``` + +The skills also trigger from plain requests, for example "is this query valid on Snowflake?", "format this SQL", "explain lint rule L019" or "check this handler for SQL injection". + +### Things to know + +- `lint_sql` and `gosqlx lint` apply the style rules **L001-L010**. Rules L011-L030 (safety, performance, naming) run only through the Go library (`gosqlx.Lint`). See [LINTING_RULES.md](https://github.com/ajitpratap0/GoSQLX/blob/main/docs/LINTING_RULES.md). +- `validate_sql` accepts these dialects: `generic`, `mysql`, `postgresql`, `sqlite`, `sqlserver`, `oracle`, `snowflake`. Newer server builds also accept `mariadb` and `clickhouse`; if the connected server's tool schema does not list them, use the CLI: `gosqlx validate --dialect clickhouse file.sql`. +- The public server is rate limited: a burst of 10 requests per second and 120 weighted requests per minute per IP. `analyze_sql` costs more than the other tools. + +## The validation hook + +The hook runs `hooks/validate-sql.sh` after `Write`, `Edit` and `MultiEdit`. It is best-effort and never blocks: + +- It does nothing unless the edited file ends in `.sql` and the `gosqlx` CLI is on `PATH`. +- If `gosqlx validate` reports a syntax error, the error is passed back to Claude so it can fix the file. +- It reads the hook input with `jq` when available, with a `sed` fallback otherwise. +- To turn it off, set `GOSQLX_HOOK_DISABLE=1` in your environment. + +Install the CLI to enable it: + +```sh +go install github.com/ajitpratap0/GoSQLX/cmd/gosqlx@latest +``` + +The CLI validates with its default dialect. Dialect-specific files can produce false positives. + +## Privacy + +By default the plugin uses the hosted server `https://mcp.gosqlx.dev/mcp`. **Any SQL that Claude passes to a GoSQLX tool is sent to that server.** Don't send SQL that contains secrets, credentials or personal data. For proprietary schemas or queries, use local mode. + +The tools only parse and analyze text. They never connect to or execute anything against a database. + +## Local / private mode + +Install the MCP server binary: + +```sh +go install github.com/ajitpratap0/GoSQLX/cmd/gosqlx-mcp@latest +``` + +**Option A: keep the plugin and point it at a local HTTP server.** The plugin's server URL honours `GOSQLX_MCP_URL`: + +```sh +gosqlx-mcp # listens on 127.0.0.1:8080 (GOSQLX_MCP_HOST / GOSQLX_MCP_PORT) +GOSQLX_MCP_URL=http://127.0.0.1:8080/mcp claude +``` + +Tool names stay the same, so the skills, commands and agent work unchanged. + +**Option B: run the server over stdio.** Add it as a separate MCP server: + +```sh +claude mcp add gosqlx-local -- gosqlx-mcp --stdio +``` + +Or add it to a project `.mcp.json`: + +```json +{ + "mcpServers": { + "gosqlx-local": { + "command": "gosqlx-mcp", + "args": ["--stdio"] + } + } +} +``` + +With option B, the tools are named `mcp__gosqlx-local__`. The skills refer to tools by their short names (`analyze_sql` and so on), so Claude still finds them, but you may be asked for permission the first time each one runs. To keep everything local, also disable the plugin's remote server in `/mcp`. + +**CLI only:** with no MCP server at all, every skill, command and the agent fall back to the local `gosqlx` CLI (`validate`, `format`, `lint`, `analyze`). + +## Claude.ai and Claude Desktop (custom connector) + +Claude.ai, Claude Desktop and Cowork can't run Claude Code plugins, but they can use the same GoSQLX tools as a remote MCP custom connector. + +**Pro / Max:** + +1. Open **Customize > Connectors**. In some versions this is under **Settings > Connectors**. +2. Click **+ Add**, then **Add custom connector**. +3. Name: `GoSQLX`. Remote MCP server URL: `https://mcp.gosqlx.dev/mcp`. +4. Authentication: **No sign in**. The server needs no auth. +5. Click **Add**. + +**Team / Enterprise:** an Owner adds the connector under **Organization settings > Connectors > Add > Custom > Web** with the same name and URL. Members then connect it from **Customize > Connectors**. + +**In a chat:** click **+** in the lower-left corner of the composer, open **Connectors**, and turn on **GoSQLX**. + +On the Free plan you can add one custom connector. The same privacy note applies: SQL you send through the connector goes to `mcp.gosqlx.dev`. + +Claude Desktop can also run the server locally over stdio through `claude_desktop_config.json` (`"command": "gosqlx-mcp", "args": ["--stdio"]`). See the [MCP guide](https://github.com/ajitpratap0/GoSQLX/blob/main/docs/MCP_GUIDE.md). + +## Development + +```sh +claude plugin validate . # from the repository root (marketplace) +claude plugin validate plugins/gosqlx # the plugin itself +claude --plugin-dir ./plugins/gosqlx # try it without installing +``` + +## License + +Apache-2.0. See [LICENSE](https://github.com/ajitpratap0/GoSQLX/blob/main/LICENSE). diff --git a/plugins/gosqlx/agents/sql-reviewer.md b/plugins/gosqlx/agents/sql-reviewer.md new file mode 100644 index 00000000..dd015bdf --- /dev/null +++ b/plugins/gosqlx/agents/sql-reviewer.md @@ -0,0 +1,30 @@ +--- +name: sql-reviewer +description: Read-only reviewer for SQL files, migrations and application code that builds SQL. Uses GoSQLX tools to validate syntax, lint style, scan for injection patterns and check dialect compatibility, then returns prioritized findings. Use after SQL or data-access code is written or changed, or when asked to review database queries. +model: sonnet +tools: Read, Grep, Glob, Bash, mcp__plugin_gosqlx_gosqlx__validate_sql, mcp__plugin_gosqlx_gosqlx__format_sql, mcp__plugin_gosqlx_gosqlx__parse_sql, mcp__plugin_gosqlx_gosqlx__extract_metadata, mcp__plugin_gosqlx_gosqlx__security_scan, mcp__plugin_gosqlx_gosqlx__lint_sql, mcp__plugin_gosqlx_gosqlx__analyze_sql +--- + +You are a SQL reviewer. You review; you never edit files, apply fixes, or run SQL against any database. + +## Scope + +Work on the files or diff you are given. If none were named, use `git diff --name-only` and `git status --short` to find changed `.sql` files, migrations, and source files that contain SQL (search for `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CREATE`, query-builder and raw-query calls). + +## For each SQL statement + +1. `analyze_sql` for the combined report (validity, statement types, tables/columns, security, lint L001-L010). +2. `validate_sql` with `dialect` when the target database is known (`generic`, `mysql`, `postgresql`, `sqlite`, `sqlserver`, `oracle`, `snowflake`, plus `mariadb` and `clickhouse` when the tool schema lists them; otherwise use `gosqlx validate --dialect` for those two). Infer it from drivers, connection strings, or migration tooling. +3. Your own checks GoSQLX does not run over MCP: `DELETE`/`UPDATE` without `WHERE` (L011/L012), `DROP` without `IF EXISTS` (L013), `NOT IN` against a nullable subquery (L019), `LIMIT` without `ORDER BY` (L028), `SELECT *` (L016), implicit cross joins (L023), missing indexes implied by new predicates, and destructive or locking DDL in migrations. + +## For code that builds SQL + +Trace user-controlled values into query text. Flag concatenation, interpolation (`fmt.Sprintf`, f-strings, template literals, `String.format`), raw-query ORM calls, and dynamic identifiers. Reconstruct a hostile instance (for example `' OR 1=1 --`) and run `security_scan` on it to demonstrate the issue. Recommend bind parameters for values and allowlists for identifiers. + +## Without the MCP tools + +If the `gosqlx` MCP tools are unavailable, use the CLI through Bash, read-only commands only: `gosqlx validate [--dialect d] `, `gosqlx lint `, `gosqlx analyze --all `, `gosqlx format ` (stdout). Never pass `-i`, `--in-place` or `--auto-fix`. If neither is available, review manually and say so. + +## Output + +Return findings ordered by severity (Critical, High, Medium, Low). For each: `file:line`, the problem, evidence (tool output or code excerpt), and a concrete fix. Label which findings come from GoSQLX tools and which are your judgment. End with a one-line verdict. If nothing is wrong, say so plainly. diff --git a/plugins/gosqlx/commands/analyze.md b/plugins/gosqlx/commands/analyze.md new file mode 100644 index 00000000..f13991a2 --- /dev/null +++ b/plugins/gosqlx/commands/analyze.md @@ -0,0 +1,11 @@ +--- +description: Full GoSQLX analysis of a SQL string or .sql file (validity, metadata, security, lint, formatting) +argument-hint: "" +allowed-tools: mcp__plugin_gosqlx_gosqlx__analyze_sql mcp__plugin_gosqlx_gosqlx__validate_sql Read Bash(gosqlx analyze:*) Bash(gosqlx validate:*) +--- + +Analyze this SQL with GoSQLX: $ARGUMENTS + +If the argument is a path to an existing file, read it and use its contents as the SQL; otherwise treat the argument as SQL text. If it is empty, ask for the SQL. + +Call the `analyze_sql` MCP tool with the SQL, then follow the `sql-analysis` skill to report: validity (with line/column for errors), what the statement does, security findings by severity, lint violations by rule, and a short prioritized fix list. If the MCP server is unavailable, fall back to `gosqlx validate` and `gosqlx analyze --all` via Bash. Do not modify any file. diff --git a/plugins/gosqlx/commands/format.md b/plugins/gosqlx/commands/format.md new file mode 100644 index 00000000..00789e64 --- /dev/null +++ b/plugins/gosqlx/commands/format.md @@ -0,0 +1,11 @@ +--- +description: Format a SQL string or .sql file with GoSQLX +argument-hint: "" +allowed-tools: mcp__plugin_gosqlx_gosqlx__format_sql mcp__plugin_gosqlx_gosqlx__validate_sql Read Bash(gosqlx format:*) +--- + +Format this SQL with GoSQLX: $ARGUMENTS + +If the argument is a path to an existing file, read it and use its contents; otherwise treat the argument as SQL text. If it is empty, ask for the SQL. + +Call the `format_sql` MCP tool (2-space indent; set `uppercase_keywords` to true unless the input clearly uses lowercase keywords). Show the result in a `sql` code block. For a file, ask before overwriting it. If formatting fails, run `validate_sql` and report the syntax error. If the MCP server is unavailable, use `gosqlx format` via Bash (it prints to stdout; do not pass `-i` unless the user asked to edit the file). diff --git a/plugins/gosqlx/commands/lint.md b/plugins/gosqlx/commands/lint.md new file mode 100644 index 00000000..ff8092d9 --- /dev/null +++ b/plugins/gosqlx/commands/lint.md @@ -0,0 +1,11 @@ +--- +description: Lint a SQL string or .sql file with GoSQLX style rules (L001-L010) +argument-hint: "" +allowed-tools: mcp__plugin_gosqlx_gosqlx__lint_sql Read Bash(gosqlx lint:*) +--- + +Lint this SQL with GoSQLX: $ARGUMENTS + +If the argument is a path to an existing file, read it and use its contents; otherwise treat the argument as SQL text. If it is empty, ask for the SQL. + +Call the `lint_sql` MCP tool. Group the violations by rule ID, show each offending line with its line/column, and give the fix; note which rules `gosqlx lint --auto-fix` can fix (L001, L002, L003, L007, L010). Use the `sql-linting` skill for rule explanations. If the MCP server is unavailable, run `gosqlx lint` via Bash. Do not modify any file unless asked. diff --git a/plugins/gosqlx/commands/security.md b/plugins/gosqlx/commands/security.md new file mode 100644 index 00000000..a54dc55c --- /dev/null +++ b/plugins/gosqlx/commands/security.md @@ -0,0 +1,13 @@ +--- +description: Scan a SQL string, .sql file, or SQL-building source file for injection risks with GoSQLX +argument-hint: "" +allowed-tools: mcp__plugin_gosqlx_gosqlx__security_scan Read Grep Glob Bash(gosqlx analyze:*) Bash(gosqlx lint:*) +--- + +Run a SQL injection review with GoSQLX on: $ARGUMENTS + +- If the argument is a `.sql` file or SQL text, call the `security_scan` MCP tool on the SQL and report findings by severity with pattern, risk and suggested fix. +- If it is a source file (Go, Python, JS/TS, Java, PHP, ...), find every place that builds SQL from variables, explain which ones are injectable, scan reconstructed SQL with `security_scan` where useful, and recommend parameterized queries or allowlists. +- If it is empty, review the SQL-related code changed in this session. + +Follow the `sql-security-review` skill. If the MCP server is unavailable, use `gosqlx analyze --security` via Bash. Do not modify any file. diff --git a/plugins/gosqlx/hooks/hooks.json b/plugins/gosqlx/hooks/hooks.json new file mode 100644 index 00000000..e1c8e91e --- /dev/null +++ b/plugins/gosqlx/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/validate-sql.sh\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/gosqlx/hooks/validate-sql.sh b/plugins/gosqlx/hooks/validate-sql.sh new file mode 100755 index 00000000..56e51549 --- /dev/null +++ b/plugins/gosqlx/hooks/validate-sql.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# GoSQLX PostToolUse hook: validate a .sql file after Claude writes or edits it. +# +# Best-effort by design: it never blocks a tool call. It exits 0 silently when +# the edited file is not a .sql file, when the gosqlx CLI is not installed, or +# when the hook input cannot be read. When gosqlx reports a syntax error it +# exits 2 so the message on stderr is shown to Claude (the edit has already +# been applied; PostToolUse cannot undo it). +# +# Disable it with GOSQLX_HOOK_DISABLE=1. + +[ "${GOSQLX_HOOK_DISABLE:-0}" = "1" ] && exit 0 +command -v gosqlx >/dev/null 2>&1 || exit 0 + +input=$(cat 2>/dev/null) || exit 0 +[ -n "$input" ] || exit 0 + +file="" +if command -v jq >/dev/null 2>&1; then + file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0 +else + # Fallback without jq: take the first "file_path":"..." value. Give up on + # paths containing JSON escapes rather than risk reading the wrong file. + file=$(printf '%s' "$input" | tr -d '\n' | + sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + case $file in *\\*) exit 0 ;; esac +fi + +[ -n "$file" ] || exit 0 +case $file in + *.sql|*.SQL) ;; + *) exit 0 ;; +esac +[ -f "$file" ] || exit 0 + +if output=$(gosqlx validate -- "$file" 2>&1); then + exit 0 +fi + +{ + printf 'GoSQLX validation failed for %s:\n' "$file" + printf '%s\n' "$output" | head -n 40 + printf 'If this file targets a specific dialect, re-check with: gosqlx validate --dialect "%s"\n' "$file" +} >&2 +exit 2 diff --git a/plugins/gosqlx/skills/sql-analysis/SKILL.md b/plugins/gosqlx/skills/sql-analysis/SKILL.md new file mode 100644 index 00000000..f2a2f8fa --- /dev/null +++ b/plugins/gosqlx/skills/sql-analysis/SKILL.md @@ -0,0 +1,37 @@ +--- +name: sql-analysis +description: Full GoSQLX review of a SQL query or .sql file - syntax validity, statement types, referenced tables/columns/functions, injection patterns, style lint and a formatted version - followed by a plain-language explanation of what the SQL does and what to fix. Use when the user asks to analyze, review, check, explain or sanity-check SQL. +argument-hint: "[SQL string or path to .sql file]" +allowed-tools: mcp__plugin_gosqlx_gosqlx__analyze_sql mcp__plugin_gosqlx_gosqlx__validate_sql mcp__plugin_gosqlx_gosqlx__extract_metadata Read Bash(gosqlx analyze:*) Bash(gosqlx validate:*) +--- + +# SQL analysis with GoSQLX + +Input: `$ARGUMENTS` - a SQL string, or a path to a `.sql` file (read the file first). If nothing was given, use the SQL in the current conversation or ask for it. + +## Steps + +1. Call `analyze_sql` (MCP server `gosqlx`, tool `mcp__plugin_gosqlx_gosqlx__analyze_sql`) with `{"sql": ""}`. It runs validate, parse, metadata, security, lint and format concurrently and returns one report with those six sections. +2. If the user names a dialect, also call `validate_sql` with `dialect` set (see the `sql-dialects` skill for valid values), because `analyze_sql` validates with the generic parser. +3. For a large multi-statement file, it is fine to analyze statement by statement. + +## Report back + +- **Verdict**: valid or not. For a parse error, quote the error and point at the line/column. +- **What it does**: statement types and the tables, columns and functions it touches (from the parse and metadata sections). +- **Security**: each finding with severity (CRITICAL/HIGH/MEDIUM/LOW) and pattern. A clean scan means no known injection pattern was found, not that the calling code is safe - see `sql-security-review`. +- **Style**: lint violations (rules L001-L010) grouped by rule, with the suggested fix. +- **Formatted SQL**: show the formatted version only when it differs meaningfully. +- **Recommendations**: a short prioritized list. Add your own judgment (missing indexes, `NOT IN` with NULLs, unbounded `DELETE`/`UPDATE`, `SELECT *`) and label it as your judgment, not GoSQLX output. + +## Fallback when the MCP tools are unavailable + +If the `gosqlx` MCP server is not connected, use the local CLI via Bash (install: `go install github.com/ajitpratap0/GoSQLX/cmd/gosqlx@latest`): + +```sh +gosqlx validate path/to/query.sql # or: echo "SELECT ..." | gosqlx validate +gosqlx analyze --all path/to/query.sql # security, performance and complexity report +gosqlx analyze "SELECT * FROM users" # inline SQL +``` + +Never execute the SQL against a database as part of the analysis. diff --git a/plugins/gosqlx/skills/sql-dialects/SKILL.md b/plugins/gosqlx/skills/sql-dialects/SKILL.md new file mode 100644 index 00000000..19161fc5 --- /dev/null +++ b/plugins/gosqlx/skills/sql-dialects/SKILL.md @@ -0,0 +1,37 @@ +--- +name: sql-dialects +description: Check whether SQL parses under a specific database dialect with GoSQLX (PostgreSQL, MySQL, SQL Server, Oracle, SQLite, Snowflake, generic; MariaDB and ClickHouse via the CLI) and explain dialect-specific syntax problems. Use when the user asks if SQL is valid for a particular database, is porting SQL between databases, or gets a syntax error that may be dialect-related. +argument-hint: "[dialect] [SQL string or .sql file]" +allowed-tools: mcp__plugin_gosqlx_gosqlx__validate_sql Read Bash(gosqlx validate:*) Bash(gosqlx transpile:*) +--- + +# Dialect validation with GoSQLX + +Input: `$ARGUMENTS` - usually a dialect name and SQL (string or `.sql` path). If no dialect is given, infer it from the project (driver imports, connection strings, migration tooling) or ask. + +## MCP tool + +Call `validate_sql` (`mcp__plugin_gosqlx_gosqlx__validate_sql`) with `sql` and `dialect`. Use only values listed in the tool's `dialect` enum. Every server version accepts: + +`generic`, `mysql`, `postgresql`, `sqlite`, `sqlserver`, `oracle`, `snowflake` + +Map user wording onto them: Postgres/PG -> `postgresql`; MSSQL/T-SQL/Azure SQL -> `sqlserver`; PL/SQL -> `oracle`. The result is `{valid, error?, dialect?}`. + +## CLI-only dialects + +Newer server builds also list `mariadb` and `clickhouse`. If the connected server's enum does not include them, use the local CLI: + +```sh +gosqlx validate --dialect mariadb query.sql +gosqlx validate --dialect clickhouse query.sql +gosqlx validate --list-dialects # prints every name the CLI accepts +``` + +## Steps + +1. Validate with the requested dialect. If it fails, also validate with `generic` (or a neighbouring dialect) to tell a real syntax error apart from a dialect-specific construct. +2. Explain the error at its line/column and name the construct involved (for example backtick vs double-quote identifiers, `LIMIT` vs `TOP`/`FETCH FIRST`, `ILIKE`, `::` casts, `QUALIFY`, `ON DUPLICATE KEY UPDATE` vs `ON CONFLICT`). +3. Suggest an equivalent for the target dialect. Separate GoSQLX's verdict from your own knowledge of the database. +4. A pass means GoSQLX parses it; it does not prove the target database accepts every function or type. Say so when it matters. + +For conversion, the CLI has `gosqlx transpile --from --to `; it currently supports mysql -> postgres, postgres -> mysql and postgres -> sqlite. diff --git a/plugins/gosqlx/skills/sql-formatting/SKILL.md b/plugins/gosqlx/skills/sql-formatting/SKILL.md new file mode 100644 index 00000000..d6b1ffd7 --- /dev/null +++ b/plugins/gosqlx/skills/sql-formatting/SKILL.md @@ -0,0 +1,36 @@ +--- +name: sql-formatting +description: Format or pretty-print SQL with GoSQLX - consistent indentation, keyword casing and optional trailing semicolon. Use when the user asks to format, beautify, pretty-print, reindent or clean up SQL, or to make a .sql file consistent. +argument-hint: "[SQL string or path to .sql file]" +allowed-tools: mcp__plugin_gosqlx_gosqlx__format_sql mcp__plugin_gosqlx_gosqlx__validate_sql Read Bash(gosqlx format:*) +--- + +# SQL formatting with GoSQLX + +Input: `$ARGUMENTS` - a SQL string or a path to a `.sql` file. + +## Steps + +1. Get the SQL (read the file if a path was given). +2. Call `format_sql` (`mcp__plugin_gosqlx_gosqlx__format_sql`) with: + - `sql` (required) + - `indent_size` - spaces per level, default 2 + - `uppercase_keywords` - default `false` on the MCP tool; set `true` when the user wants `SELECT`/`FROM` style or the existing file uses uppercase keywords + - `add_semicolon` - default `false` +3. If formatting fails, the SQL probably does not parse: call `validate_sql` and report the error instead of guessing a fix. +4. Show the formatted SQL in a fenced `sql` block. When the input was a file, write the result back only if the user asked you to change the file; otherwise show it. + +Formatting is whitespace and keyword-case only - it must not change meaning. If the output appears to drop or alter anything (comments, hints, dialect syntax), say so and keep the original. + +## Fallback when the MCP tools are unavailable + +```sh +gosqlx format query.sql # print formatted SQL (keywords uppercased by default) +gosqlx format --indent 4 query.sql +gosqlx format --uppercase=false query.sql # keep original keyword case (do not use --no-uppercase: it currently uppercases) +gosqlx format --check query.sql # exit non-zero if the file needs formatting +gosqlx format -i query.sql # rewrite in place - only when asked +echo "select a from b" | gosqlx format +``` + +Note the defaults differ: the CLI uppercases keywords by default, the MCP tool does not. diff --git a/plugins/gosqlx/skills/sql-linting/SKILL.md b/plugins/gosqlx/skills/sql-linting/SKILL.md new file mode 100644 index 00000000..873751a3 --- /dev/null +++ b/plugins/gosqlx/skills/sql-linting/SKILL.md @@ -0,0 +1,73 @@ +--- +name: sql-linting +description: Lint SQL with GoSQLX and explain its rules L001-L030 (whitespace, keyword case, comma placement, aliasing, plus safety, performance and naming rules), including which rules each entry point applies and which can be auto-fixed. Use when the user asks to lint SQL, enforce SQL style, explains a GoSQLX rule ID like L007 or L019, or sets up SQL linting in CI. +argument-hint: "[SQL string, .sql file, or rule ID like L016]" +allowed-tools: mcp__plugin_gosqlx_gosqlx__lint_sql Read Bash(gosqlx lint:*) +--- + +# SQL linting with GoSQLX + +Input: `$ARGUMENTS` - SQL, a `.sql` file, or a rule ID to explain. + +## Which rules run where + +| Entry point | Rules applied | +|---|---| +| MCP `lint_sql` (`mcp__plugin_gosqlx_gosqlx__lint_sql`) | L001-L010 (style). Keyword case expected UPPER, trailing commas, max line 100 | +| `gosqlx lint` CLI | L001-L010; `--auto-fix` fixes L001, L002, L003, L007, L010 | +| `gosqlx.Lint()` Go library | All 30 rules, L001-L030 | + +L011-L030 are not returned by `lint_sql` or the CLI. When you spot one of those patterns yourself, cite the rule ID as guidance and say it comes from the Go library's rule set. + +## Rules + +| ID | Rule | Severity | Auto-fix | +|---|---|---|---| +| L001 | Trailing whitespace | Warning | Yes | +| L002 | Mixed indentation (tabs and spaces) | Error | Yes | +| L003 | Consecutive blank lines | Warning | Yes | +| L004 | Indentation depth (excessive nesting) | Warning | No | +| L005 | Long lines | Info | No | +| L006 | Column alignment in SELECT lists | Info | No | +| L007 | Keyword case consistency | Warning | Yes | +| L008 | Comma placement (trailing vs leading) | Info | No | +| L009 | Aliasing consistency | Warning | No | +| L010 | Redundant whitespace | Info | Yes | +| L011 | DELETE without WHERE | Error | No | +| L012 | UPDATE without WHERE | Error | No | +| L013 | DROP without IF EXISTS | Warning | No | +| L014 | TRUNCATE TABLE | Warning | No | +| L015 | SELECT ... INTO OUTFILE / DUMPFILE | Error | No | +| L016 | SELECT * | Warning | No | +| L017 | Missing WHERE on full scan (no WHERE or LIMIT) | Warning | No | +| L018 | Leading wildcard LIKE (`'%x'`) | Warning | No | +| L019 | NOT IN with NULL risk (prefer NOT EXISTS) | Warning | No | +| L020 | Correlated subquery in SELECT list (N+1) | Warning | No | +| L021 | OR chain instead of IN | Warning | No | +| L022 | Function on indexed column in WHERE | Warning | No | +| L023 | Implicit cross join (comma joins) | Warning | No | +| L024 | Table alias required | Warning | No | +| L025 | Reserved keyword used as identifier | Warning | No | +| L026 | INSERT without explicit column list | Warning | No | +| L027 | UNION instead of UNION ALL | Warning | No | +| L028 | LIMIT without ORDER BY | Warning | No | +| L029 | Subquery that can be a JOIN | Warning | No | +| L030 | DISTINCT on many columns | Warning | No | + +Full reference with examples: https://github.com/ajitpratap0/GoSQLX/blob/main/docs/LINTING_RULES.md + +## Steps + +1. Call `lint_sql` with `{"sql": "..."}`. Each violation has `rule`, `rule_name`, `severity`, `message`, `line`, `column` and `suggestion`. +2. Group violations by rule, show the offending line, and give the fix. For auto-fixable rules, mention `gosqlx lint --auto-fix`. +3. For a rule-ID question, explain the rule from the table above and show a before/after example. + +## Fallback when the MCP tools are unavailable + +```sh +gosqlx lint query.sql +gosqlx lint -r ./queries/ # recurse into a directory +gosqlx lint --auto-fix query.sql # rewrites the file - only when asked +gosqlx lint --fail-on-warn query.sql # non-zero exit on warnings (CI) +gosqlx lint --max-length 120 query.sql +``` diff --git a/plugins/gosqlx/skills/sql-security-review/SKILL.md b/plugins/gosqlx/skills/sql-security-review/SKILL.md new file mode 100644 index 00000000..acf0edef --- /dev/null +++ b/plugins/gosqlx/skills/sql-security-review/SKILL.md @@ -0,0 +1,46 @@ +--- +name: sql-security-review +description: SQL injection review with GoSQLX - scan SQL text for injection patterns (tautologies, UNION attacks, stacked queries, comment bypasses, time-based and out-of-band probes, dangerous functions) and review application code that builds SQL strings (concatenation, f-strings, fmt.Sprintf, template literals), recommending parameterized queries. Use when the user asks whether SQL or database code is safe, about SQL injection, or to security-review queries or data-access code. +argument-hint: "[SQL string, .sql file, or source file that builds SQL]" +allowed-tools: mcp__plugin_gosqlx_gosqlx__security_scan mcp__plugin_gosqlx_gosqlx__validate_sql Read Grep Glob Bash(gosqlx analyze:*) Bash(gosqlx lint:*) +--- + +# SQL security review with GoSQLX + +Input: `$ARGUMENTS` - a SQL string, a `.sql` file, or source code that constructs SQL. + +## 1. Scan SQL text + +Call `security_scan` (`mcp__plugin_gosqlx_gosqlx__security_scan`) with `{"sql": "..."}`. It returns `is_clean`, per-severity counts (`critical_count`, `high_count`, `medium_count`, `low_count`) and `findings[]`, each with `severity`, `pattern`, `description`, `risk` and `suggestion`. Pattern types: `TAUTOLOGY`, `COMMENT_BYPASS`, `STACKED_QUERY`, `UNION_BASED`, `UNION_INJECTION`, `UNION_GENERIC`, `TIME_BASED`, `BOOLEAN_BASED`, `OUT_OF_BAND`, `DANGEROUS_FUNCTION`. + +The scanner inspects a finished SQL string. It catches injected payloads, not the vulnerable code path that lets them in. + +## 2. Review code that builds SQL + +Use Grep/Read to find where queries are assembled. Flag any user-influenced value that reaches the SQL text through: + +- string concatenation or interpolation: `"... WHERE id = " + id`, Python f-strings / `%` / `.format()`, JS template literals, Go `fmt.Sprintf`, Java `String.format`, PHP `"$var"` in query strings; +- ORM raw-query escape hatches (`raw()`, `extra()`, `Sequelize.literal`, `knex.raw`, `db.Exec(fmt.Sprintf(...))`) fed with user input; +- dynamic identifiers (table, column, `ORDER BY` direction) taken from input. + +For each finding give: file:line, the tainted source, why it is injectable, and a fix: + +- **Values** -> placeholders/bind parameters (`?`, `$1`, `:name`, `@p1`) with the driver's parameter API. +- **Identifiers / sort direction** -> map input onto a hard-coded allowlist; never quote-escape by hand. +- **LIKE patterns** -> bind the pattern and escape `%`/`_` in user text. + +To exercise the scanner on code findings, reconstruct the SQL with a hostile value (for example `' OR 1=1 --`) and run `security_scan` on it. + +## 3. Report + +Lead with CRITICAL/HIGH issues. Separate GoSQLX scanner findings from your own code-review findings. Do not claim code is safe just because `is_clean` is true for a sample query. + +## Fallback when the MCP tools are unavailable + +```sh +gosqlx analyze --security query.sql +gosqlx lint --security query.sql # adds LIKE-injection, tautology and UNION checks to lint +echo "SELECT * FROM users WHERE id = 1 OR 1=1" | gosqlx analyze --security +``` + +Never run suspected injection payloads against a real database. From 08bbfc6ec363c8cdccdca7906810ee51401c06c0 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 20 Oct 2026 00:42:56 +0530 Subject: [PATCH 3/8] docs: add terms of service, update privacy policy and integration docs Add gosqlx.dev/terms, describe hosted MCP server, ChatGPT app and Claude plugin data handling accurately in the privacy policy, document both integrations in README, MCP guide and CHANGELOG, and fix the Claude Desktop stdio example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w --- CHANGELOG.md | 17 +++ README.md | 4 + docs/MCP_GUIDE.md | 26 +++- website/src/app/privacy/page.tsx | 46 ++++++- website/src/app/sitemap.ts | 1 + website/src/app/terms/page.tsx | 162 +++++++++++++++++++++++ website/src/components/layout/Footer.tsx | 1 + 7 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 website/src/app/terms/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f33b1f40..8cf0b59c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,23 @@ acceptance, CLI refusals), so read **Behavior changes** before upgrading. `WINDOW w AS (w2)`. (#608, #629) ### Added +- **Claude Code plugin and marketplace**: `/plugin marketplace add + ajitpratap0/GoSQLX` then `/plugin install gosqlx@gosqlx` installs the + `gosqlx` plugin (`plugins/gosqlx/`): the hosted MCP server, five SQL skills + (analysis, formatting, security review, linting, dialects), four slash + commands, a read-only `sql-reviewer` agent and an opt-out PostToolUse hook + that validates edited `.sql` files when the `gosqlx` CLI is installed. +- **ChatGPT app support in `gosqlx-mcp`** (OpenAI Apps SDK and MCP Apps): + `analyze_sql` links an embedded report widget + (`ui://gosqlx/analysis-v1.html`, `text/html;profile=mcp-app`); all tools now + return `structuredContent` alongside the unchanged text JSON, carry + `securitySchemes`/invocation-status metadata, and the server sends usage + instructions. `validate_sql` accepts `mariadb` and `clickhouse`; the server + reports `gosqlx.Version`; `/health` reports the real tool count; the + optional `GOSQLX_MCP_OPENAI_APPS_CHALLENGE` env var serves + `/.well-known/openai-apps-challenge` for app verification; idle MCP sessions + are swept after 30 minutes instead of living until restart. Setup: + `integrations/chatgpt/README.md`, details: `docs/CHATGPT_APP.md`. - **`--dialect` flag for the `parse`, `format`, `analyze`, and `lint` CLI commands**: dialect-specific SQL (e.g. MySQL `LIMIT offset, count`) can now be parsed by these commands, matching the existing `validate --dialect`. Unknown diff --git a/README.md b/README.md index 2f13a5f0..3a7fe52c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ claude mcp add --transport http gosqlx https://mcp.gosqlx.dev/mcp 7 SQL tools in Claude, Cursor, or any MCP client. [MCP guide](https://gosqlx.dev/docs/integrations/mcp/). +**Claude Code plugin** (skills, slash commands, SQL reviewer agent): `/plugin marketplace add ajitpratap0/GoSQLX` then `/plugin install gosqlx@gosqlx` - [details](plugins/gosqlx/README.md). + +**ChatGPT app**: add `https://mcp.gosqlx.dev/mcp` as a connector in developer mode for an interactive analysis widget - [details](integrations/chatgpt/README.md). + [![MCP](https://img.shields.io/badge/MCP-Remote%20Server-blue?style=flat-square)](https://mcp.gosqlx.dev/health) [![Glama MCP Server](https://glama.ai/mcp/servers/ajitpratap0/GoSQLX/badges/score.svg)](https://glama.ai/mcp/servers/ajitpratap0/GoSQLX) diff --git a/docs/MCP_GUIDE.md b/docs/MCP_GUIDE.md index 04dec099..045a3183 100644 --- a/docs/MCP_GUIDE.md +++ b/docs/MCP_GUIDE.md @@ -595,6 +595,28 @@ curl -s -X POST http://127.0.0.1:8080/mcp \ ## AI Assistant Integration +### Claude Code plugin + +The repository doubles as a Claude Code plugin marketplace. The `gosqlx` plugin +connects to the hosted server and adds SQL skills, `/gosqlx:analyze`, +`/gosqlx:format`, `/gosqlx:lint` and `/gosqlx:security` commands, a read-only +`sql-reviewer` agent and an optional hook that validates edited `.sql` files: + +``` +/plugin marketplace add ajitpratap0/GoSQLX +/plugin install gosqlx@gosqlx +``` + +See [`plugins/gosqlx/README.md`](../plugins/gosqlx/README.md) for local mode and privacy notes. + +### ChatGPT + +`gosqlx-mcp` is also a ChatGPT app (OpenAI Apps SDK): `analyze_sql` renders an +interactive report widget, and every tool returns `structuredContent`. Add +`https://mcp.gosqlx.dev/mcp` as a connector in ChatGPT developer mode (no auth). +See [`integrations/chatgpt/README.md`](../integrations/chatgpt/README.md) and +[CHATGPT_APP.md](CHATGPT_APP.md). + ### Claude Desktop Add `gosqlx-mcp` to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): @@ -604,9 +626,7 @@ Add `gosqlx-mcp` to your Claude Desktop configuration (`~/Library/Application Su "mcpServers": { "gosqlx": { "command": "gosqlx-mcp", - "env": { - "GOSQLX_MCP_PORT": "8080" - } + "args": ["--stdio"] } } } diff --git a/website/src/app/privacy/page.tsx b/website/src/app/privacy/page.tsx index 409c10e3..1ffa24ab 100644 --- a/website/src/app/privacy/page.tsx +++ b/website/src/app/privacy/page.tsx @@ -5,7 +5,7 @@ import { pageJsonLd } from '@/lib/jsonld'; export const metadata: Metadata = pageMeta({ title: 'Privacy Policy', - description: 'GoSQLX privacy policy: no tracking, no telemetry and no query logging. What the website and the playground do and do not collect.', + description: 'GoSQLX privacy policy: no tracking, no telemetry and no query logging. What the website, playground, hosted MCP server, ChatGPT app and Claude plugin do and do not collect.', path: '/privacy/', image: { url: '/opengraph-image', alt: 'GoSQLX' }, }); @@ -29,6 +29,10 @@ export default function PrivacyPage() { (and is not) collected across our website, tools, and services.

+

+ Last updated: October 20, 2026 +

+

Website

The GoSQLX website is a static site. We use Vercel Speed Insights, a @@ -47,11 +51,36 @@ export default function PrivacyPage() { ever sent to our servers.

-

Remote MCP Server

+

Remote MCP Server, ChatGPT App and Claude Plugin

+

+ The hosted MCP server at mcp.gosqlx.dev also powers the GoSQLX + ChatGPT app and, by default, the GoSQLX Claude plugin. When an AI + assistant calls a GoSQLX tool, the server receives the tool + arguments: the SQL text and options such as the dialect or + indentation. It analyzes them in memory, returns the result and + keeps nothing. It does not log, store, sell or share SQL text or + results, and it does not use them to train models. +

+

+ To serve requests, the server holds short-lived, in-memory state + that is never written to disk: an MCP session identifier for each + connected client, cleared after 30 minutes of inactivity, and per-IP + rate-limit counters, cleared after 10 minutes of inactivity. Request metadata that ChatGPT may attach, such as + locale, user agent or approximate location, is ignored. +

+

+ The server is hosted on Render behind Cloudflare. As our network and + hosting providers, they process connection data such as IP + addresses and request times to deliver and protect the service, + under their own privacy policies. Your conversations with ChatGPT or + Claude are handled by OpenAI or Anthropic under their policies; we + never see them, only the tool calls the assistant makes. +

- The hosted MCP server is stateless. It does not log, store, or - transmit any SQL queries it processes. IP-based rate-limit counters - are held in memory and automatically cleared after 10 minutes. + You stay in control: the plugin can run against a local server, and + the open-source server can be self-hosted so that SQL never leaves + your infrastructure. Do not send secrets or personal data in SQL to + any hosted service.

VS Code Extension

@@ -68,6 +97,13 @@ export default function PrivacyPage() { review them independently.

+

Changes

+

+ We will update this page when our data handling changes; the date + above shows the latest revision. See also our{' '} + Terms of Service. +

+

Contact

If you have questions about this privacy policy, please open an issue diff --git a/website/src/app/sitemap.ts b/website/src/app/sitemap.ts index b61e75e1..36f01db5 100644 --- a/website/src/app/sitemap.ts +++ b/website/src/app/sitemap.ts @@ -32,6 +32,7 @@ export default function sitemap(): MetadataRoute.Sitemap { '/benchmarks/', '/compare/', '/privacy/', + '/terms/', ] .concat(COMPETITOR_PAGES.map((c) => `/compare/${c.slug}/`)) .map((p) => ({ url: `${baseUrl}${p}` })); diff --git a/website/src/app/terms/page.tsx b/website/src/app/terms/page.tsx new file mode 100644 index 00000000..f91e1ed8 --- /dev/null +++ b/website/src/app/terms/page.tsx @@ -0,0 +1,162 @@ +import type { Metadata } from 'next'; +import { pageMeta } from '@/lib/seo'; +import { JsonLd } from '@/components/JsonLd'; +import { pageJsonLd } from '@/lib/jsonld'; + +export const metadata: Metadata = pageMeta({ + title: 'Terms of Service', + description: 'Terms for using the GoSQLX website, playground, hosted MCP server, ChatGPT app and Claude plugin: free, as-is, read-only SQL analysis with fair-use limits.', + path: '/terms/', + image: { url: '/opengraph-image', alt: 'GoSQLX' }, +}); + +const jsonLd = pageJsonLd({ + name: 'Terms of Service', + description: metadata.description as string, + path: '/terms/', + crumbs: [{ name: 'Terms of Service', path: '/terms/' }], +}); + +export default function TermsPage() { + return ( + <> + +

+
+

Terms of Service

+

+ These terms cover the GoSQLX hosted services: the gosqlx.dev website + and playground, the remote MCP server at mcp.gosqlx.dev, the GoSQLX + ChatGPT app and the GoSQLX Claude plugin (together, the + “Services”). By using the Services you agree to these + terms. +

+

+ Last updated: October 20, 2026 +

+ +

The software

+

+ The GoSQLX source code, CLI, libraries, VS Code extension and MCP + server binary are open source under the{' '} + + Apache License 2.0 + + . That license, not these terms, governs your use, modification and + distribution of the software, including when you run it yourself. +

+ +

What the Services do

+

+ The Services parse, validate, format, lint and scan SQL text that you + (or an AI assistant acting for you) send to them, and return the + analysis. They never connect to a database and never execute SQL. + The Services are free and require no account. +

+ +

Acceptable use

+

You agree not to:

+
    +
  • + send secrets, credentials, or personal or confidential data in SQL + you submit to a hosted Service (self-host the open-source server + for sensitive SQL); +
  • +
  • + attempt to overload, disrupt, probe for vulnerabilities in or get + around the rate limits of the Services, or access them by automated + means at a volume beyond normal interactive or CI use; +
  • +
  • + use the Services to break the law or to infringe anyone’s + rights. +
  • +
+

+ We may rate-limit, block or stop serving traffic that breaks these + rules or threatens the availability of the Services for others. + Report security issues privately as described in our{' '} + + security policy + + . +

+ +

Your content

+

+ You keep all rights to the SQL you submit. You grant us only the + permission needed to process it and return a result. How the hosted + Services handle your data is described in our{' '} + Privacy Policy. +

+ +

AI assistants

+

+ When you use GoSQLX through ChatGPT, Claude or another AI assistant, + your use of that assistant is governed by its provider’s own + terms and privacy policy. The assistant decides when to call GoSQLX + tools and what to tell you about the results; we are not responsible + for the assistant’s output. +

+ +

No warranty

+

+ The Services are provided “as is” and “as + available”, without warranties of any kind, express or implied, + including merchantability, fitness for a particular purpose and + non-infringement. Analysis results, including security findings and + lint suggestions, may be incomplete or wrong. A clean security scan + does not mean SQL is safe. Review results before you rely on them. +

+ +

Availability and changes

+

+ The hosted Services are run on a best-effort basis with no uptime + commitment. We may change, suspend or discontinue any of them at any + time. We may update these terms; the date above shows the latest + revision, and continued use after a change means you accept it. +

+ +

Limitation of liability

+

+ To the maximum extent permitted by law, the GoSQLX authors and + maintainers are not liable for any indirect, incidental, special, + consequential or punitive damages, or for any loss of data, profits + or business, arising from your use of or inability to use the + Services. +

+ +

Contact and support

+

+ For questions about these terms or help with the Services, open an + issue on{' '} + + GitHub Issues + {' '} + or start a thread in{' '} + + GitHub Discussions + + . +

+
+
+ + ); +} diff --git a/website/src/components/layout/Footer.tsx b/website/src/components/layout/Footer.tsx index d13eb8d5..52e0e0a2 100644 --- a/website/src/components/layout/Footer.tsx +++ b/website/src/components/layout/Footer.tsx @@ -19,6 +19,7 @@ const FOOTER_LINKS = { { label: 'Changelog', href: '/changelog' }, { label: 'CHANGELOG.md on GitHub', href: 'https://github.com/ajitpratap0/GoSQLX/blob/main/CHANGELOG.md', external: true }, { label: 'Privacy Policy', href: '/privacy' }, + { label: 'Terms of Service', href: '/terms' }, ], Community: [ { label: 'GitHub', href: 'https://github.com/ajitpratap0/GoSQLX', external: true }, From 13645174223a0daeb699f9239bbd5c45f45422e4 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 20 Oct 2026 01:14:25 +0530 Subject: [PATCH 4/8] docs(legal): harden terms and privacy policy for app directory review Name the maintainer as controller and contracting party, add a private contact and grievance officer, legal bases, recipients (incl. Vercel), international transfers, retention, security, user rights, children, DPDP Act 2023 and GDPR references; terms gain eligibility, liability cap, consumer carve-out, indemnity, termination, Indian governing law, boilerplate and conspicuous disclaimers. Correct the VS Code telemetry opt-in text and replace its dead PRIVACY.md link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w --- integrations/chatgpt/README.md | 4 +- vscode-extension/package.json | 2 +- vscode-extension/src/utils/telemetry.ts | 6 +- website/src/app/privacy/page.tsx | 207 ++++++++++++++++++------ website/src/app/terms/page.tsx | 157 +++++++++++++----- 5 files changed, 284 insertions(+), 92 deletions(-) diff --git a/integrations/chatgpt/README.md b/integrations/chatgpt/README.md index 8c85ce93..2f4130ef 100644 --- a/integrations/chatgpt/README.md +++ b/integrations/chatgpt/README.md @@ -139,8 +139,8 @@ available to projects with EU data residency. - [ ] Privacy policy URL: . It must cover the data categories, purposes, recipients and user controls. - [ ] Terms of service URL: . -- [ ] Support contact: (if the form - insists on an email address, use a maintainer-controlled inbox). +- [ ] Support contact: ; email + `privacy@gosqlx.dev` (forwarding must be set up on gosqlx.dev). - [ ] Logo (light and dark), at least 3 screenshots of the `analyze_sql` widget, and a short demo video. - [ ] Test cases: use the positive and negative example prompts above diff --git a/vscode-extension/package.json b/vscode-extension/package.json index ffe408f5..09f3ec3f 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -151,7 +151,7 @@ "gosqlx.telemetry.enable": { "type": "boolean", "default": false, - "description": "Enable anonymous usage telemetry to help improve GoSQLX. No SQL content or file paths are collected." + "description": "Record anonymous usage statistics in memory on this machine. They are never transmitted. No SQL content or file paths are collected." }, "gosqlx.performance.showStatusBar": { "type": "boolean", diff --git a/vscode-extension/src/utils/telemetry.ts b/vscode-extension/src/utils/telemetry.ts index fc8472ac..ad6650b8 100644 --- a/vscode-extension/src/utils/telemetry.ts +++ b/vscode-extension/src/utils/telemetry.ts @@ -314,8 +314,8 @@ export class TelemetryManager { * Prompts user to opt-in to telemetry. */ export async function promptTelemetryOptIn(): Promise { - const message = 'Would you like to help improve GoSQLX by sharing anonymous usage data? ' + - 'No SQL content or file paths are ever collected.'; + const message = 'Allow GoSQLX to record anonymous usage statistics on this machine? ' + + 'They are never transmitted, and no SQL content or file paths are collected.'; const learnMore = 'Learn More'; const yes = 'Yes, help improve'; @@ -330,7 +330,7 @@ export async function promptTelemetryOptIn(): Promise { if (choice === learnMore) { vscode.env.openExternal( - vscode.Uri.parse('https://github.com/ajitpratap0/GoSQLX/blob/main/PRIVACY.md') + vscode.Uri.parse('https://gosqlx.dev/privacy/#vscode') ); // Re-prompt after they've had a chance to read return promptTelemetryOptIn(); diff --git a/website/src/app/privacy/page.tsx b/website/src/app/privacy/page.tsx index 1ffa24ab..2dd35e64 100644 --- a/website/src/app/privacy/page.tsx +++ b/website/src/app/privacy/page.tsx @@ -5,7 +5,7 @@ import { pageJsonLd } from '@/lib/jsonld'; export const metadata: Metadata = pageMeta({ title: 'Privacy Policy', - description: 'GoSQLX privacy policy: no tracking, no telemetry and no query logging. What the website, playground, hosted MCP server, ChatGPT app and Claude plugin do and do not collect.', + description: 'GoSQLX privacy policy: no tracking cookies and no query logging. What the website, playground, hosted MCP server, ChatGPT app, Claude plugin and VS Code extension collect, why, who receives it and your rights.', path: '/privacy/', image: { url: '/opengraph-image', alt: 'GoSQLX' }, }); @@ -17,6 +17,8 @@ const jsonLd = pageJsonLd({ crumbs: [{ name: 'Privacy Policy', path: '/privacy/' }], }); +const CONTACT_EMAIL = 'privacy@gosqlx.dev'; + export default function PrivacyPage() { return ( <> @@ -25,91 +27,202 @@ export default function PrivacyPage() {

Privacy Policy

- GoSQLX is committed to user privacy. This policy explains what data is - (and is not) collected across our website, tools, and services. + This policy explains what personal data the GoSQLX website, playground, + hosted MCP server, ChatGPT app, Claude plugin and VS Code extension + collect, why, who receives it, and the choices and rights you have.

-

Last updated: October 20, 2026

-

Website

+

Who is responsible

+

+ GoSQLX is an open-source project run by its maintainer, Ajit Pratap + Singh, an individual developer based in India (“we”, + “us”). He is the controller (data fiduciary) for the + personal data described here and also acts as grievance officer. + Contact:{' '} + {CONTACT_EMAIL}. +

+ +

Website

+

+ The website gosqlx.dev is hosted by Vercel, which, like any web host, + processes your IP address, browser user agent and the pages you + request in order to serve the site and protect it from abuse. We set + no cookies. Your light/dark theme choice is saved in your + browser’s local storage and never leaves your device. +

- The GoSQLX website is a static site. We use Vercel Speed Insights, a - privacy-friendly service that measures page performance (such as load - time) without cookies and without tracking individual users. We also - use Sentry to receive error reports when something breaks in your - browser; these contain the error message, stack trace, and browser and - page details, but we do not enable session replay, and we do not - collect IP addresses or other personal data through it. + We use Vercel Speed Insights to measure page performance (such as + load time) without cookies and without identifying individual + visitors. We use Sentry to receive error reports and a sample of + performance traces from your browser. These contain the error + message, stack trace, page address and browser and device details. + IP addresses are not stored, session replay is off, and we do not + send other personal data to Sentry.

-

WASM Playground

+

WASM Playground

- All SQL parsing in the interactive playground runs entirely in your - browser via WebAssembly. No queries, SQL text, or parsing results are - ever sent to our servers. + SQL in the interactive playground is parsed entirely in your browser + via WebAssembly; we do not receive your SQL or the results. Error + reports are not designed to include what you type, but if the + playground itself fails, an error message sent to Sentry could + contain a short fragment of it.

-

Remote MCP Server, ChatGPT App and Claude Plugin

+

Remote MCP Server, ChatGPT App and Claude Plugin

The hosted MCP server at mcp.gosqlx.dev also powers the GoSQLX ChatGPT app and, by default, the GoSQLX Claude plugin. When an AI assistant calls a GoSQLX tool, the server receives the tool arguments: the SQL text and options such as the dialect or indentation. It analyzes them in memory, returns the result and - keeps nothing. It does not log, store, sell or share SQL text or - results, and it does not use them to train models. + keeps nothing. We do not log, store, sell or share SQL text or + results, and we do not use them to train models.

- To serve requests, the server holds short-lived, in-memory state + To serve requests, the server holds short-lived state in memory that is never written to disk: an MCP session identifier for each - connected client, cleared after 30 minutes of inactivity, and per-IP - rate-limit counters, cleared after 10 minutes of inactivity. Request metadata that ChatGPT may attach, such as - locale, user agent or approximate location, is ignored. + connected client, deleted after 30 minutes of inactivity, and + per-IP rate-limit counters, deleted after 10 minutes of inactivity. + ChatGPT may attach metadata to requests, such as locale, user agent + or approximate location; the server receives it but does not use, + log or store it. +

+

+ The server is hosted on Render behind Cloudflare. As our hosting and + network providers, they process connection data such as IP + addresses and request times to deliver and protect the service. + Your conversations with ChatGPT or Claude are handled by OpenAI or + Anthropic under their own policies; we never see them, only the + tool calls the assistant makes. +

+

+ You stay in control: the Claude plugin can use a local server + instead, and the open-source server can be self-hosted so that SQL + never leaves your infrastructure. Do not send secrets or personal + data in SQL to any hosted service. +

+ +

VS Code Extension

+

+ The GoSQLX VS Code extension runs entirely on your machine and sends + nothing to us or anyone else. An optional, off-by-default setting + records anonymous usage statistics (such as command durations and + error codes, never SQL or file paths) in memory on your machine + only; they are never transmitted. +

+ +

Why we process data and on what basis

+
    +
  • + Delivering the services and returning results{' '} + to the request you or your AI assistant made. +
  • +
  • + Security, abuse prevention and rate limiting{' '} + (IP addresses, connection data). +
  • +
  • + Fixing errors and improving performance (Sentry, + Speed Insights). +
  • +
+

+ Where the EU or UK GDPR applies, our legal basis is our legitimate + interest in providing a secure, working free service (Art. 6(1)(f) + GDPR). Under India’s Digital Personal Data Protection Act, + 2023, we process the data you voluntarily provide for the specified + purpose of serving your request, which is a legitimate use under + Section 7 of that Act. +

+ +

Who receives data

+

+ We use these service providers, each under its own privacy policy: + Vercel (website hosting and Speed Insights), Sentry (error and + performance monitoring), Render (MCP server hosting) and Cloudflare + (network and security for the MCP server). We do not sell or share + personal information, including as those terms are defined in the + California Consumer Privacy Act, and we do not use it for + advertising. +

+ +

International transfers

+

+ These providers process data in the United States and other + countries. Where data leaves the EEA, UK or India, transfers rely on + the providers’ safeguards, such as Standard Contractual + Clauses or the EU–U.S. Data Privacy Framework, and are made + in line with applicable law.

+ +

Retention

+

+ We keep SQL and tool results only for the time it takes to process + a request. Session and rate-limit state is deleted as described + above. Our providers keep connection logs and error reports for + their standard retention periods, after which they are deleted. +

+ +

Security

+

+ All traffic to the website and the MCP server is encrypted with TLS. + The MCP server writes no SQL, results or request data to disk. +

+ +

Your rights

- The server is hosted on Render behind Cloudflare. As our network and - hosting providers, they process connection data such as IP - addresses and request times to deliver and protect the service, - under their own privacy policies. Your conversations with ChatGPT or - Claude are handled by OpenAI or Anthropic under their policies; we - never see them, only the tool calls the assistant makes. + Depending on where you live, you may have the right to access, + correct, erase or receive a copy of your personal data, to object to + or restrict its processing, to withdraw consent, and to nominate + someone to exercise your rights. Because we keep almost no personal + data, there is usually nothing tied to you that we can find, but we + will answer every request. Email{' '} + {CONTACT_EMAIL}; we respond + within 30 days.

- You stay in control: the plugin can run against a local server, and - the open-source server can be self-hosted so that SQL never leaves - your infrastructure. Do not send secrets or personal data in SQL to - any hosted service. + If you are unhappy with our response, you may complain to your + local data protection authority (in the EU or UK) or, in India, to + the Data Protection Board of India after first raising the + grievance with us.

-

VS Code Extension

+

Children

- The GoSQLX VS Code extension runs entirely on your local machine. It - does not send telemetry, usage data, or any SQL content to external - servers. + GoSQLX is a developer tool and is not directed to children. We do + not knowingly collect personal data from anyone under 13 (or under + the age of digital consent where you live, such as 16 in parts of + the EU, or 18 in India). If you believe a child has sent us + personal data, contact us and we will delete it.

-

Third-party Links

+

- The website links to third-party services such as GitHub and Render. - These services have their own privacy policies. We encourage you to - review them independently. + The website links to third-party services such as GitHub. These + services have their own privacy policies, which we encourage you to + review.

-

Changes

+

Changes

- We will update this page when our data handling changes; the date - above shows the latest revision. See also our{' '} + We will update this page when our data handling changes and change + the date above. For material changes we will also note them in the + project changelog. See also our{' '} Terms of Service.

-

Contact

+

Contact and grievances

- If you have questions about this privacy policy, please open an issue + For privacy questions, requests or grievances, email{' '} + {CONTACT_EMAIL} (grievance + officer: Ajit Pratap Singh). For general questions, open an issue on our{' '} diff --git a/website/src/app/terms/page.tsx b/website/src/app/terms/page.tsx index f91e1ed8..a8abf857 100644 --- a/website/src/app/terms/page.tsx +++ b/website/src/app/terms/page.tsx @@ -17,6 +17,8 @@ const jsonLd = pageJsonLd({ crumbs: [{ name: 'Terms of Service', path: '/terms/' }], }); +const CONTACT_EMAIL = 'privacy@gosqlx.dev'; + export default function TermsPage() { return ( <> @@ -25,17 +27,19 @@ export default function TermsPage() {

Terms of Service

- These terms cover the GoSQLX hosted services: the gosqlx.dev website - and playground, the remote MCP server at mcp.gosqlx.dev, the GoSQLX - ChatGPT app and the GoSQLX Claude plugin (together, the - “Services”). By using the Services you agree to these - terms. + These terms are an agreement between you and Ajit Pratap Singh, the + individual developer who maintains GoSQLX (“we”, + “us”). They cover the GoSQLX hosted services: the + gosqlx.dev website and playground, the remote MCP server at + mcp.gosqlx.dev, the GoSQLX ChatGPT app and the GoSQLX Claude plugin + (together, the “Services”). By using the Services you + agree to these terms. If you do not agree, do not use them.

Last updated: October 20, 2026

-

The software

+

The software

The GoSQLX source code, CLI, libraries, VS Code extension and MCP server binary are open source under the{' '} @@ -48,9 +52,11 @@ export default function TermsPage() { . That license, not these terms, governs your use, modification and distribution of the software, including when you run it yourself. + As the license states, it grants no rights in the GoSQLX name or + logo, which you may use only to refer to the project accurately.

-

What the Services do

+

What the Services do

The Services parse, validate, format, lint and scan SQL text that you (or an AI assistant acting for you) send to them, and return the @@ -58,8 +64,20 @@ export default function TermsPage() { The Services are free and require no account.

-

Acceptable use

-

You agree not to:

+

Eligibility

+

+ You must be at least 18, or the age of majority where you live, or + use the Services with the consent and supervision of a parent or + guardian. If you use the Services for an organization, you confirm + that you may accept these terms for it. +

+ +

Acceptable use

+

+ Use through AI assistants and integrations, including the GoSQLX + ChatGPT app and Claude plugin, and reasonable automated use such as + CI checks, is permitted. You agree not to: +

  • send secrets, credentials, or personal or confidential data in SQL @@ -68,8 +86,8 @@ export default function TermsPage() {
  • attempt to overload, disrupt, probe for vulnerabilities in or get - around the rate limits of the Services, or access them by automated - means at a volume beyond normal interactive or CI use; + around the rate limits of the Services, or send traffic at a + volume that degrades them for others;
  • use the Services to break the law or to infringe anyone’s @@ -77,8 +95,6 @@ export default function TermsPage() {

- We may rate-limit, block or stop serving traffic that breaks these - rules or threatens the availability of the Services for others. Report security issues privately as described in our{' '} -

Your content

+

Your content

- You keep all rights to the SQL you submit. You grant us only the - permission needed to process it and return a result. How the hosted - Services handle your data is described in our{' '} + You keep all rights to the SQL you submit, and you are responsible + for having the right to submit it. You grant us only the permission + needed to process it and return a result. How the Services handle + your data is described in our{' '} Privacy Policy.

-

AI assistants

+

AI assistants and third-party services

When you use GoSQLX through ChatGPT, Claude or another AI assistant, your use of that assistant is governed by its provider’s own terms and privacy policy. The assistant decides when to call GoSQLX tools and what to tell you about the results; we are not responsible - for the assistant’s output. + for the assistant’s output or for third-party services. +

+ +

No warranty

+

+ + The Services are provided “as is” and “as + available”, without warranties of any kind, express or + implied, including merchantability, fitness for a particular + purpose, accuracy and non-infringement, to the fullest extent + permitted by law. + +

+

+ Analysis results, including security findings and lint suggestions, + may be incomplete or wrong. A clean security scan does not mean SQL + is safe, and the Services are not a substitute for professional + security or code review. You are responsible for reviewing results + before you rely on them.

-

No warranty

+

Limitation of liability

+

+ + To the fullest extent permitted by law, we are not liable for any + indirect, incidental, special, consequential, exemplary or + punitive damages, or for any loss of data, profits, revenue or + business, arising from or related to the Services, even if + advised of the possibility. Our total liability for all claims + relating to the Services is limited to the greater of the amount + you paid us for them in the 12 months before the claim (which, + for these free Services, is zero) and USD 100. + +

- The Services are provided “as is” and “as - available”, without warranties of any kind, express or implied, - including merchantability, fitness for a particular purpose and - non-infringement. Analysis results, including security findings and - lint suggestions, may be incomplete or wrong. A clean security scan - does not mean SQL is safe. Review results before you rely on them. + Nothing in these terms excludes or limits liability that cannot be + excluded or limited by law, including liability for death or + personal injury caused by negligence, for fraud, or for gross + negligence or wilful misconduct, or any rights you have as a + consumer under mandatory law.

-

Availability and changes

+

Indemnity

+ To the extent permitted by law, you will compensate us for + reasonable losses and costs arising from third-party claims caused + by your breach of the acceptable-use rules above or your misuse of + the Services. +

+ +

Suspension and termination

+

+ We may rate-limit, suspend or end access to the Services for anyone + who breaks these terms or threatens their availability for others, + and we may change, suspend or discontinue any Service at any time. The hosted Services are run on a best-effort basis with no uptime - commitment. We may change, suspend or discontinue any of them at any - time. We may update these terms; the date above shows the latest - revision, and continued use after a change means you accept it. + commitment. The sections on your content, no warranty, limitation of + liability, indemnity and governing law survive termination. +

+ +

Changes to these terms

+

+ We may update these terms and will change the date above. Material + changes take effect 14 days after they are posted, and we will note + them in the project changelog; other changes take effect when + posted. If you keep using the Services after a change takes effect, + the updated terms apply. +

+ +

Governing law and disputes

+

+ These terms are governed by the laws of India. The courts at + Bengaluru, Karnataka have exclusive jurisdiction over disputes + arising from them, except that if you use the Services as a + consumer, you may also bring proceedings in, and keep the + protection of the mandatory laws of, the country where you live. + Before starting proceedings, please contact us so we can try to + resolve the issue informally.

-

Limitation of liability

+

General

- To the maximum extent permitted by law, the GoSQLX authors and - maintainers are not liable for any indirect, incidental, special, - consequential or punitive damages, or for any loss of data, profits - or business, arising from your use of or inability to use the - Services. + These terms and the Privacy Policy are the entire agreement between + you and us about the Services. If any provision is held + unenforceable, the rest remain in effect. Our failure to enforce a + provision is not a waiver. You may not transfer these terms; we may + transfer them to someone who takes over the project. If a + translation conflicts with the English version, the English + version controls.

-

Contact and support

+

Contact and support

- For questions about these terms or help with the Services, open an - issue on{' '} + For legal notices or questions about these terms, email{' '} + {CONTACT_EMAIL}. For help + with the Services, open an issue on{' '} Date: Tue, 20 Oct 2026 01:15:47 +0530 Subject: [PATCH 5/8] fix(website): keep privacy meta description within SEO length limit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w --- website/src/app/privacy/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/app/privacy/page.tsx b/website/src/app/privacy/page.tsx index 2dd35e64..89cc7664 100644 --- a/website/src/app/privacy/page.tsx +++ b/website/src/app/privacy/page.tsx @@ -5,7 +5,7 @@ import { pageJsonLd } from '@/lib/jsonld'; export const metadata: Metadata = pageMeta({ title: 'Privacy Policy', - description: 'GoSQLX privacy policy: no tracking cookies and no query logging. What the website, playground, hosted MCP server, ChatGPT app, Claude plugin and VS Code extension collect, why, who receives it and your rights.', + description: 'GoSQLX privacy policy: no cookies, no SQL logging. What the website, playground, MCP server, ChatGPT app and Claude plugin collect, and your rights.', path: '/privacy/', image: { url: '/opengraph-image', alt: 'GoSQLX' }, }); From 48bda6ebc7a1d3500fa00eae28bef03fd76f2940 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 20 Oct 2026 12:28:11 +0530 Subject: [PATCH 6/8] fix(mcp): address PR #823 test findings in widget, sessions and format_sql - widget: cap long lists (counted statement types, +N more), show failed tool calls as errors on both host bridges, skip clipboard API when the frame's permissions policy blocks it - server: one streamable transport per Server reused by Handler/Start, new Server.Shutdown, Start gains read-header/idle timeouts, transport logger drops Info so session IDs are never logged - format_sql: uppercase_keywords=true also uppercases operator/function keywords; option semantics documented precisely - docs/privacy: session state is swept 30-45 minutes after last request and IDs are not tracked - tests: full HTTP chain via newMux, session TTL wiring, sweeper leak, widget script structure, constants and metadata keys Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w --- CHANGELOG.md | 9 +- cmd/gosqlx-mcp/main_test.go | 133 +++++++++- docs/CHATGPT_APP.md | 27 ++ integrations/chatgpt/README.md | 8 +- pkg/mcp/apps_test.go | 83 +++++- pkg/mcp/format_options_test.go | 238 +++++++++++++++++ pkg/mcp/lifecycle_test.go | 239 ++++++++++++++++++ pkg/mcp/server.go | 134 ++++++++-- pkg/mcp/tools.go | 125 +++++++++ pkg/mcp/widget/analysis.html | 142 +++++++++-- plugins/gosqlx/commands/format.md | 2 +- plugins/gosqlx/skills/sql-formatting/SKILL.md | 8 +- website/src/app/privacy/page.tsx | 10 +- 13 files changed, 1094 insertions(+), 64 deletions(-) create mode 100644 pkg/mcp/format_options_test.go create mode 100644 pkg/mcp/lifecycle_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf0b59c..468980df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,7 +227,14 @@ acceptance, CLI refusals), so read **Behavior changes** before upgrading. reports `gosqlx.Version`; `/health` reports the real tool count; the optional `GOSQLX_MCP_OPENAI_APPS_CHALLENGE` env var serves `/.well-known/openai-apps-challenge` for app verification; idle MCP sessions - are swept after 30 minutes instead of living until restart. Setup: + have their transport state swept 30–45 minutes after their last request + (`SessionIdleTTL`, sweep every TTL/2) instead of living until restart, and + session IDs are not logged. `uppercase_keywords: true` on + `format_sql` now also uppercases operator and function keywords (`AND`, + `OR`, `COUNT`); `indent_size` affects nested blocks only, as now + documented. `Server.Shutdown(ctx)` stops the transport, and `Server.Start` + sets read-header and idle timeouts. The `analyze_sql` widget caps long + lists, shows failed calls as errors and avoids blocked clipboard calls. Setup: `integrations/chatgpt/README.md`, details: `docs/CHATGPT_APP.md`. - **`--dialect` flag for the `parse`, `format`, `analyze`, and `lint` CLI commands**: dialect-specific SQL (e.g. MySQL `LIMIT offset, count`) can now be diff --git a/cmd/gosqlx-mcp/main_test.go b/cmd/gosqlx-mcp/main_test.go index 6191b406..e73b8807 100644 --- a/cmd/gosqlx-mcp/main_test.go +++ b/cmd/gosqlx-mcp/main_test.go @@ -15,10 +15,13 @@ package main import ( + "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" @@ -27,7 +30,7 @@ import ( func TestNewMux_Health(t *testing.T) { cfg := gosqlxmcp.DefaultConfig() - srv := gosqlxmcp.New(cfg) + srv := newTestServer(t, cfg) ts := httptest.NewServer(newMux(cfg, srv)) t.Cleanup(ts.Close) @@ -69,7 +72,7 @@ func TestNewMux_OpenAIAppsChallenge(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := gosqlxmcp.DefaultConfig() cfg.OpenAIAppsChallenge = tt.token - ts := httptest.NewServer(newMux(cfg, gosqlxmcp.New(cfg))) + ts := httptest.NewServer(newMux(cfg, newTestServer(t, cfg))) t.Cleanup(ts.Close) resp, err := http.Get(ts.URL + "/.well-known/openai-apps-challenge") @@ -96,3 +99,129 @@ func TestNewMux_OpenAIAppsChallenge(t *testing.T) { }) } } + +// newTestServer builds a Server whose session sweeper is stopped at test end. +func newTestServer(t *testing.T, cfg *gosqlxmcp.Config) *gosqlxmcp.Server { + t.Helper() + srv := gosqlxmcp.New(cfg) + t.Cleanup(func() { + if err := srv.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown: %v", err) + } + }) + return srv +} + +// mcpPost sends one JSON-RPC message to url. xff isolates the request in its +// own rate-limit bucket. +func mcpPost(t *testing.T, url, token, sessionID, xff, body string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("X-Forwarded-For", xff) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return resp, string(b) +} + +const initializeRequest = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"main-test","version":"0"}}}` + +// TestNewMux_HTTPChain exercises the production chain built by newMux: +// rate limit -> bearer auth -> MCP, on both /mcp and /mcp/. +func TestNewMux_HTTPChain(t *testing.T) { + const token = "s3cret" + cfg := gosqlxmcp.DefaultConfig() + cfg.AuthToken = token + ts := httptest.NewServer(newMux(cfg, newTestServer(t, cfg))) + t.Cleanup(ts.Close) + + for i, path := range []string{"/mcp", "/mcp/"} { + t.Run(path, func(t *testing.T) { + url := ts.URL + path + xff := fmt.Sprintf("192.0.2.%d", i+1) + + resp, _ := mcpPost(t, url, "", "", xff, initializeRequest) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("no token: status = %d, want 401", resp.StatusCode) + } + if resp.Header.Get("WWW-Authenticate") == "" { + t.Error("no token: WWW-Authenticate header missing") + } + if resp.Header.Get("X-RateLimit-Limit") == "" { + t.Error("no token: X-RateLimit-Limit missing; the rate limiter must run before auth") + } + + resp, _ = mcpPost(t, url, "wrong", "", xff, initializeRequest) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("wrong token: status = %d, want 401", resp.StatusCode) + } + + resp, body := mcpPost(t, url, token, "", xff, initializeRequest) + if resp.StatusCode != http.StatusOK { + t.Fatalf("valid token: status = %d, want 200 (body %s)", resp.StatusCode, body) + } + if !strings.Contains(body, `"serverInfo"`) { + t.Errorf("initialize response has no serverInfo: %s", body) + } + if resp.Header.Get("Mcp-Session-Id") == "" { + t.Error("initialize response has no Mcp-Session-Id") + } + if resp.Header.Get("X-RateLimit-Remaining") == "" { + t.Error("X-RateLimit-Remaining missing on an allowed request") + } + }) + } + + t.Run("rate limit", func(t *testing.T) { + const xff = "198.51.100.7" + resp, _ := mcpPost(t, ts.URL+"/mcp", token, "", xff, initializeRequest) + sid := resp.Header.Get("Mcp-Session-Id") + call := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"validate_sql","arguments":{"sql":"SELECT 1"}}}` + limited := false + for i := 0; i < 30 && !limited; i++ { + resp, body := mcpPost(t, ts.URL+"/mcp", token, sid, xff, call) + if strings.Contains(body, "Rate limit exceeded") { + limited = true + if !strings.Contains(body, `"code":-32000`) { + t.Errorf("rate-limit response is not a JSON-RPC -32000 error: %s", body) + } + if resp.Header.Get("X-RateLimit-Remaining") != "0" { + t.Errorf("X-RateLimit-Remaining = %q, want 0", resp.Header.Get("X-RateLimit-Remaining")) + } + } + } + if !limited { + t.Error("30 rapid tools/call requests from one IP were never rate limited") + } + }) + + t.Run("unknown paths", func(t *testing.T) { + for _, p := range []string{"/", "/.well-known/oauth-authorization-server", "/.well-known/openai-apps-challenge"} { + resp, err := http.Get(ts.URL + p) + if err != nil { + t.Fatalf("GET %s: %v", p, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", p, resp.StatusCode) + } + } + }) +} diff --git a/docs/CHATGPT_APP.md b/docs/CHATGPT_APP.md index 7ebfc1ea..267f55bb 100644 --- a/docs/CHATGPT_APP.md +++ b/docs/CHATGPT_APP.md @@ -162,6 +162,15 @@ build step or SDK: 4. ChatGPT fallback: reads `window.openai.toolOutput`, `toolInput` and `theme`, and listens for `openai:set_globals`. 5. Theme: follows the host theme (`data-theme`) and otherwise `prefers-color-scheme`. +6. Failed calls: a tool result with `isError: true`, or one without analysis data, + shows "Analysis failed" with the error text instead of waiting forever. +7. Large results: repeated statement types collapse into counts (`SELECT ×5000`), and + every chip and finding list shows its first 50 items with a "+K more" toggle; an + expanded list scrolls inside a fixed height. The reported iframe height stays + bounded however large the input is. +8. Copy uses the async Clipboard API only when the frame's permissions policy allows + `clipboard-write`, and `execCommand("copy")` otherwise, so hosts that omit + `allow="clipboard-write"` see no console error. It renders all data with `textContent`, so SQL text and error messages are never parsed as HTML. The test `TestApps_ResourcesRead_ReturnsSelfContainedWidget` fails if the @@ -176,6 +185,24 @@ HTML gains remote `src`/`href` URLs, `@import`, `fetch(`, `XMLHttpRequest` or | `/health` | `{"status":"ok","version":,"tools":}` | | `/.well-known/openai-apps-challenge` | Plain-text domain-verification token. Served only when `GOSQLX_MCP_OPENAI_APPS_CHALLENGE` is set; returns 404 otherwise | +### Sessions + +The streamable HTTP transport runs in mcp-go's default stateless mode. Session IDs +are random, the server does not record which ones it issued, and any well-formed +ID is accepted. Per-session transport state (request-ID counters, per-session +tool/resource registrations, last-activity time) is removed by a sweeper that runs +every `SessionIdleTTL/2`, so state goes 30 to 45 minutes after a session's last +request (`SessionIdleTTL` is 30 minutes). Neither the sweep nor `DELETE /mcp` +invalidates an ID: a later request with the same ID simply starts new state. +mcp-go's informational transport logs, which include session IDs, are discarded; +only transport errors are logged, so nothing identifies a session in the logs. + +The HTTP server sets `ReadHeaderTimeout`. `Server.Start` (library use) also sets +`IdleTimeout` but applies only bearer auth, not the rate limiter or the 64 KB body +cap; `cmd/gosqlx-mcp` wraps the handler in `RateLimitMiddleware`. Call +`Server.Shutdown` when you discard a `Server` whose `Handler` you used, to stop the +session sweeper goroutine. + The server sends no CORS headers. ChatGPT and Claude connect to MCP servers server to server, and nothing in the Apps SDK or MCP Apps requirements asks for CORS on the MCP endpoint. Browser-based tools such as MCP Inspector go through their own proxy. diff --git a/integrations/chatgpt/README.md b/integrations/chatgpt/README.md index 2f4130ef..6544e3a5 100644 --- a/integrations/chatgpt/README.md +++ b/integrations/chatgpt/README.md @@ -45,7 +45,7 @@ what you see, look for "Developer mode" and "Create connector" under Apps or Con |---|---| | "Review this query: `SELECT * FROM orders o JOIN users u ON o.user_id = u.id WHERE u.email = 'a' OR 1=1`" | `analyze_sql` (renders the report) | | "Is `SELECT TOP 10 * FROM t` valid SQL Server syntax?" | `validate_sql` with `dialect: sqlserver` | -| "Format this SQL with 4-space indentation and uppercase keywords: …" | `format_sql` | +| "Format this SQL with uppercase keywords and a trailing semicolon: …" | `format_sql` | | "Which tables and columns does this query touch?" | `extract_metadata` | | "Does this WHERE clause look like SQL injection? `id = '' OR '1'='1' --`" | `security_scan` | | "Lint this SQL for style problems." | `lint_sql` | @@ -109,8 +109,10 @@ are not accepted for directory submission. - All tools run locally inside the server process on the SQL text you send. GoSQLX never connects to a database and never executes SQL. - The hosted server at `mcp.gosqlx.dev` does not log, store or forward the SQL it - processes. Its only state is in memory: MCP session state (swept after 30 minutes of inactivity) - and per-IP rate-limit counters (dropped after 10 minutes of inactivity). Render and + processes. Its only state is in memory: per-session MCP transport state (removed 30 to 45 + minutes after the session's last request) and per-IP rate-limit counters (dropped after 10 + minutes of inactivity). MCP session IDs are random; the server does not record or log them, + and removing a session's state does not invalidate its ID. Render and Cloudflare, which host and front it, see connection metadata such as IP addresses. See . - Tool results contain only analysis of the SQL you sent: no user identifiers, request diff --git a/pkg/mcp/apps_test.go b/pkg/mcp/apps_test.go index 74bcf6b6..5b33fa48 100644 --- a/pkg/mcp/apps_test.go +++ b/pkg/mcp/apps_test.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "reflect" + "regexp" "strings" "testing" @@ -96,6 +97,9 @@ func checkWidgetResourceMeta(t *testing.T, meta map[string]any) { if ui["prefersBorder"] != true { t.Errorf("ui.prefersBorder = %v, want true", ui["prefersBorder"]) } + if meta["openai/widgetPrefersBorder"] != true { + t.Errorf("openai/widgetPrefersBorder = %v, want true", meta["openai/widgetPrefersBorder"]) + } perms := mustMap(t, ui["permissions"], "ui.permissions") if _, ok := perms["clipboardWrite"]; !ok { t.Error("ui.permissions.clipboardWrite missing") @@ -192,14 +196,79 @@ func TestApps_ResourcesRead_ReturnsSelfContainedWidget(t *testing.T) { t.Errorf("widget HTML contains %q; it must be self-contained and render via textContent", forbidden) } } - // The widget must speak the MCP Apps bridge. - for _, want := range []string{"ui/initialize", "ui/notifications/initialized", "ui/notifications/tool-result", "structuredContent"} { - if !strings.Contains(tc.Text, want) { - t.Errorf("widget HTML does not reference %q", want) + checkWidgetScript(t, tc.Text) +} + +// widgetScript returns the widget's inline JavaScript with HTML comments +// removed, so prose in the header comment cannot satisfy a code check. +func widgetScript(t *testing.T, html string) string { + t.Helper() + html = regexp.MustCompile(`(?s)`).ReplaceAllString(html, "") + m := regexp.MustCompile(`(?s)`).FindStringSubmatch(html) + if m == nil { + t.Fatal("widget has no inline