Skip to content

feat(mcp): per-call approval card — S4a#41

Open
ndemianc wants to merge 1 commit into
developfrom
feat/mcp-s4-approval
Open

feat(mcp): per-call approval card — S4a#41
ndemianc wants to merge 1 commit into
developfrom
feat/mcp-s4-approval

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

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:

normal tool destructive tool
Skip · Always allow · Allow once Skip · Allow once (no "Always allow")
args pretty-printed & wrapping amber "server marks this destructive" warning

(Two buttons sharing .approve was 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:

  1. allow-listed → runs, no prompt
  2. un-listed → prompts → Allow → runs (card carried the real args)
  3. un-listed → prompts → Skip → declined, tool not called
  4. destructive + allow-listed → still prompts, card offers no "Always allow"
  5. no webview (headless/tests) → refuses with the non-interactive message

Security properties

  • A destructive tool always prompts (classifyMcpTool tightens on it) and the card hides "Always allow" — a destructive tool can never be allow-listed, so offering it would be a dead button. canAllowAlways is derived from the same annotation the classifier reads, so they can't drift.
  • Arguments shown in full (length-capped) — that is the decision; the user owns the credentials and needs to see the repo it touches, the row it deletes. The card is ephemeral UI, never the transcript; G4's debug-log redaction is unchanged.
  • Always allow writes the USER (Global) tier, matching the setting's application scope, and rejects a non-namespaced tool name.

Also

  • MCP chips used icon:'plug', which isn't a registered codicon — addAgentLine rendered the literal word "plug" in the rail. Swapped to sparkle. The identical issue on the auto-preview globe chip 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); describeMcpCall mutation-checked.

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings July 24, 2026 18:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in mcpConfig.js, and wire agent MCP calls to prompt (or refuse in non-interactive contexts).
  • Persist “Always allow” decisions to levelcode.ai.mcp.toolPolicy at 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) });
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants