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
- Gate any MCP tools/call execution behind explicit user consent
All triggers
- Invoke an MCP tool with side effects from any Koder client
- Implement MCP consumer in any product
- Audit MCP-related security flow
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
- Untrusted by default — todo MCP server é untrusted até o user dar consent explícito.
- 4-grain control — Allow once / Allow always / Deny once / Deny always; binário (sim/não) viola UX state of the art.
- Decoupled lookup — permission resolution SEPARADA do schema load. Tool catálogo pode estar incompleto; permission cache pode estar fresh.
- 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:
| Slot | Conteúdo |
|---|---|
| Tool icon + name | Como em mcp-tool-invocation.kmd R1 |
| Server origin chip | Slug + trust indicator (cross-link mcp-server-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) |
|---|---|---|
| Low | readOnlyHint: true AND server trusted | success-container |
| Medium | readOnlyHint: true AND server untrusted, OR idempotentHint: true with side effects | warning-container |
| High | 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 |
|---|---|
| Allow once | Executa este invoke; próximo invoke do mesmo tool re-prompts. |
| Allow always | Executa este invoke; cria entry em permission store: (server_id, tool_name, user_id, workspace_id) → ALLOW. Re-prompts NÃO acontecem. |
| Deny once | Cancela este invoke; próximo invoke do mesmo tool re-prompts. |
| Deny always | Cancela 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/listrequest → 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):
| 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 (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:
| 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
- 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.
Cross-link
- Companion:
mcp-tool-invocation.kmd(consumer da decision),mcp-server-state.kmd(server trust state) - Policies:
multi-tenant-by-default.kmd(R4 storage),identity-data-retention.kmd(R6 audit retention) - Backend:
services/ai/mcp/,services/foundation/audit/ - Historical incident: https://github.com/anthropics/claude-code/issues/28580 (decouple lesson)
- MCP normative: https://modelcontextprotocol.io/specification/2025-11-25/server/tools
References
meta/docs/stack/specs/ai-ui/mcp-tool-invocation.kmdmeta/docs/stack/specs/ai-ui/mcp-server-state.kmdmeta/docs/stack/policies/multi-tenant-by-default.kmdmeta/docs/stack/policies/identity-data-retention.kmd