Skip to content

fix(mcp): pin stateless session IDs and stop logging session IDs in transport errors - #835

Merged
ajitpratap0 merged 10 commits into
mainfrom
fix/mcp-session-retention
Oct 21, 2026
Merged

ajitpratap0 merged 10 commits into
mainfrom
fix/mcp-session-retention

Conversation

@ajitpratap0

Copy link
Copy Markdown
Owner

Summary

Stacked on #831 (which is stacked on #823); retarget after those merge.

  • Pin the session ID manager of the streamable HTTP transport to mcp-go's StatelessGeneratingSessionIdManager (newSessionIdManager in pkg/mcp/server.go) instead of relying on the library default.
  • Redact session IDs from transport error logs. transportLogger.Errorf now prints arguments only for an allowlist of mcp-go formats known to carry no session ID (I/O write failures, ErrSessionExists). Any other format, including formats added in future mcp-go releases, is logged with [redacted] arguments. Generated mcp-session-<uuid> IDs are also scrubbed from every error line as a second line of defence. Info messages are still dropped, as they were in fix(mcp): harden gosqlx-mcp HTTP server (rate limiting, timeouts, auth, tool errors) #831.

Root cause

Terminated-session map (latent; not active on this branch). In mcp-go v0.45.0, InsecureStatefulSessionIdManager stores every terminated ID in terminated sync.Map (server/streamable_http.go:1489, Store at :1523), and nothing ever prunes it. I checked the code: this server never used that manager. It ran on the library default, StatelessGeneratingSessionIdManager (streamable_http.go:218), which keeps no record of IDs. So the unbounded growth was not happening in production. The risk was that a future change to mcp-go's default would turn it on with no change on our side. Pinning the manager removes that risk. Our tools need no per-session state, so stateless mode loses nothing. DELETE and the idle sweeper from #831 still free the transport's per-session state.

Session IDs in logs (an active bug, which #831 did not fix). #831 drops Infof, but some Errorf paths include the client-supplied Mcp-Session-Id:

  • streamable_http.go:363 logs Failed to handle sampling response: %v with errors from deliverSamplingResponse (no active session found for session <id>, :826) and Validate (invalid session id: <id>, :1471).
  • :811 logs Failed to deliver sampling response: %v.

Any client can reach these paths without auth by POSTing a response-shaped JSON-RPC message (no method, with id and result). The ID, or any string the client puts in the header, then lands in the server log. The privacy page says MCP session identifiers are "never logged".

Test Plan

New file pkg/mcp/session_retention_test.go:

  • TestNewSessionIdManager_KeepsNoTerminatedSet: the manager is the stateless generating one, and after Terminate it does not report the ID as terminated, so nothing is tracked.
  • TestServer_CreateTerminateCyclesRetainNothing: 500 initialize+DELETE cycles over HTTP, then a ping with the first terminated ID returns 200. With InsecureStatefulSessionIdManager it returns 404 Session terminated, which means IDs are being recorded.
  • TestTransportLogger_RedactsSessionIDsInErrors (table test): unknown formats are redacted, safe formats keep their error detail, and generated IDs are scrubbed even from safe formats.
  • TestServer_SessionIDsNotLoggedEndToEnd: sends sampling-response POSTs through the real transport, with both a real ID and a forged one. The error is logged, but neither ID (nor the UUID part of the real one) appears in the log.

Mutation check: I swapped in InsecureStatefulSessionIdManager and the old Errorf. Both end-to-end tests failed (404 on the terminated ID; the real and forged IDs leaked into the log). Both pass with the fix.

Commands run (all passing):

go test -race -timeout 120s ./pkg/mcp/... ./cmd/gosqlx-mcp/   # ok
go test -race -count=10 -run 'CreateTerminate|NotLogged|Redacts' ./pkg/mcp/   # ok
go vet ./pkg/mcp/ ./cmd/gosqlx-mcp/                           # clean
gofmt -l pkg/mcp                                              # clean
golangci-lint run ./pkg/mcp/...                               # 0 issues
pre-commit hook (fmt, vet, go test -short ./...)              # passed

Breaking Changes

None. The transport already used stateless generating IDs, so client-visible behaviour is unchanged. Arguments of non-allowlisted transport errors are now redacted in server logs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9

Ajit Pratap Singh and others added 8 commits October 20, 2026 00:42
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w
…t_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w
…rrors

- rate limit keys on RemoteAddr; X-Forwarded-For no longer trusted by
  default; opt-in GOSQLX_MCP_TRUSTED_PROXY_HEADER / _HOPS with IP
  validation (render.yaml: CF-Connecting-IP)
- bounded limiter memory with per-shard LRU eviction
- GET/SSE streams excluded from load metric, capped at 4 per IP
- POST body read deadline (408), idle timeout, 32KB header cap, read
  deadline on non-/mcp routes so stalled bodies cannot hold connections
- oversized bodies get 413; denials are HTTP 429 with Retry-After and the
  request id (initialize keeps 200 for mcp-go clients); batches charged
  per tool call
- bearer auth: constant-time digest compare, case-insensitive scheme
- tool input problems return isError results naming the argument;
  indent_size limited to 0-16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2wnDjnjXs2u8baaf2PC6w
…ransport errors

Pin the streamable HTTP transport to mcp-go's
StatelessGeneratingSessionIdManager instead of relying on the library
default. mcp-go's InsecureStatefulSessionIdManager stores every terminated
session ID in a sync.Map that is never pruned (v0.45.0
server/streamable_http.go:1489,1523); pinning the manager keeps an upstream
default change from turning the public no-auth server into an unbounded
store of session IDs.

Redact transport error arguments unless the format is known to be safe.
mcp-go logs errors that embed the client-supplied Mcp-Session-Id header
(streamable_http.go:363 via :826 and :1471), which any client can trigger
by POSTing a response-shaped JSON-RPC message, contradicting the privacy
policy's promise that session IDs are never logged. Generated IDs are also
scrubbed from every error line as a second line of defence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9
@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gosqlx-website Ready Ready Preview Oct 21, 2026 11:33am UTC

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 24s —— View job


Review of PR #835

I reviewed the server.go diff. I did not read session_retention_test.go or run the tests, so the PR's test results are unverified by me.

Overall: The change is small, focused and does what it says. Pinning the session ID manager is a cheap guard against an upstream default change. Redacting by allowlist means formats added in future mcp-go releases fail closed.

Strengths

  • The allowlist fails closed. Unknown formats get [redacted] arguments.
  • The mcp-session-<uuid> scrub is a sensible second layer. It runs on the already-formatted message, so it also covers safe formats.
  • Printf("...%s", msg) is used, so % characters in a message can't be read as format verbs.
  • The PR description is honest that the terminated-session growth was latent, not active.

Suggestions (non-blocking)

  1. Allowlist drift on mcp-go upgrades. The allowlist matches v0.45.0 format strings exactly. A reworded upstream format silently becomes redacted. That is safe, but it means useful error detail is lost without any signal. Consider a test that pins the mcp-go version, or a comment in go.mod or the test to re-audit the allowlist on upgrade.
  2. Failed to register POST session: %v. The comment says this is ErrSessionExists only. If a future mcp-go version wraps the ID into that error, the regex scrub still catches generated IDs. It would not catch a forged non-UUID ID. That is acceptable, but worth knowing.
  3. Regex scope. mcp-session-[0-9A-Fa-f-]+ only covers generated IDs. Forged IDs are covered only through the allowlist, which is the right design. The end-to-end test with a forged ID covers this.
  4. Log injection. For allowlisted formats, the error text comes from I/O errors and is not client-controlled. Nothing to fix.
  5. Stateless manager semantics. After DELETE, the ID is still accepted. The code comments and the privacy-policy comment document this, and the PR's ping test asserts it. Nothing to change.

Performance: Negligible. The regex and Sprintf run only on the error path.

Security: This closes a real, unauthenticated log-leak path. No new concerns found.

Verdict: Looks good to merge once the stacked base PRs (#831 and #823) land.

@ajitpratap0
ajitpratap0 changed the base branch from fix/mcp-server-hardening to main October 21, 2026 11:15
#831 was squash-merged; took main's versions of its files and re-applied
only this PR's pkg/mcp/server.go change (import block union).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 14s —— View job


Review of PR #835

I read the server.go diff and session_retention_test.go. I did not run the tests, so I'm relying on the PR's reported results.

Verdict: Good to merge once the stacked base PRs (#831 and #823) land. I found no blocking issues.

What works

  • newSessionIdManager pins the stateless manager, so a change to the mcp-go default can't switch the server to the unbounded terminated-ID map. The comment explains why, and it accurately says this risk was latent.
  • The allowlist fails closed. Formats that aren't listed, including any added in future mcp-go releases, get [redacted] arguments.
  • The mcp-session-<uuid> regex scrub is a useful second layer. It runs on the formatted message, so it also covers allowlisted formats.
  • Printf("%s", msg) means a % in a message can't be read as a format verb.
  • The tests cover both the behaviour and the leak. The end-to-end test checks the real ID, its UUID part and a forged ID. TestServer_CreateTerminateCyclesRetainNothing fails if the stateful manager is swapped in.

Suggestions (non-blocking)

  1. Allowlist drift on mcp-go upgrades. The allowlist matches v0.45.0 format strings exactly. If a format is reworded upstream, it silently becomes redacted. That is safe, but the error detail is lost with no signal. A comment next to the mcp-go requirement in go.mod, or in the test, saying to re-audit the allowlist on upgrade would help.
  2. Failed to register POST session: %v. The comment says this carries only ErrSessionExists. If a future version wraps the ID into that error, the regex catches generated IDs but not a forged one. Dropping this entry from the allowlist would cost almost nothing and would remove the dependency on that assumption.
  3. Regex coverage. The pattern covers only generated IDs. Forged IDs are protected only by the allowlist, which is the right design, and the forged-ID e2e test covers it.
  4. Test fragility. TestServer_SessionIDsNotLoggedEndToEnd asserts the log contains "sampling response". That ties it to the mcp-go message wording. It's fine as a sanity check that the error path ran, but expect it to need an update on upgrade.

Performance: The regex and Sprintf run only on the error path, so the cost is negligible.

Security: This closes an unauthenticated path where a client-supplied Mcp-Session-Id reached the server log. I found no new concerns.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 17s —— View job


Review of PR #835

I read the pkg/mcp/server.go diff (git diff origin/main...HEAD). I did not run the tests, and I did not re-read session_retention_test.go for this pass. The test results are the ones reported in the PR description.

Verdict: No blocking issues. Merge once the stacked base PRs (#831 and #823) land.

What works

  • newSessionIdManager pins StatelessGeneratingSessionIdManager. A change to the mcp-go default can't switch the server to the unbounded terminated-ID map. The comment says the risk was latent, which matches the PR description.
  • transportLogger.Errorf fails closed. Formats outside transportErrorsWithSafeArgs get [redacted] arguments, and that includes formats added in future mcp-go releases.
  • The mcp-session-<uuid> regex runs on the formatted message, so it also covers allowlisted formats.
  • Printf("...%s", msg) means a % in a message can't be read as a format verb.
  • This closes an unauthenticated path where a client-supplied Mcp-Session-Id could reach the server log.
  • The regex and Sprintf run only on the error path, so the cost is negligible.

Suggestions (non-blocking)

  1. Failed to register POST session: %v (server.go, allowlist). The comment says it carries ErrSessionExists only. If a future mcp-go version wraps the ID into that error, the regex catches generated IDs but not a forged non-UUID ID. Removing the entry costs almost nothing and removes the dependency on that assumption.
  2. Allowlist drift on mcp-go upgrades. The allowlist matches v0.45.0 format strings exactly. If upstream rewords a format, it is silently redacted. That is safe, but the error detail is lost with no signal. A comment next to the mcp-go requirement in go.mod, or in the test, saying to re-audit the allowlist on upgrade would help.
  3. Regex scope. mcp-session-[0-9A-Fa-f-]+ covers generated IDs only. Forged IDs are protected by the allowlist alone. That is the right design, and the PR's forged-ID end-to-end test covers it.
  4. Test fragility. The PR description says the end-to-end test checks that the log contains the sampling-response error. If that assertion matches mcp-go's message wording, expect to update it on upgrade.

Test coverage: The PR lists four tests that cover both the manager pinning and the log leak. The PR's mutation check (swapping in the stateful manager and the old Errorf) is good evidence that they would catch a regression.

@ajitpratap0
ajitpratap0 merged commit aa4f7da into main Oct 21, 2026
39 of 40 checks passed
@ajitpratap0 ajitpratap0 mentioned this pull request Oct 21, 2026
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

1 active deployment
Preview — 2838f838 Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant