sqlguard-mcp

An MCP server that lets an agent query a SQLite database. Reads run at once. A write waits until a person approves that exact statement with a separate command, and the approval works once.

People want an agent that can answer “how many orders were refunded last month” from the real data, without handing it write access to find out. An agent decides for itself when to call a tool, so this server lets it ask for a write and gives it no way to grant one.

View on GitHub Security model

Plays the six stages below in order, about 75 seconds. Scroll or press Esc to stop.

Language
Go 1.25+, no cgo
Tests
139 passing (65 tests plus 74 subtests)
Protocol
MCP Go SDK, five tools, stdio or HTTP
Database
SQLite, through a pure Go driver
Approval
sqlguard approve <token>, run by a person
Deploy
Helm chart, distroless image, non-root
License
MIT
agent → request_write_approval {"sql":"UPDATE orders SET status = 'refunded' WHERE id = 3"} {"decision":"pending","expires_at":"2026-10-04T00:46:55+03:00","next_step":"Ask the operator to run that command in their own terminal, then call execute_approved_write with this token and the identical statement. You cannot approve this yourself.","operator_must_run":"sqlguard approve 2798b3790c80","statement":"UPDATE orders SET status = 'refunded' WHERE id = 3","token":"2798b3790c80"}

A real session against the bundled sample shop. The agent's calls were sent as MCP JSON-RPC over stdio, and each reply is the tool's structured result, verbatim. Times are local to the machine that ran it. The Approved tab leaves out two calls with edited statements, made between the approval and the run; stage 4 shows one of them.

1Classify before anything runs

Every statement is classified before it goes near the database. The classifier does not parse SQL. It scans the text once for bare words, and it refuses anything it cannot prove is a read.

  • Text inside quotes is skipped, so SELECT 'DELETE FROM orders' is a read and deleted_at is one identifier, not a verb.
  • A comment or a second statement is refused outright, because either can hide something.
  • Order decides. A write keyword anywhere makes it a write, then a schema keyword makes it DDL, and only then does a leading SELECT, WITH, VALUES or EXPLAIN make it a read.
  • Anything else is unknown and refused. Unknown is the type's zero value, so a missed case fails closed.

It is wrong in one direction on purpose: SET timezone is harmless and still refused. A real parser is engine-specific, the good ones need cgo, and it is wrong the other way whenever its grammar is not the one the engine runs.

Ten statements sent to the running server

    Word the decision turned onQuoted, skippedWhere the scan gave up
    Each statement went to sqlguard serve over MCP on the bundled sample shop. The decision, kind and reason, or the rows for the two reads, are its replies, verbatim, with the other keys left out. The highlighting follows the scan rules in internal/policy/classify.go.

    2Read on a connection that cannot write

    Reads run on their own SQLite handle, opened with query_only(1). If a write ever got past the classifier, SQLite itself would refuse it there, so both layers have to fail before a write lands on the read path.

    • Two handles on one file. Reads use the read-only one; only an approved write reaches the other.
    • The path is a filename, not a connection string, so a caller cannot slip in query_only(0).
    • Reads stop at 500 rows and 30 seconds by default, and a cut-off result says how to narrow the query.

    To see the second layer work, the classifier was broken on purpose in a scratch copy, so that it trusts the first word. SQLite's own form of a write that starts with WITH then went straight to the read handle.

    Three writes that start like reads

    Statement 1 of 3

    Real classifier

    1. Classifier
    2. Read handle
    3. orders

    First-word classifier Broken on purpose, scratch copy

    1. Classifier
    2. Read handle
    3. orders
    Both columns are real replies on the bundled sample shop: the binary built from main on the left, and on the right a build whose classifier returns “read” whenever the first word can start a read. The real classifier's replies are shortened to their first keys. The broken build exists only to show the second layer.

    3Turn a refused write into a request

    A refused write names the way forward: ask for approval. Asking returns a token, but holding the token grants nothing.

    • request_write_approval takes writes only. Reads need no token, and schema changes can never be approved.
    • The request is a file in a directory the server owns, not a row in the database the agent can query. It records the statement, a SHA-256 fingerprint of it and an expiry, five minutes by default.
    • A token is 12 hex characters from crypto/rand. Anything else is rejected before it can be used as a path.
    • Running the write with the token straight away is refused, with a next step that says to wait rather than retry in a loop.

    The request, on the wire and on disk

      approvals/2798b3790c80.json

      
                    
      The three calls and replies are verbatim from the session in the hero terminal. The file is shown as it was written at request time; approved_at and redeemed_at are added in stage 4, and their values come from the same file read after the session.

      4A person approves, out of band

      The agent has five tools and none of them approves anything. Approval is a separate command, sqlguard approve, that a person runs in their own terminal, in a process the agent has no way to start.

      • Two protocol tests fail if a sixth tool is advertised or a tool is named to approve. Adding an approve tool to a scratch copy failed both.
      • sqlguard pending lists what is waiting. sqlguard approve prints the statement, when it was asked for and when it expires, then records the approval in the token's file.
      • An approval is bound to one statement and used up by one run, so an edited statement is refused and the token still works for the original. Stage 5 looks at both rules closely.

      One token, two processes

      approvals/2798b3790c80.json

      1. Requested
      2. Approved
      3. Used
      token
      2798b3790c80
      statement
      UPDATE orders SET status = 'refunded' WHERE id = 3
      fingerprint
      f7f41e152fb18caa…
      expires_at
      2026-10-04T00:46:55.746462+03:00
      approved_at
      not set
      redeemed_at
      not set

      approvals/2798b3790c80.used created with O_EXCL, 0 bytes

        Every call, command and reply is verbatim from one session on the bundled sample shop, in this order. A script played the operator, so the approval landed 27 ms after the first refusal; with a person it takes as long as they take. One more edited call (WHERE id = 4) was refused the same way and is left out.

        ClearedAbove this line the agent reads and asks, and nothing is written. Below it, a statement a person approved runs once, exactly as approved.

        5Run it once, exactly as approved

        execute_approved_write classifies the statement again rather than trusting the token, then redeems it. The token must be approved, unexpired and issued for this statement. Only then does the statement run, on the writable handle.

        • The same statement means the same SHA-256 fingerprint once runs of whitespace are collapsed. Reformatting passes. A changed value, a missing WHERE, a dropped semicolon or a change of case does not.
        • A mismatch does not use up the approval; the approved statement still runs afterwards.
        • Once is enforced by the file system. Redeeming creates <token>.used with O_EXCL, which exactly one process can do. The server and the approve command are separate programs, so a lock inside one would not hold the other.
        • An approved write that fails, on a missing table for example, still uses up its token.

        The landing page's statement, approved once Illustrative input

        Approved, token 7525fca039a2 Ran once

        UPDATE accounts
        SET plan = 'pro'
        WHERE id = 42;
        Whitespace collapsed
        UPDATE accounts SET plan = 'pro' WHERE id = 42;
        SHA-256, first 16
        529cb8f057e84cdf
        Account 42
        [[42,"free"]]
          42Changed;Removed, not sent=Same fingerprint as the approved statement
          The statement is the one in the landing page's hero. The sample database has no accounts table, so one was added ((41,'free'), (42,'free'), (43,'team')) to let it run; everything else is the real binary. Calls are shown in the order they were made. Each reply is real, shortened to its decision and the first clause of its reason. Fingerprints are SHA-256 of the whitespace-collapsed text, as Fingerprint computes them, and match the token's file.

          6Move the approval behind the cluster

          Deployed with the Helm chart, approving needs kubectl exec into the pod, so it takes cluster credentials. An agent can reach the MCP endpoint from anywhere in the cluster and still cannot start the process that approves. The approval code did not change.

          • --http serves MCP at /mcp next to two probes. /healthz never touches the database; /readyz pings it. A volume problem takes the pod out of the Service instead of restarting a healthy process into a crash loop.
          • One replica and a Recreate strategy, because SQLite is a single-writer file on a ReadWriteOnce volume. The replica count is deliberately not a chart value.
          • A distroless image with no shell, running as user 65532. The Service is ClusterIP, and an init container seeds the database only if it is missing.

          The endpoint has no authentication or TLS yet, so anything that reaches the Service can read. The approval protects writes, not reads.

          What CI checks on a real cluster

          Approving on your machine needs write access to ./approvals

          sqlguard approve <token>

          Approving in the cluster needs cluster credentials

          kubectl exec deploy/sqlguard -c sqlguard -- \
            /usr/local/bin/sqlguard approve <token> --approvals /data/approvals

          The kubernetes job, run 3390948766993 s in all

            Step names, results and durations come from GitHub's API for the run on commit b52b684, rounded to whole seconds by GitHub. CI's own logs need a login to read, so the terminal lines were captured locally against sqlguard serve --http, making the same calls as deploy/verify.sh.

            The audit log of that session

            Every decision the server makes is appended to audit.jsonl, allowed or refused. This is the whole log for the session in the hero terminal and stage 4. The person's approval is not a server decision: it lives in the token's file as approved_at, which is why the log jumps from the early refusal to the edited attempts.

              The log file, verbatim, one row per line, with the date and time zone dropped from at and the reason shortened where noted.

              Measured, not assumed

              Each guard was checked the same way: run it, then delete it in a scratch copy and see what fails. Nothing here changed the repository.

              Once, across processes

              The test races 16 separate stores over one directory, which is what two programs look like, to redeem a single approval.

              With O_EXCL1 of 16
              Without, fewest3 of 16
              Without, most12 of 16

              With O_EXCL, exactly one winner in 100 of 100 runs. With it replaced by a plain “already redeemed?” check, the test failed in all 60 runs. Racing goroutines through one store instead passed 30 of 30 runs even without the claim, which is why the test uses separate stores.

              Delete a guard, a test fails

              Six guards removed one at a time in a scratch copy, then the suite run.

              • No query_only(1)TestReadConnectionCannotWrite
              • No fingerprint checkTestApprovalCannotBeWidened
              • First word decidesTestClassify
              • An approve toolTestNoToolCanGrantApproval
              • Line comments allowedTestClassifyRefuses
              • One flag parseTestFlagsParseOnEitherSideOfThePositional

              Each removal failed at least the test named, and most failed more than one.

              Reads are bounded

              Both limits measured on the sample shop with tighter settings than the defaults.

              3 of 11 rows2,002.8 ms

              At --max-rows 3, an 11-row table came back as 3 rows with "truncated":true. At --timeout 2s, a recursive query with no end stopped with query: context deadline exceeded after a 2,002.8 ms round trip.

              Ordinary tool calls on the sample database took 0.1 to 3.3 ms round trip over stdio, on one Mac.

              139 tests, no database server and no credentials

              Counted with go test -race -count=1 -json ./...: 65 tests plus 74 subtests, all passing. Each package is tested at its own layer, and the outermost tests start the real binary.

                Weighted toward the boundary

                • Classifier: 43 statements, including the ones a naive check gets wrong: verbs inside strings, deleted_at, a quoted "delete", writes behind WITH or EXPLAIN, and comments or second statements hiding a DROP.
                • Engine: writes fired at the read handle fail with SQLite's own refusal and the row count is unchanged; row cap edges; cancelled and expired contexts.
                • Approval: single use, binding to the statement, whitespace-only normalising, expiry on an injected clock, the 16-store race, and tokens that try to be paths.
                • Protocol: a real MCP client over in-memory transports; exactly five tools, none that approves; refusals that carry their next step.
                • The binary: built and started as a subprocess, then driven over stdio through refuse, request, early run, approve, run and reuse, ending with the audit log.
                • Deployment: liveness survives a closed database, readiness returns 503, MCP over HTTP, clean shutdown, and seeding that leaves an existing file alone.

                CI runs vet, gofmt, the race-enabled suite and a build on Go 1.25.x and the current stable release, plus a job that deploys the chart to a kind cluster and drives it.

                Deliberately missing

                A sandbox
                An approved write runs exactly as written.
                Cost limits on reads
                Reads are capped by rows and time, not by cost, so a legal SELECT can still be expensive.
                Row and column access control
                Anything readable is readable.
                Other databases
                SQLite only. The policy layer does not depend on the engine; the driver does. For PostgreSQL the plan is a pg_query front end, with this classifier as the fallback.
                Authentication and TLS
                Not in the deployment. The endpoint belongs behind a NetworkPolicy and an authenticating proxy, with TLS at an ingress or a mesh.

                Run it

                git clone https://github.com/ahmed-hashim-pro/sqlguard-mcp.git
                cd sqlguard-mcp
                
                make build          # -> ./bin/sqlguard
                ./bin/sqlguard seed ./shop.db
                make check          # vet, gofmt and the full suite with -race
                
                # Point an MCP client at, with absolute paths:
                #   bin/sqlguard serve --db shop.db
                
                # When the agent asks for a write, in your own terminal:
                ./bin/sqlguard pending
                ./bin/sqlguard approve <token>

                Go 1.25 or later. No database server, no cgo and no API key. seed writes a small shop from SQL compiled into the binary. The server and approve must use the same approvals directory, ./approvals by default, or pass --approvals. make deploy installs the chart into a local kind cluster.