Repository navigation
fix(mcp): rate-limit follow-ups from #831 review - #841
Merged
Merged
Conversation
- Reserve a GET stream slot before charging the rate limit, so a GET refused by the 4-streams-per-client cap costs no budget; a GET denied by the limiter releases its slot. - Log one startup warning when no trusted proxy is configured: behind a proxy every client shares one bucket and stream cap. - Count body read-deadline errors and log the first with a fixed message (error type only; no session IDs, bodies or addresses). - Add opt-in GOSQLX_MCP_TRUSTED_PROXY_SECRET_HEADER and GOSQLX_MCP_TRUSTED_PROXY_SECRET: forwarded client IPs are trusted only when the header matches (SHA-256 + constant-time compare), otherwise RemoteAddr is used. The header is stripped before the MCP handler. Default behaviour is unchanged. - Non-empty unparseable POST bodies and empty batches cost 1 unit; a batched initialize gets HTTP 200 on denial like a single initialize. - Document that Server.Start/Handler skip RateLimitMiddleware, the Cloudflare Transform Rule setup, and the shared limit for ChatGPT. Closes #838 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9
|
Claude finished @ajitpratap0's task in 39s —— View job Review of PR #841: rate-limit follow-upsI read the diff for Verdict: The change is sound and I found no blocking issues. The secret handling is careful and the stream-cap reordering is correct. The points below are minor. What looks good
Suggestions (non-blocking)
Security notes
Open item"Confirm Render forwards |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
12 of 14 tasks
ajitpratap0
added a commit
that referenced
this pull request
Oct 22, 2026
* chore(parser): remove stray parser.go-e backup with conflict markers [vercel skip] The sed backup file was committed by accident in #791. It is not compiled but ships in the module zip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * chore(release): bump version to 1.16.0 [vercel skip] gosqlx.Version and the CLI version were still 1.14.0 (v1.15.0 shipped with them), so gosqlx --version, the MCP server info and /health reported 1.14.0. Also bumps the VS Code extension, Claude Code plugin and marketplace, glama, performance baseline label, llms.txt and the site's release badge. pygosqlx __version__ now matches pyproject (0.2.0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs(changelog): finalize 1.16.0 release notes [vercel skip] Turns [Unreleased] into [1.16.0] - 2026-10-21, adds the missing entries for #734, #735, #780, #786, #789, #790, #791, #826, #835, #836, #837, #840, #841, #843 and #844, updates the MCP and composite-field entries amended by #841 and #843, adds PR refs, and adds the compare links. The VS Code extension gets a 1.16.0 entry, and split-changelog.js generates the /changelog page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs: v1.15 to v1.16 migration guide and release doc fixes [vercel skip] MIGRATION gains a v1.15.0 -> v1.16.0 section with a behaviour-change checklist. ERROR_CODES documents E4001 dialect gating and the 11 dialect names. CLI_GUIDE lists --dialect on format, parse, analyze and lint and all 11 dialects. SQL_COMPATIBILITY marks Oracle CONNECT BY/PRIOR and SQL Server PIVOT as parsed. api/mcp.md matches the current Config and middleware API, api/models.md drops the nonexistent Location.Offset and documents byte columns, and HIGHLIGHTING notes ANSI control-character neutralization. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * chore(website): regenerate sample AST and changelog pages The website build rewrote both files on main: sample-ast.json gains the WithOrdinality and ColumnAliases fields from #843, and v1-9-1.md drops the emoji the changelog splitter strips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs(release): correct allocation claim, plugin history, E4001 and LoadConfig errors Address review notes on #845: the lead paragraph understated the perf audit's allocation cuts, the MIGRATION table listed a v1.15.0 plugin that never shipped, E4001 also covers LIMIT under SQL Server, and LoadConfig rejects more trusted-proxy misconfigurations than the API doc listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs(release): make glama.json match the MCP server List all 11 validate_sql dialects, describe lint_sql as the 10 rules it runs (L001-L010; the CLI and SDK have 30), sync tool descriptions with pkg/mcp/server.go, and drop the unsourced 1.38M ops/sec claim in favour of a link to the benchmarks page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs(release): add #846-#851 to the v1.16.0 CHANGELOG and migration guide Record the pygosqlx 0.3.0 API and wheels, the gosqlx_version double free, LSP serverInfo/CLI version reporting, the Python CI and release workflows and the #848 docs. MIGRATION companion table lists pygosqlx 0.3.0, libgosqlx exports and the LSP serverInfo version, with matching behaviour-change notes. Regenerate the v1.16.0 blog page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs(release): address v1.16.0 release review; add #852 - Release date 2026-10-22 in CHANGELOG, MIGRATION, the extension changelog and the generated blog post (re-stamp at tag time if it slips). - vscode-extension CHANGELOG: Fixed entry for the #846 version reporting. - CHANGELOG: keep the Behavior changes list tight; name TranspileError and the LintResult/SecurityScanResult types in the pygosqlx 0.3.0 entry. - MIGRATION: PyPI wheels row is conditional on the pygosqlx-v0.3.0 tag. - Add #852 (sdist builds fail without libgosqlx; release tag lookup skips v1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * build(release): replace deprecated goreleaser keys (formats, version_template) goreleaser check (v2.12.7) flagged archives.format, format_overrides.format and snapshot.name_template as deprecated; release.yml runs goreleaser 'latest'. Snapshot release dry-run builds all six archives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * docs(release): add #853 to the v1.16.0 CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 * feat(python): version pygosqlx in lockstep with GoSQLX (1.16.0) pygosqlx wheels bundle libgosqlx built from the same commit, so its version now matches the GoSQLX release. The first PyPI release is 1.16.0 (it was 0.2.0, never published); no older versions are backfilled. - pygosqlx-release.yml also publishes on GoSQLX 'vX.Y.Z' tags (the floating 'v1' tag does not match); 'pygosqlx-v*' stays for Python-only .postN releases. Both must match pyproject.toml and be on main. - pyproject.toml / __version__ 1.16.0; the version test now asserts __version__ equals the bundled library's version(). - CHANGELOG, MIGRATION and python/README describe the lockstep scheme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9 --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-ups from the reviews of #831 (gosqlx-mcp HTTP hardening), in
pkg/mcp,cmd/gosqlx-mcpand the MCP docs.GET /mcpnow reserves a stream slot before the rate limiter charges it. A GET refused by the 4-streams-per-client cap costs no sustained budget or burst tokens. A GET that the rate limiter then denies gives its slot back through the deferred release.GOSQLX_MCP_TRUSTED_PROXY_HEADERnorGOSQLX_MCP_TRUSTED_PROXY_HOPSis set,gosqlx-mcplogs one warning line at startup: behind a proxy, every client shares one rate-limit bucket and one stream cap. The same is documented indocs/MCP_GUIDE.md.deadlineErrs). Only the first is logged, with a fixed message. If the writer lacks deadline support (http.ErrNotSupported) the message says so; any other error is reported by its Go type only (%T), because its text can contain connection addresses. Session IDs, request bodies and client addresses are never logged.CF-Connecting-IPresidual risk). NewGOSQLX_MCP_TRUSTED_PROXY_SECRET_HEADERandGOSQLX_MCP_TRUSTED_PROXY_SECRETsettings, also available asConfig.TrustedProxySecretHeaderandConfig.TrustedProxySecret. When both are set, the trusted header and hops apply only to requests whose secret header, last value, matches the secret. The comparison is SHA-256 followed bysubtle.ConstantTimeCompare, so it runs in constant time and does not leak the secret's length. Requests with a missing or wrong secret fall back toRemoteAddr; they are not rejected. The secret header is stripped before the request reaches the MCP handler. With neither setting, behaviour is unchanged.[], now costminUnparsedWeight(1) instead of 0. Body-less requests such asDELETEstay free.initializegets HTTP 200 on denial, the same as a singleinitialize.Server.Start()andServer.Handler()doc comments now state that they skipRateLimitMiddleware, and the MCP_GUIDE API table says the same.docs/CHATGPT_APP.mdexplains that ChatGPT traffic arrives from OpenAI egress IPs, so ChatGPT users share one per-IP limit.docs/MCP_GUIDE.mddocuments the Cloudflare Transform Rule setup for the secret header.Root cause / design
Stream cap ordering. fix(mcp): harden gosqlx-mcp HTTP server (rate limiting, timeouts, auth, tool errors) #831 ran
rl.allowbeforestreams.acquire, so the charge happened before the cap check. Swapping the order means a stream-cap rejection skips the limiter entirely, so those 429s do not carryX-RateLimit-*headers. The docs now say this. These rejections are cheap (no body read), and a client can only reach them while it already holds 4 open streams.Startup warning placement. The warning lives in
cmd/gosqlx-mcp(sharedClientIPWarning), not inpkg/mcp. Library users who callNewRateLimitMiddleware(nil, …)keep silent behaviour. It also warns on loopback binds, because a reverse proxy on the same host is a common setup.Secret validation.
LoadConfigvalidates the secret settings and fails fast in these cases:X-Forwarded-For,Authorization(which would clash with bearer auth, since the header is stripped) or the client-IP header itself;Error messages never include the secret. If a programmatic
Configsets only one of the two fields, the resolver fails closed and never trusts forwarded values.Why the secret is a fallback, not a rejection. A mismatched secret falls back to
RemoteAddrrather than returning 403. Rotating the secret, or a Transform Rule that does not cover every hostname, therefore degrades to the shared bucket instead of an outage. Traffic that bypasses the proxy (for example via*.onrender.com) shares one bucket instead of choosing its own.Infra untouched.
render.yaml, secrets and DNS are not changed. Turning the feature on in production means deploying the Transform Rule first, then setting the two variables in the Render dashboard (steps are indocs/MCP_GUIDE.md).Test Plan
Run in the worktree on
fix/mcp-ratelimit-followups:gofmt -l pkg/mcp cmd/gosqlx-mcpgo vet ./pkg/mcp/... ./cmd/gosqlx-mcp/...golangci-lint run ./pkg/mcp/... ./cmd/gosqlx-mcp/...0 issues.go test -race -count=1 -timeout 180s ./pkg/mcp/... ./cmd/gosqlx-mcp/... ./cmd/gosqlx/cmd/ok(the last package covers the docs dialect-sync check)go fmt,go vet,go test -short ./...)These packages have no golden tests. No benchmark was run: the changes are in the HTTP middleware, not in the tokenizer, parser or AST hot paths. The only new per-request work is one SHA-256, and only when a secret is configured.
New behaviour tests:
TestRateLimitMiddleware_StreamCapRejectionIsFree: with 4 streams open, 20 refused GETs (more than the burst limit) leaveweightedCountat 4. A following POST returns 200 withX-RateLimit-Remaining= 115.TestRateLimitMiddleware_RateLimitedGETReleasesStreamSlot: GETs denied by the rate limiter leave 0 stream slots held.TestRateLimitMiddleware_ReadDeadlineErrors:TestRateLimitMiddleware_UnparseableBodyCostsBudget: malformed bodies,[]and invalid batches cost 1 each;DELETEstays free.TestClientKey_ProxySecret(13 cases): matching, missing, wrong, prefix, suffix, empty, forged-first and forged-last secrets; gating of hops; fail-closed partial config; behaviour unchanged when no secret is configured.TestRateLimitMiddleware_ProxySecret: rotatingCF-Connecting-IPwithout the secret still gets 429. With the secret, each client gets its own bucket. The secret header never reaches the MCP handler.TestRateLimitMiddleware_Denials: a batched initialize gets 200; a batch without initialize gets 429.TestReadRequestInfo: new cases for minimum weight and for theinitializeflag.TestLoadConfig_TrustedProxySecret(11 cases), including a check that errors never contain the secret.TestSharedClientIPWarningandTestClientIPSource(secret-gated case, secret never printed).Unchecked: "Confirm Render forwards
CF-Connecting-IP" cannot be verified from code. It needs a request through the livemcp.gosqlx.dev→ Render path with the received headers inspected, so it is left open for the maintainer.Breaking Changes
None for valid configurations.
X-RateLimit-*headers;initializenow gets HTTP 200 instead of 429.LoadConfignow returns an error for invalid values of the two new variables. They were previously unused.Configgains two string fields. This is additive, though unkeyed struct literals ofConfig(unlikely) would need updating.Release note
Closes #838
🤖 Generated with Claude Code
https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9