Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

mcp: server modes, elicitation gating, audit subsystem (PR2)#652

Merged
jeffreyaven merged 5 commits into
mainstackql/stackql:mainfrom
feature/mcp-pr2stackql/stackql:feature/mcp-pr2Copy head branch name to clipboard
May 17, 2026
Merged

mcp: server modes, elicitation gating, audit subsystem (PR2)#652
jeffreyaven merged 5 commits into
mainstackql/stackql:mainfrom
feature/mcp-pr2stackql/stackql:feature/mcp-pr2Copy head branch name to clipboard

Conversation

@jeffreyaven

Copy link
Copy Markdown
Member

Summary

PR2 of the two-PR MCP server series. PR1 trimmed the tool surface to 11 + 1 prompt and shipped a single read_only: true/false flag. PR2 replaces that flag with a four-mode safety contract, wires MCP elicitation for approval-required modes, and adds an audit subsystem.

  • Four server modes replacing IsReadOnly: read_only, safe (new default), delete_safe, full_access. Verb-classified gate (SELECT / INSERT|UPDATE|REPLACE|MERGE|UPSERT / DELETE / EXEC) decides allow / refuse / needs-approval per mode.
  • Elicitation flow for needs-approval modes: server sends elicitation/create if the client advertised the capability; otherwise refuses with a message pointing at full_access.
  • Audit subsystem writing JSONL via a Sink interface; file sink ships with lumberjack rotation. Audit is on by default. Three failure_mode options: strict (default), strict_mutations, best_effort.
  • Gate middleware is the single chokepoint: classify → decide → (refuse / elicit / allow) → handler → audit. Replaces PR1's per-handler isReadOnlyConfig checks.
  • server_info exposes mode (new) and is_read_only (back-compat: true iff mode == "read_only").
  • Legacy back-compat: read_only: true JSON/YAML key still parses and maps to mode: read_only. When both are set, mode wins.

Breaking changes

  1. Default behaviour shift. PR1 default = "no enforcement; mutations proceed." PR2 default = mode: safe = "mutations require user approval." Operators running the bundled client (which does not advertise elicitation) or any non-elicitation automation must explicitly opt into mode: full_access.
  2. Audit on by default. PR1 logged nothing. PR2 writes JSONL to ./stackql_mcp_server_<RFC3339-utc-second>.log by default. To preserve PR1 behaviour set "audit": {"disabled": true} in mcp.config.
  3. Config.Server.IsReadOnly *bool removed in favour of Config.Server.Mode string. Legacy wire form preserved.
  4. Backend constructors (NewStackqlMCPBackendService, NewStackqlMCPReverseProxyService) no longer take isReadOnly bool - the value is derived from the mode string carried by serverBuildInfo.

Architectural invariant

pkg/mcp_server/... still imports zero symbols from internal/... or any-sdk. Verified by grep. The new policy and audit packages are pure: primitive types only, no SDK or internal dependencies.

The serverBuildInfo data carrier in internal/stackql/mcpbackend remains an unexported interface backed by an unexported struct with constructor-only mutation, per the AGENTS.md data-carrier rule.

What landed

New packages

  • pkg/mcp_server/policy/ - QueryClass, ClassifyQuery, Decision, GateDecision, mode string constants. Pure functions.
  • pkg/mcp_server/audit/ - Event (flat, primitive-typed DTO), Sink interface, NopSink, NewFileSink (lumberjack-backed, fsync per record, 0600 file perms).

New / changed files

Go module

  • Added gopkg.in/natefinch/lumberjack.v2 for log rotation.

Test plan

  • go build ./... clean.
  • go vet ./... clean.
  • golangci-lint run ./pkg/mcp_server/... ./internal/stackql/mcpbackend/... ./internal/stackql/cmd/...0 issues.
  • go test --tags sqlite_stackql -count=1 ./... → all packages pass.

New unit tests

  • pkg/mcp_server/policy/policy_test.go - 6 tests covering classification of every verb and gate decision for every (mode, class) combination including empty / unknown.
  • pkg/mcp_server/audit/file_test.go - JSONL round-trip, default-path generation in cwd, fail-fast on unwritable dir, nop sink.
  • pkg/mcp_server/tools_test.go extensions:
    • TestMode_ReadOnly_RefusesAllMutationsAndLifecycle
    • TestMode_Safe_RefusesMutationsWithoutElicitation
    • TestMode_DeleteSafe_AllowsCreateRefusesDeleteAndLifecycle
    • TestMode_FullAccess_AllowsEverything
    • TestMode_Safe_ElicitationAcceptProceeds (uses ClientOptions.ElicitationHandler)
    • TestMode_Safe_ElicitationDeclineRefuses
    • TestMode_Safe_ElicitationCancelRefuses
    • TestMode_DeleteSafe_ElicitationAcceptAllowsDelete
    • TestServerInfo_SurfacesMode
    • TestAudit_RecordsAllToolCalls (end-to-end: in-process server → temp audit file → JSONL parse)
  • pkg/mcp_server/config_legacy_test.go - 8 tests covering legacy shim mapping, mode-wins-over-legacy, read_only: false doesn't force a mode, unknown-mode rejected, audit.disabled parsing, failure_mode validation, empty-mode defaults to safe.

Robot scenarios added

  • MCP HTTP Mode Read Only Refuses Mutations And Lifecycle (9920)
  • MCP HTTP Mode Safe Refuses Mutations Without Elicitation (asserts the no-elicitation fallback)
  • MCP HTTP Mode Delete Safe Allows Create Refuses Delete And Lifecycle (9921)
  • MCP HTTP Mode Full Access Allows Everything (9922)
  • MCP HTTP Audit Basic Records Tool Calls (9923 with known path)
  • MCP HTTP Audit Disabled Writes No File

Existing MCP HTTP Server Info Includes Version extended to assert the mode key. Existing MCP HTTPS Run Mutation Refused In Read Only updated to assert read_only (underscore) - exercises the legacy shim on the 9916 server which still uses the read_only: true wire form.

  • Robot suite run locally / in CI

Out of scope (deferred)

  • An elicitation-capable MCP test client. Robot scenarios cover the no-elicitation fallback path only; the elicitation-positive path is covered by Go unit tests via ClientOptions.ElicitationHandler and verified manually with elicitation-capable clients (Claude Desktop, Cursor, etc).
  • Sinks other than file (Kafka, RDBMS, KV). The Sink interface admits them as future additions.
  • Per-tool mode overrides. Mode is global per server.

🤖 Generated with Claude Code

jeffreyaven and others added 3 commits May 16, 2026 18:15
The new mode-contract scenarios (MCP HTTP Mode Read Only / Delete Safe /
Full Access / Audit Basic / Audit Disabled) all failed in CI with rc=2.

Root cause: I synthesised SQL like

  select name from google.compute.networks where project = 'stackql-demo'
  delete from google.compute.firewalls where project = 'p' and firewall = 'f'

against the test registry.  google.compute.networks is registered as
metadata (used by describe_resource / list_methods in earlier scenarios)
but is not mocked as an executable SELECT target.  The synthetic DELETE
values 'p' / 'f' don't match the canonical mock either.  The bundled
mcp_client panics on unknown-target query errors with exit code 2.

Switch all five scenarios to the canonical mocked SQL already used by
the working MCP HTTPS Server Query / Exec Query Canonical scenarios:

  select name, id from google.storage.buckets where project = 'stackql-demo'
  delete from google.compute.firewalls where project = 'mutable-project' and firewall = 'deletable-firewall'

The MCP HTTP Mode Safe Refuses Mutations Without Elicitation scenario
already happened to pass (it expected rc != 0, which the synthetic SQL
also produced) but the same SQL substitution makes its intent clearer.

Also:
- Clean up six empty stackql_mcp_server_*.log files inadvertently
  committed in the PR2 squash.  These were left by unit tests that
  exercised audit-on paths before connectInProcess gained its disable-
  audit default.
- Add stackql_mcp_server_*.log to .gitignore so future stray audit
  files don't get tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MCP HTTP Audit Basic Records Tool Calls failed on Windows CI with
rc=1 on the first SELECT against the audit-enabled 9923 server.

Root cause: the audit log path embedded in mcp.config was
`${CURDIR}/tmp/mcp-audit-9923.log`.  On Windows, ${CURDIR} expands to
`D:\a\stackql\stackql\test\robot\functional`.  Inside a JSON string,
the resulting `\a` `\s` `\t` etc are invalid backslash escapes, so
json.Unmarshal in cmd/mcp.go's runMCPServer fails silently and
config stays as zero-value.  The server then binds the default
address 127.0.0.1:9876 instead of 9923, the client sees connection
refused, and rc=1.

Fix the embedded path to a relative filename `mcp-audit-9923.log`.
The stackql process resolves it through filepath.Abs against its cwd
(the directory robot was invoked from), so the file lands in the
repo root regardless of OS path-separator semantics.  Update the
test's Get File call to use ${EXECDIR} to match.

Also:
- Add a preflight server_info call at the start of the audit test
  so a future audit-server startup failure surfaces with a clear
  message ("9923 server must be reachable...") instead of a generic
  rc-comparison failure.
- Add mcp-audit-*.log to .gitignore so the audit file from a robot
  run doesn't get accidentally committed.

The audit-disabled scenario is unaffected (its glob
stackql_mcp_server_*.log does not match mcp-audit-9923.log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pkg/mcp_server/audit package should have the Sink interface and its implementations moved to a separate pkg/sink package and keep all of the mcp type stuff like formats and reasons can stay here for now. When new forms of sinks come in and we start doing stuff like file split / rotate / GC then it will be mega useful, not only for mcp

@@ -0,0 +1,145 @@
// Package policy classifies SQL queries and decides whether the server should

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please condense the separate QueryClass and Descision objects as return values of a composite Policy interface. The construction of same can be handled by a factory function at the client site or a New... constructor here, latter provided that only inherent types and no imports required

@general-kroll-4-life general-kroll-4-life left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall pretty good. A couple of points to change

Comment thread pkg/sink/file.go
// "stackql_mcp_server_<timestamp>.log") without bringing the format
// string into the sink package. When nil, a generic
// "sink_<RFC3339-utc-second>.log" filename is used.
DefaultFilename func(time.Time) string `json:"-" yaml:"-"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force the client to at least specify the directory for the file sink. If the mcp server defaults this to cwd for now thats fine.

@general-kroll-4-life general-kroll-4-life left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found one more thing that will do then

@jeffreyaven jeffreyaven merged commit 93d989b into main May 17, 2026
20 checks passed
@jeffreyaven jeffreyaven deleted the feature/mcp-pr2 branch May 17, 2026 06:41
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.

2 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.