feat(mcp): per-call approval card — S4a#41
Open
ndemianc wants to merge 1 commit into
Open
Conversation
…all)
S3 REFUSED any MCP tool the user hadn't hand-added to levelcode.ai.mcp.toolPolicy.
That was the safe-but-unusable placeholder; S4 replaces it with an actual prompt.
Now a tool that isn't allow-listed shows a card — server · tool · the real
arguments — with Skip / Allow once / Always allow. "Always allow" writes the tool
to the allow-list (Global tier, matching the setting's application scope), so
future runs skip the prompt: the allow-list is still the ONE thing that grants
'allow' (G3). Autopilot does not relax any of this — an MCP tool is third-party
code.
Security properties, all verified end-to-end against the S2 fixture server (five
paths: allow-listed runs silently; un-listed prompts→runs on Allow; un-listed
prompts→NOT run on Skip; destructive prompts even when allow-listed and offers no
"always"; no-webview refuses):
- A destructive tool ALWAYS prompts (classifyMcpTool tightens on it) and the card
hides "Always allow" — offering it would be a button that does nothing, since a
destructive tool can never be allow-listed. canAllowAlways is derived from the
same annotation the classifier reads, so they can't disagree.
- The arguments are shown IN FULL on the card (only length-capped). That is the
decision — the user owns the credentials and needs to see the repo it will
touch, the row it will delete. The card is ephemeral UI, never the transcript;
the debug-log redaction (G4) is unchanged.
- No webview to ask through (headless / tests) → falls back to S3's refusal
rather than running third-party code with no way to say no.
Pieces:
- mcpConfig.describeMcpCall (pure): server/tool from the route with a
namespaced-name fallback, bounded+pretty args (never throws on circular input),
destructive/canAllowAlways. 3 new tests, mutation-checked (making a destructive
tool "always-allowable" fails).
- agent.js router: refuse → prompt via ctx.approve({kind:'mcp',…}).
- extension.js: mcpAllowAlways writes the allow-list (user-scoped read, Global
write, rejects a non-namespaced name).
- chat.html: the kind:'mcp' card. VERIFIED VISUALLY — screenshotted both the
normal and destructive variants: args wrap, buttons are distinct (two sharing
`.approve` was a selector bug, caught and fixed), destructive shows the amber
warning and no "Always allow".
Also: the MCP chips used icon:'plug', which isn't a registered codicon, so
addAgentLine rendered the literal word "plug" in the rail. Swapped to 'sparkle'
(the 🔌 emoji still marks it MCP). The identical latent issue on the auto-preview
'globe' chip (extension.js:701) is #35's, left for a follow-up. And
explainMcpRefusal's wording ("this build has no prompt") was made accurate — it's
now only the non-interactive fallback.
Deferred to S4b: the G1 trust-on-first-use LAUNCH gate for workspace-file
(.levelcode/mcp.json) servers — they're still read-and-listed but never started.
This PR is the per-call gate; the launch gate is its own reviewable slice.
Verified: 24 suites, 0 failures (mcpConfig 44 cases).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Implements S4a’s per-call MCP approval UX by replacing the prior “refuse unless pre-allow-listed” behavior with an interactive approval card that shows server/tool and a bounded, pretty-printed argument preview, plus “Allow once” and (when applicable) “Always allow” persistence to the user-scoped allow-list.
Changes:
- Add MCP-specific approval-card rendering in the chat webview, including destructive-tool warnings and “Always allow” gating.
- Introduce
describeMcpCall()+ argument preview bounding inmcpConfig.js, and wire agent MCP calls to prompt (or refuse in non-interactive contexts). - Persist “Always allow” decisions to
levelcode.ai.mcp.toolPolicyat the Global (user) tier, and update MCP activity chips to use a valid codicon.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| extensions/levelcode-ai/test/mcpConfig.test.js | Adds unit coverage for describeMcpCall() (server/tool derivation, destructive gating, bounded args preview). |
| extensions/levelcode-ai/media/chat.html | Adds MCP approval card UI (args well, button semantics, destructive warning, and selector fix via distinct classes). |
| extensions/levelcode-ai/mcpConfig.js | Adds MAX_ARG_CHARS, previewArgs(), and describeMcpCall() to shape approval-card data consistently with classifier annotations. |
| extensions/levelcode-ai/extension.js | Adds “Always allow” persistence plumbing on approval responses (writes to Global user settings). |
| extensions/levelcode-ai/agent.js | Changes MCP tool execution to prompt when not allow-listed (or always for destructive), with non-interactive fallback refusal and corrected codicon usage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+754
to
+768
| async function mcpAllowAlways(name) { | ||
| if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+__[A-Za-z0-9_-]+$/.test(name)) { | ||
| dbg('mcp.allow.reject', { name }); return; | ||
| } | ||
| try { | ||
| const cfg = aiConfig(); | ||
| const cur = userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) || {}; | ||
| if (cur[name] === 'allow') { return; } | ||
| await cfg.update('mcp.toolPolicy', Object.assign({}, cur, { [name]: 'allow' }), vscode.ConfigurationTarget.Global); | ||
| post({ type: 'agentTool', icon: 'check', text: '🔌 mcp · always allow ' + name }); | ||
| dbg('mcp.allow.persisted', { name }); | ||
| } catch (e) { | ||
| dbg('mcp.allow.failed', { name, error: String((e && e.message) || e) }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
S3 refused any MCP tool the user hadn't hand-added to
levelcode.ai.mcp.toolPolicy— the safe-but-unusable placeholder. S4a replaces that wall with an actual prompt.An un-allow-listed call now shows a card — server · tool · the real arguments — with Skip / Allow once / Always allow. "Always allow" writes the tool to the allow-list, so future runs skip the prompt. The allow-list stays the one thing that grants
allow(G3); Autopilot relaxes none of it.Verified
The card, visually — I screenshotted both variants rather than shipping blind:
(Two buttons sharing
.approvewas a selector bug —querySelector('.approve')grabbed the wrong one. Caught in the visual check, fixed with distinct classes.)The flow, end-to-end against the S2 fixture server — five paths:
Security properties
canAllowAlwaysis derived from the same annotation the classifier reads, so they can't drift.Always allowwrites the USER (Global) tier, matching the setting'sapplicationscope, and rejects a non-namespaced tool name.Also
icon:'plug', which isn't a registered codicon —addAgentLinerendered the literal word "plug" in the rail. Swapped tosparkle. The identical issue on the auto-previewglobechip is feat(ai): auto-open the built-in browser when the agent starts a web server #35's; flagged, not mixed in here.explainMcpRefusal's wording ("this build has no prompt") corrected — it's now only the non-interactive fallback.Deferred to S4b
The G1 trust-on-first-use launch gate for workspace-file (
.levelcode/mcp.json) servers — still read-and-listed but never started. This PR is the per-call gate; the launch gate is its own reviewable slice.Verified: 24 suites, 0 failures (mcpConfig 44 cases);
describeMcpCallmutation-checked.🤖 Generated with Claude Code