Skip to content

MCP permission prompt

ai-ui specs/ai-ui/mcp-permission-prompt.kmd

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.

When this spec applies

Primary triggers

All triggers

Specification body

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. Untrusted by default — todo MCP server é untrusted até o user dar consent explícito.
  2. 4-grain control — Allow once / Allow always / Deny once / Deny always; binário (sim/não) viola UX state of the art.
  3. Decoupled lookup — permission resolution SEPARADA do schema load. Tool catálogo pode estar incompleto; permission cache pode estar fresh.
  4. Audit 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:

SlotConteúdo
Tool icon + nameComo em mcp-tool-invocation.kmd R1
Server origin chipSlug + trust indicator (cross-link mcp-server-state.kmd)
Risk badgeper R2 — Low / Medium / High
AnnotationsRender de tools/list[].annotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
Arguments previewJSON truncated com Show-more
Actions4 botões per R3

R2 — Risk derivation

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

RiskCritériosColor (color-roles)
LowreadOnlyHint: true AND server trustedsuccess-container
MediumreadOnlyHint: true AND server untrusted, OR idempotentHint: true with side effectswarning-container
HighdestructiveHint: 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)

ActionBehavior
Allow onceExecuta este invoke; próximo invoke do mesmo tool re-prompts.
Allow alwaysExecuta este invoke; cria entry em permission store: (server_id, tool_name, user_id, workspace_id) → ALLOW. Re-prompts NÃO acontecem.
Deny onceCancela este invoke; próximo invoke do mesmo tool re-prompts.
Deny alwaysCancela este invoke; entry permission store: → DENY. AI client receive error result com explanation.

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

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

R4 — Persistência

Permission store schema (kdb-kv table per rfc-001 kdb-as-unified-data-plane):

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 é O(1) per tool call.
  • Cache em memory client-side; sync com kdb on session start.
  • Cross-tenant lookup retorna nil (não erro), per multi-tenant-by-default.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 default-allow ou default-deny silenciosamente.

R5 — Auto-revoke

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

RiskDefault expiry
Low90 dias
Medium30 dias
High7 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

SurfaceAPI
FlutterKoderMCPPermissionSheet em engines/sdk/koder_kit/lib/src/ai/mcp_permission_sheet.dart
Web<koder-mcp-permission-sheet>
Compose AndroidKoderMCPPermissionSheet em koder-design-compose (futuro)
SwiftUI iOSKoderMCPPermissionSheet em koder-design-swift (futuro)
CLI / TUIPlain prompt: header + actions numeradas (1/2/3/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.
  • Reduced-motion: sheet aparece sem slide-in.
  • Touch target: cada button ≥48dp.

R9 — i18n

Copy ratificada em koder_kit/l10n:

Keyen-USpt-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

  • T1 Prompt shows: novo tool call sem entry no store → sheet aparece.
  • T2 Allow once: tool executa; segundo call do mesmo tool → re-prompt.
  • T3 Allow always: tool executa; segundo call → sem prompt (cache hit). Audit log emite "cache_hit".
  • T4 Deny once: tool NÃO executa; AI client recebe error result.
  • T5 Deny always: tool NÃO executa; entry persiste; segundo call também denied sem re-prompt.
  • T6 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.
  • T7 Auto-revoke: Allow always low-risk; avançar clock 91 dias; próximo call → re-prompt (expired).
  • T8 Multi-tenant: user A allow always em workspace 1; user B no workspace 2 invoca mesmo tool → re-prompt (scoping correto).
  • N1 Race condition negativo: invoke disparado antes do permission store sync → R4.1 fallback (prompt).
  • N2 Audit log emit on every decision (T1-T5).
  • N3 Destructive hint Allow always proibido: tool com destructiveHint: true → Allow always button disabled, tooltip explica.

References