MCP permission prompt

mandatory

Consent gate UI before invoking MCP tools with side effects. Implements the SHOULD-level requirement from MCP spec (Tools Security): "Clients SHOULD prompt for user confirmation on sensitive operations." Required to ship any MCP-aware Koder client safely.

Spec — MCP permission prompt

MCP normative source: https://modelcontextprotocol.io/specification/2025-11-25/server/tools §Security. Histórico: Claude Code bug #28580 — permission lookup acoplado ao tool schema load causou false-denies. Lição: separar lookup.

Princípios

  1. *ntrusted by default*— todo MCP server é untrusted até o user dar consent explícito.
  2. *-grain control*— Allow once / Allow always / Deny once / Deny always; binário (sim/não) viola UX state of the art.
  3. *ecoupled lookup*— permission resolution SEPARADA do schema load. Tool catálogo pode estar incompleto; permission cache pode estar fresh.
  4. *udit everything*— toda decisão persistida em audit log; user pode revisar histórico.

R1 — Anatomia

Bottom sheet (mobile, compact width) OU modal centered (desktop, expanded width):

┌──────────────────────────────────────────────┐
│  [🔧] tool_name                              │
│  From <server_origin_chip> [risk_badge]      │
├──────────────────────────────────────────────┤
│  This tool will:                             │
│  • <annotation.title>                        │  ← annotations do MCP tool
│  • <annotation.readOnlyHint?>                │
│  • <annotation.destructiveHint?>             │
│                                              │
│  Arguments preview:                          │
│  { "query": "...", "limit": 10 }             │
├──────────────────────────────────────────────┤
│  [ Deny always ]   [ Deny once ]             │  ← actions
│  [ Allow once  ]   [ Allow always ]          │
└──────────────────────────────────────────────┘

Slots:

Slot Conteúdo
Tool icon + name Como em mcp-tool-invocation.kmd R1
Server origin chip Slug + trust indicator (crosslink [`mcpserverstate.kmd`](mcpserver-state.kmd))
Risk badge per R2 — Low / Medium / High
Annotations Render de tools/list[].annotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
Arguments preview JSON truncated com Show-more
Actions 4 botões per R3

R2 — Risk derivation

Risk badge derivado de tools/list[].annotations + heuristics:

Risk Critérios Color (color-roles)
*ow* readOnlyHint: true AND server trusted success-container
*edium* readOnlyHint: true AND server untrusted, OR idempotentHint: true with side effects warning-container
*igh* destructiveHint: true OR openWorldHint: true OR no annotations (treat as worst case) error-container

Quando server fornece custom risk metadata via _meta.koder.risk, isso sobrescreve o derivation acima (server self-describes).

R3 — Actions (4-grain control)

Action Behavior
*llow once* Executa este invoke; próximo invoke do mesmo tool re-prompts.
*llow always* Executa este invoke; cria entry em permission store: (server_id, tool_name, user_id, workspace_id) → ALLOW. Re-prompts NÃO acontecem.
*eny once* Cancela este invoke; próximo invoke do mesmo tool re-prompts.
*eny always* Cancela este invoke; entry permission store: → DENY. AI client receive error result com explanation.

Default focus button: *llow once*(princípio do menor compromisso — user precisa confirmar, mas não auto-trust).

Tools com destructiveHint: true: default focus muda pra *eny once*(reduce accident risk).

R4 — Persistência

Permission store schema (kdbkv table per `rfc001 kdbasunifieddataplane`):

key:  mcp_permission:<koder_user_id>:<workspace_id>:<server_id>:<tool_name>
value: {
  decision: "ALLOW" | "DENY",
  granted_at: ISO8601,
  expires_at: ISO8601 | null,
  granted_by: <koder_user_id>,
  args_hash: optional sha256(canonical_json(args))  // se per-args, não per-tool
}
  • Lookup é *(1)*per tool call.
  • Cache em memory client-side; sync com kdb on session start.
  • Crosstenant lookup retorna nil (não erro), per `multitenantbydefault.kmd`.

R4.1 — Lookup decouple (lição Claude Code #28580)

Permission lookup MUST NOT bloquear ou atrasar tool schema load.

  • Schema load: tools/list request → cache schemas. Não consulta permission store.
  • Permission lookup: chamado SÓ no momento do tools/call, após user click "invoke" (ou agent autonomous decide invoke). Lookup roda em background; UI mostra "Checking permissions…" se >100ms.
  • Race condition: se permission store está vazio E user já clicou invoke, fall back para R1 prompt (consent UI). NUNCA assume defaultallow ou defaultdeny silenciosamente.

R5 — Auto-revoke

Allow always SHOULD ter expires_at automático (mitigation):

Risk Default expiry
Low 90 dias
Medium 30 dias
High 7 dias (Allow always proibido pra destructiveHint: true; user MUST escolher Allow once)

User pode override via settings (extender ou desabilitar expiry). Expired permissions disparam re-prompt na próxima invocação.

R6 — Audit log

Toda decisão de permission MUST emit audit event pra services/foundation/audit/ schema:

{
  event_type: "mcp.permission.decision",
  decision: "ALLOW_ONCE" | "ALLOW_ALWAYS" | "DENY_ONCE" | "DENY_ALWAYS",
  koder_user_id: ...,
  workspace_id: ...,
  server_id: ...,
  tool_name: ...,
  args_hash: sha256(canonical_json(args)),
  risk_tier: "low" | "medium" | "high",
  timestamp: ISO8601,
  origin: "user_prompt" | "cache_hit" | "auto_revoke_renewal"
}

Audit log respeita policies/identity-data-retention.kmd (R2 auth_events retention windows; mcp.permission.* falls under same retention).

R7 — Surface bindings

Surface API
Flutter KoderMCPPermissionSheet em engines/sdk/koder_kit/lib/src/ai/mcp_permission_sheet.dart
Web <koder-mcp-permission-sheet>
Compose Android KoderMCPPermissionSheet em koder-design-compose (futuro)
SwiftUI iOS KoderMCPPermissionSheet em koder-design-swift (futuro)
CLI / TUI Plain prompt: header + actions numeradas (123/4)

API consistent: show(toolCall, onDecision: callback).

R8 — Acessibilidade

  • Sheet é role="dialog" aria-modal="true" aria-labelledby="tool-name".
  • Focus trap: Tab cycle entre 4 botões + close.
  • ESC = Deny once (não Deny always — escape é cautious).
  • Screen reader announce: tool name + risk tier + annotations.
  • Reducedmotion: sheet aparece sem slidein.
  • Touch target: cada button ≥48dp.

R9 — i18n

Copy ratificada em koder_kit/l10n:

Key en-US pt-BR
mcp.permission.title "Allow this tool to run?" "Permitir execução desta ferramenta?"
mcp.permission.from "From {server}" "Do servidor {server}"
mcp.permission.action.allow_once "Allow once" "Permitir uma vez"
mcp.permission.action.allow_always "Allow always" "Permitir sempre"
mcp.permission.action.deny_once "Deny once" "Negar uma vez"
mcp.permission.action.deny_always "Deny always" "Negar sempre"
mcp.permission.risk.low "Low risk · read-only" "Risco baixo · somente leitura"
mcp.permission.risk.medium "Medium risk" "Risco médio"
mcp.permission.risk.high "High risk · may modify data" "Risco alto · pode modificar dados"

Per feedback_kds_owner_curated_content: editorial copy NOT editable by AI autonomously; changes require owner review.

T-suite

  • *1*Prompt shows: novo tool call sem entry no store → sheet aparece.
  • *2*Allow once: tool executa; segundo call do mesmo tool → re-prompt.
  • *3*Allow always: tool executa; segundo call → sem prompt (cache hit). Audit log emite "cache_hit".
  • *4*Deny once: tool NÃO executa; AI client recebe error result.
  • *5*Deny always: tool NÃO executa; entry persiste; segundo call também denied sem re-prompt.
  • *6*Decouple regression (lição #28580): tools/list demora 5s; user clica invoke imediatamente após render do tool card → permission lookup roda independentemente; sheet aparece sem aguardar schema reload.
  • *7*Autorevoke: Allow always lowrisk; avançar clock 91 dias; próximo call → re-prompt (expired).
  • *8*Multitenant: user A allow always em workspace 1; user B no workspace 2 invoca mesmo tool → reprompt (scoping correto).
  • *1*Race condition negativo: invoke disparado antes do permission store sync → R4.1 fallback (prompt).
  • *2*Audit log emit on every decision (T1-T5).
  • *3*Destructive hint Allow always proibido: tool com destructiveHint: true → Allow always button disabled, tooltip explica.

Source: ../home/koder/dev/koder/meta/docs/stack/specs/ai-ui/mcp-permission-prompt.kmd