Imported from pascalallen/pgmcp (
AGENTS.md). Install upstream withnpx skills add pascalallen/pgmcp. Copyright stays with the author.
AGENTS.md
doc.go/README are authoritative; this file records what is not derivable from code.
Commands
go test -race -cover ./...
go vet ./...
gofmt -l . # any filename printed = unformatted; run before finishing
go run honnef.co/go/tools/cmd/staticcheck@latest ./...
go build ./cmd/pgmcp
bin/up # docker compose up --build -d, follow logs
bin/down # tear down, including volumes
bin/exec <command> # run a command in a throwaway go container
Integration tests are skipped unless PGMCP_TEST_DSN points at a Postgres with
pg_stat_statements preloaded; the scratch-container recipe is in the README's
Testing section. The conformance suite runs against a live HTTP server:
npx -y @modelcontextprotocol/conformance server --url http://127.0.0.1:8080/mcp \
--expected-failures .github/conformance-expected-failures.yaml
The release pipeline — six binaries, the darwin universal binary, the Claude
Desktop bundle, checksums — can be run end to end without publishing (needs
Node for the pinned @anthropic-ai/mcpb CLI):
go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --clean --skip=publish,docker
Invariants — keep the tests that pin them
Each line is a property the codebase must not lose. If a change makes one of these tests fail, the change is wrong until proven otherwise.
Read-only enforcement
- Every statement the adapter runs is inside a
READ ONLYtransaction, and a write inside it fails with SQLSTATE 25006 — pinned byTestStoreReadOnly(infrastructure/postgres). statement_timeoutandlock_timeoutare set on every such transaction, and the transaction is always rolled back — pinned byTestStoreReadOnly(infrastructure/postgres).- Only a single top-level
SELECT/EXPLAIN/SHOWis allowed; a nested write statement (including one hidden in a CTE), aFOR UPDATE/FOR SHARElocking clause, aSELECT INTO, or a denied function is rejected — pinned byTestValidate(domain/sqlguard). - The libpg_query adapter reports the node types, function names and schemas the guard's rules are written against — pinned by
TestParserParseandTestParserSatisfiesTheSqlguardParserPort(infrastructure/postgres).
Nothing leaks back to the model or the log
- A parse failure never echoes the statement text: the detail is replaced with a fixed phrase, whether the rejection came from the guard or from the port revalidating — pinned by
TestExplainandTestQuery(application/mcp/tool). - The logging middleware records method, duration, ok and user id, and never the arguments or the error text — pinned by
TestLogging(application/mcp/middleware). - The recover middleware logs the panic's type and stack but never the panic value — pinned by
TestRecover(application/mcp/middleware). - An unparsable DSN is reported without the connection string in the message — pinned by
TestOpenRejectsAnUnparsableDSNWithoutLeakingIt(infrastructure/postgres). /healthzreportsdegradedwithout the failure detail — pinned byTestHandlerHealth(infrastructure/http).
Bounds
- The rate limiter refills per minute, not per second, and isolates principals while bucketing every unauthenticated caller as
anonymous— pinned byTestRateLimit(application/mcp/middleware). - Idle principals are evicted, so a churn of callers cannot grow the bucket map without bound — pinned by
TestRateLimit(application/mcp/middleware). - Rate limiting is installed for HTTP and not for stdio — pinned by
TestNew(infrastructure/mcp). - Oversized structured content is replaced with a truncation marker on a still-successful call, over a live session as well as in isolation — pinned by
TestOutputCap(application/mcp/middleware). - Every tool call and resource read gets a bounded context that is cancelled when the handler returns — pinned by
TestTimeout(application/mcp/middleware). - The HTTP transport refuses a request body over 1 MiB and still answers — pinned by
TestHandlerBodyLimit(infrastructure/http).
Auth fails secure
- A static key is compared in constant time against every configured hash with no early exit, and a prefix of a configured key is rejected — pinned by
TestStaticVerifier(infrastructure/http). - A JWT with the wrong audience, the wrong issuer, a missing or past
exp, oralg=noneis rejected — pinned byTestNewJWTVerifier(infrastructure/http). - An unreachable JWKS still builds a handler, and that handler rejects every token until a key set arrives — the server starts closed — pinned by
TestNewHandler(infrastructure/http). - HTTP on a non-loopback listen address with
auth-mode=noneis refused, by the config loader and again by the handler constructor — pinned byTestLoad(infrastructure/config) andTestNewHandler(infrastructure/http). - A request to
/mcpwithout a token, or with the wrong one, is refused and points at the resource metadata — pinned byTestHandlerAuth(infrastructure/http).
Surface
- All nine tools register by default, eight with
--disable-query, and a disabledquerytool cannot be called at all — pinned byTestRegisterandTestNames(application/mcp/tool). - Every registered tool is annotated read-only, non-destructive, idempotent and closed-world, with a title and an output schema — pinned by
TestRegister(application/mcp/tool). - The schema allowlist binds
queryandexplain, is matched case-insensitively, refuses an unqualified table reference, and names only the schema back to the caller — pinned byTestQuerySchemaAllowlistandTestExplainSchemaAllowlist(application/mcp/tool). - Slices in a tool output are empty, never null: plan children, hot nodes and warnings, columns and rows, and the parser's own slices — pinned by
TestExplain,TestQuery(application/mcp/tool) andTestParserNeverReturnsNilSlices(infrastructure/postgres). - The domain result types marshal the JSON keys the tool output schemas are derived from — pinned by
TestExplainResultMarshalsExpectedTopLevelKeysandTestIndexHealthResultMarshalsExpectedTopLevelKeys(domain/diagnostics). - A configuration error exits 2 and a runtime failure exits 1 — pinned by
TestCommandStartup(cmd/pgmcp). - The Claude Desktop bundle (
mcpb/manifest.json) lists exactly the tools the catalogue registers with the same descriptions, launchesserver/pgmcp(server/pgmcp.exeon win32) over stdio, takes the DSN as a required sensitiveuser_configfield, and sets no environment variable that is not aPGMCP_*key fed fromuser_config— pinned byTestBundleManifest(application/mcp/tool).
Design decisions
- Official
modelcontextprotocol/go-sdk(v1.7.0) rather than mark3labs/mcp-go — tier-1 SDK, tracks spec releases same-day, ships RFC 9728 bearer middleware and conformance tooling. - SQL parsing via
github.com/wasilibs/go-pgquery(pure-Go wazero build of libpg_query) soCGO_ENABLED=0holds everywhere; costs ~15 MB of binary. - Read-only is enforced three independent ways: dedicated
pg_monitorrole,default_transaction_read_only+ per-callREAD ONLYtransaction, and a parser-level statement/function guard (a read-only tx alone does not stoppg_terminate_backend/pg_read_file/pg_sleep). - Streamable HTTP runs stateless (spec 2026-07-28); no sessions, no EventStore.
Conventions
- Module
github.com/pascalallen/pgmcp;go 1.25directive (floor required by go-sdk) — never bump to "newest". CGO_ENABLED=0for every build; no cgo dependency may be introduced.- Domain packages (
internal/pgmcp/domain/...) import stdlib only (plus sibling domain packages). Application imports domain +go-sdk/mcp. Infrastructure imports anything. - Every SQL the adapter runs goes through
postgres.Store.readOnly(ctx, timeout, fn)→BEGIN READ ONLY+SET LOCAL statement_timeout+SET LOCAL lock_timeout='2s'+ROLLBACK. No exceptions. - Every tool:
Annotations{ReadOnlyHint:true, DestructiveHint:&false, IdempotentHint:true, OpenWorldHint:&false, Title}via the sharedtool.readOnly(title)helper; typedIn/Out;Outembedstool.Meta; slices inOutare never nil. - Never log tool arguments, SQL text, or result rows. Log
{tool, duration_ms, ok, user_id}only. - Tests: testify (
require/assert), descriptive names,t.Runsubtests that read as sentences. Test-first.go test -race -cover ./...,gofmt -l .empty,go vet ./...,staticcheck ./...green before every commit. - Integration tests gate on
PGMCP_TEST_DSN;t.Skipwhen unset. - Workflow: GitHub Issue →
feature/<issue#>-<slug>→ PR referencing the issue. Agents never merge. NoCo-Authored-Bytrailers. Commit subjects imperative, reference issue(#n). wire_gen.gois generated (go generate ./internal/pgmcp/infrastructure/container/...), never hand-edited.- Distribution is one
.mcpbbundle for Claude Desktop (darwin universal + windows/amd64; the MCPB manifest selects by OS, not architecture, and Desktop has no Linux build), packed byscripts/mcpb-pack.shfrom theuniversal_binariespost hook — the last goreleaser stage before archives/checksums/publish. Unsigned on purpose. The@anthropic-ai/mcpbCLI is pinned in the script, not watched by Dependabot.server.json's committed MCPBfileSha256is an all-zero placeholder thatrelease.ymlreplaces from the published asset; CI refuses to publish the placeholder. - Licence MIT
Copyright (c) 2026 Pascal Allen. README follows the pubsub/pgqueue shape (6 badges, Installation, Usage, Testing, Contributing, License).