Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,30 @@ acceptance, CLI refusals), so read **Behavior changes** before upgrading.
to `variable`.

### 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
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`.
- **SQL syntax highlighting (`pkg/highlight`)**: `highlight.Spans(sql, opts...)`
classifies SQL into sorted, non-overlapping byte-offset spans tagged with one
of 13 stable categories (keyword, function, type, identifier,
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
54 changes: 41 additions & 13 deletions cmd/gosqlx-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand All @@ -32,6 +35,7 @@ package main

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
Expand Down Expand Up @@ -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() {
Expand All @@ -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
}
227 changes: 227 additions & 0 deletions cmd/gosqlx-mcp/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// 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 (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
gosqlxmcp "github.com/ajitpratap0/GoSQLX/pkg/mcp"
)

func TestNewMux_Health(t *testing.T) {
cfg := gosqlxmcp.DefaultConfig()
srv := newTestServer(t, 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, newTestServer(t, 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)
}
})
}
}

// 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)
}
}
})
}
Loading
Loading