Skip to content

Phase 4: MCP Permission Model & Management UI #10

Description

@Blankll

[Phase 4] MCP Permission Model — client-driven confirmation + server-enforced policy

Parent epic: #9
Status: Implemented — all deliverables complete, E2E release-gate pending
Priority: High


Background

data-studio-mcp exposes ~63 database tools across ES, MongoDB, DynamoDB (via dockit) and PostgreSQL, MySQL, SQL Server, SQLite (via sqlkit). These tools have different risk levels — reading data vs. creating indexes vs. deleting tables. We need a permission model that:

  1. Confirms dangerous operations in the agent client (Claude Code, Cursor, OpenCode, Codex, Pi) — the UI the user is actually in
  2. Enforces policy server-side at the bridge — the final safety boundary, even if the client fails to intercept

Research: How the MCP ecosystem actually handles this

The MCP spec (2025-06-18) has NO built-in confirmation/permission mechanism.

  • Tools are "model-controlled"; the protocol does not mandate any user-interaction model
  • Spec security recommendations only say clients SHOULD prompt for confirmation on sensitive operations
  • elicitation is a server→client request for structured input (usernames, tokens) — not a confirmation gate; servers MUST NOT use it to collect sensitive info
  • No mainstream MCP server uses a two-step confirm/execute tool pair — that pattern is not recognized by the ecosystem

What the ecosystem actually does — two separated layers:

Layer Mechanism Examples
UX confirmation (client) Client intercepts tools/call before sending, shows native dialog that stays open until the user decides Claude Code permissions.ask rules, OpenCode permission config, Cursor mcpAllowlist + auto-review, Codex approval_mode
Risk vocabulary (server) ToolAnnotations in tools/list: readOnlyHint, destructiveHint, idempotentHint, openWorldHint — clients use these to decide when to prompt Filesystem MCP (annotates every tool), Claude Code (auto-approves read-only tools from trusted servers), Cursor auto-review classifier
Static enforcement (server) Server filters/denies at the boundary GitHub MCP (OAuth scope filtering), Neon MCP (readonly=true hides write tools)

Key insight: Confirmation UX is 100% client-side and happens before the server ever receives the call. The server's job is (a) to expose honest risk metadata via ToolAnnotations, and (b) to enforce policy statically so a client that fails to intercept (bypass mode, malicious client) is still blocked.

Architecture

┌─ UX layer (agent client) ─────────────────────────────────────────┐
│                                                                  │
│  tools/list returns ToolAnnotations per tool:                    │
│    data_studio__es_delete_index → destructiveHint: true          │
│    data_studio__mongo_find      → readOnlyHint: true             │
│                                                                  │
│  Client shows native confirmation (stays open until decision):   │
│    "Call data_studio__es_delete_index?"  [Allow] [Deny]          │
│  → Allow: client sends tools/call  |  Deny: call never sent      │
└──────────────────────────────────────────────────────────────────┘
┌─ Policy layer (bridge — final enforcement) ──────────────────────┐
│                                                                  │
│  McpPolicy (store-backed):                                       │
│    mode: ReadOnly | DataReadWrite | FullAccess                   │
│    allowed_connection_ids: allowlist (empty = all)               │
│    connection_overrides: per-connection read-only                │
│                                                                  │
│  /invoke checks every call: mode → allowlist → override          │
│  → insufficient: 403 even if the client allowed it               │
│                                                                  │
│  Optional read-only mode: /tools hides all non-readonly tools    │
└──────────────────────────────────────────────────────────────────┘

Desktop apps (dockit/sqlkit) do not participate in MCP confirmations — they only execute. The built-in AI assistant has its own existing confirmation flow (ConfirmMap + tool-confirmation-card) and is out of scope.

Solution Design

1. Risk vocabulary: ToolAnnotations (MCP server)

Map each capability's RiskLevel to the standard ToolAnnotations in tools/list:

RiskLevel readOnlyHint destructiveHint idempotentHint openWorldHint
Safe (read/query/metadata) true false true false
Elevated (insert/update/create) false false false true
Destructive (delete/drop/truncate) false true false true
  • Bridge already exposes metadata.riskLevel per tool in /tools — MCP server maps it to annotations
  • destructiveHint: true is what makes Claude Code / Cursor / OpenCode show their native confirm dialogs

2. Server-enforced policy: McpPolicy (bridge)

// crates/data-studio-agent/src/capabilities/permissions.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum McpPermissionMode { ReadOnly, DataReadWrite, FullAccess }

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpPolicy {
    pub mode: McpPermissionMode,
    pub allowed_connection_ids: Vec<String>,              // empty = all
    pub connection_overrides: HashMap<String, ConnectionMcpOverride>,
    pub confirm_destructive: bool,                        // default true (policy hint, see below)
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConnectionMcpOverride { pub read_only: bool }
  • Stored per app (.store.dat via tauri-plugin-store), read on every /tools and /invoke
  • confirm_destructive is a policy hint surfaced in /tools policy payload so the MCP server can strengthen annotations when needed — the actual confirmation UI remains the client's job

Decision flow in /invoke:

1. Lookup capability → unknown → 404
2. Read McpPolicy
3. Connection allowlist → not allowed → 403
4. Risk check:
   - Safe → execute
   - Elevated → execute if mode allows, else 403
   - Destructive → execute if mode allows (FullAccess), else 403
5. Per-connection read-only override → 403 for Elevated/Destructive

/tools additionally filters tool list by mode when running in read-only mode:

/tools?readonly=true  (or policy.mode == ReadOnly)
  → only capabilities whose riskLevel == Safe are listed
  → MCP server never sees write/delete tools (Neon-style)

3. Optional read-only mode (MCP server)

Neon-style flag: --readonly (or env DATA_STUDIO_MCP_READONLY=1) — MCP server filters backend tools to riskLevel == Safe before advertising them. Defense in depth for "query-only" deployments.

4. Client-side documentation (per agent)

Users configure which tools require confirmation in their client. We document the exact configs:

Client Config
Claude Code settings.json: "permissions": { "ask": ["mcp__data-studio__*delete*", "mcp__data-studio__*drop*", "mcp__data-studio__*truncate*"] } (or ask: ["mcp__data-studio__*"] for everything)
OpenCode opencode.json: "permission": { "mcp__data-studio__*": "ask" } (or granular per-tool)
Cursor permissions.json: omit from mcpAllowlist → routed to auto-review/safety classifier; beforeMCPExecution hooks as needed
Codex config.toml: default_tools_approval_mode = "writes" + per-tool overrides

Implementation Plan

1. data-studio-agent — permission types ✅ (done)

McpPermissionMode, McpPolicy, ConnectionMcpOverride + allows(), is_connection_allowed(), is_connection_read_only() + 6 unit tests (PR #20). confirm_destructive added with decide() (Allow/Ask/Deny), deny_reason(), policy_notice() — 90 unit tests (v0.1.5).

2. MCP server — ToolAnnotations + read-only mode (TypeScript)

packages/data-studio-mcp/src/
├── tools.ts         ← map BridgeToolDef.metadata.riskLevel → ToolAnnotations
├── annotations.ts   ← NEW: pure riskLevel→annotations mapper (unit-tested)
└── index.ts         ← --readonly flag; filter tools; attach annotations in tools/list

3. Bridge — McpPolicy enforcement (dockit + sqlkit)

mcp_bridge.rs:
- load_policy(handle) -> McpPolicy          (store-backed)
- handle_invoke: mode → allowlist → override decision flow
- handle_tools:   filter by mode (read-only mode), include policy payload
- /invoke errors: 403 with clear message ("requires FullAccess mode" etc.)

4. Settings UI (dockit + sqlkit)

Settings → MCP
├── Bridge Status: ● Running on port 9120
├── Permission Mode
│   ○ Read Only — Query data and explore schemas only
│   ○ Data Read/Write — Insert, update, and create
│   ○ Full Access — Also allows delete, drop, and destructive operations
├── [x] Confirm destructive operations (enforced via agent confirmation)
├── Connection Allowlist
│   ☑ prod-es          (Elasticsearch)
│   ☑ staging-es       (Elasticsearch)
│   ☐ analytics-mongo  (MongoDB)
│   [Select all] [Deselect all]
└── Connection Overrides
    prod-es  ──────────────────── [Read-only for MCP ☐]
    staging-es ────────────────── [Read-only for MCP ☐]

5. Documentation — client permission configs

Per-agent setup guide (Claude Code / OpenCode / Cursor / Codex) so dangerous tools prompt in the client UI.

Testing

  • Unit: annotations mapping (riskLevel → 4 hints); McpPolicy allowlist/override/mode matrix
  • Bridge: /invoke 403 paths (mode, allowlist, override); /tools read-only filtering
  • E2E (release gate): real dockit + real client — destructive tool prompts in client UI; deny blocks; bypass-mode client still gets 403 from bridge

Deliverables

  • data-studio-agent: McpPermissionMode, McpPolicy, ConnectionMcpOverride types + tests (PR feat: MCP permission model — PolicyAction, ToolAnnotations, --readonly, unified server-side Deny #20 → master, v0.1.5)
  • data-studio-agent: Add confirm_destructive field to McpPolicy + tests (incl. PolicyAction Allow/Ask/Deny, deny_reason(), policy_notice())
  • data-studio-mcp: Map RiskLevelToolAnnotations in tools/list (ANNOTATIONS_BY_RISK in tools.ts)
  • data-studio-mcp: --readonly mode flag (filter non-Safe tools) — published as npm 0.1.4
  • dockit: McpPolicy enforcement in bridge /invoke (mode → allowlist → override → 403 with actionable deny_reason() guidance)
  • dockit: Read-only filtering in bridge /tools + policy_notice() appended to surviving tool descriptions
  • dockit: MCP settings page (permission mode, confirm toggle, allowlist, connection access with per-action R/W/D, restart feedback) — PR #473
  • sqlkit: Same as dockit (enforcement + filtering + settings page) — PR #129
  • docs: Client permission configuration guide (Claude Code / OpenCode / Cursor / Codex) — PERMISSIONS.md
  • extra: PolicyAction three-value decision + server-side Deny short-circuit in the built-in agent loop (should_deny in loop_runner)
  • extra: McpAction per-connection action allowlist (read/write/delete), supersedes the boolean read_only override

Remaining: E2E release-gate test (real dockit/sqlkit + real client — destructive tool prompts in client UI; deny blocks; bypass-mode client still gets 403 from bridge).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions