@@ -27,7 +27,7 @@ const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
2727const { openCustomize } = require ( './customize' ) ;
2828const { importFromVscode } = require ( './importVscode' ) ;
2929const { reapMcp, listActive, getServer } = require ( './mcpClient' ) ;
30- const { userScopedSetting, isNamespacedToolName, safeCopy, loadServerConfig, summarizeMcp } = require ( './mcpConfig' ) ;
30+ const { userScopedSetting, isNamespacedToolName, safeCopy, loadServerConfig, summarizeMcp, parseArgv } = require ( './mcpConfig' ) ;
3131
3232const SECRET_KEY = 'levelcode.ai.anthropicKey' ; // legacy Anthropic key location (kept for back-compat)
3333const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}' ;
@@ -1350,6 +1350,226 @@ async function pickCloudModel() {
13501350 }
13511351}
13521352
1353+ // ---- "Manage MCP servers…" (docs/MCP.md S6) --------------------------------
1354+ // The last place MCP forced people into raw JSON, and the only UI for revoking a repo server's G1
1355+ // launch trust — until now that decision was write-once, with no way back short of clearing
1356+ // workspaceState by hand.
1357+
1358+ /**
1359+ * Is this server's G1 approval STALE — approved once, but the command line has changed since?
1360+ *
1361+ * summarizeMcp collapses "never approved" and "approved, then the repo edited the command" into one
1362+ * `trusted:false`, which is right for starting the server and wrong for explaining it. The second case
1363+ * is the exact attack G1 exists to stop: get a benign entry approved, then swap the command. It
1364+ * deserves to be named rather than shown as a server that was simply never set up.
1365+ */
1366+ function mcpTrustIsStale ( s , store ) {
1367+ return s . trusted === false && Object . prototype . hasOwnProperty . call ( store || { } , s . name ) ;
1368+ }
1369+
1370+ /** One row per configured server, on the same shape summarizeMcp already produces for `/mcp`. */
1371+ function mcpServerItem ( s , store ) {
1372+ let state , icon ;
1373+ if ( s . running ) { state = 'running' ; icon = '$(pass-filled)' ; }
1374+ else if ( mcpTrustIsStale ( s , store ) ) { state = 'command changed — needs approval' ; icon = '$(warning)' ; }
1375+ // A repo server waiting on the consent card is not broken — saying only "not started" would send
1376+ // the user debugging a gate that is working.
1377+ else if ( s . trusted === false ) { state = 'needs approval' ; icon = '$(shield)' ; }
1378+ else { state = 'not started' ; icon = '$(circle-outline)' ; }
1379+
1380+ const bits = [ s . source === 'settings' ? 'your settings' : ( s . origin || 'workspace' ) , state ] ;
1381+ if ( s . running ) {
1382+ bits . push ( s . tools + ' tool' + ( s . tools === 1 ? '' : 's' ) ) ;
1383+ if ( s . allowed != null ) { bits . push ( s . allowed + ' allow-listed' ) ; }
1384+ }
1385+ return { label : icon + ' ' + s . name , description : bits . join ( ' · ' ) , detail : s . commandLine , _server : s } ;
1386+ }
1387+
1388+ async function manageMcpServers ( ) {
1389+ const view = mcpOverview ( ) ;
1390+ const store = mcpLaunchTrust ( ) ;
1391+ const items = [ ] ;
1392+
1393+ if ( view . servers . length ) {
1394+ items . push ( { label : 'Configured servers' , kind : vscode . QuickPickItemKind . Separator } ) ;
1395+ for ( const s of view . servers ) { items . push ( mcpServerItem ( s , store ) ) ; }
1396+ }
1397+ items . push ( { label : view . servers . length ? 'Actions' : 'No servers configured' , kind : vscode . QuickPickItemKind . Separator } ) ;
1398+ items . push ( { label : '$(add) Add a server…' , description : 'Write a new entry to your settings' , _action : 'add' } ) ;
1399+ items . push ( { label : '$(edit) Open settings JSON' , description : 'levelcode.ai.mcp.servers' , _action : 'settings' } ) ;
1400+ if ( Object . keys ( store ) . length ) {
1401+ items . push ( { label : '$(discard) Revoke all workspace trust…' , description : 'Repo-defined servers will ask again before starting' , _action : 'revokeAll' } ) ;
1402+ }
1403+ // Config problems belong here rather than only in `/mcp`: a server the user typed and cannot find is
1404+ // most often a rejected entry, and this is where they came to look for it.
1405+ if ( view . problems . length ) {
1406+ items . push ( { label : 'Problems' , kind : vscode . QuickPickItemKind . Separator } ) ;
1407+ for ( const p of view . problems ) {
1408+ items . push ( { label : '$(warning) ' + p . message , description : p . level , _action : 'settings' } ) ;
1409+ }
1410+ }
1411+
1412+ const pick = await vscode . window . showQuickPick ( items , {
1413+ placeHolder : view . configured
1414+ ? view . configured + ' configured · ' + view . running + ' running'
1415+ : 'No MCP servers configured yet' ,
1416+ matchOnDescription : true ,
1417+ matchOnDetail : true
1418+ } ) ;
1419+ if ( ! pick ) { return ; }
1420+
1421+ if ( pick . _action === 'add' ) { return mcpAddServer ( ) ; }
1422+ if ( pick . _action === 'settings' ) { return openMcpSettings ( ) ; }
1423+ if ( pick . _action === 'revokeAll' ) { return mcpRevokeTrust ( null , Object . keys ( store ) . length ) ; }
1424+ if ( pick . _server ) { return mcpServerActions ( pick . _server , store ) ; }
1425+ }
1426+
1427+ /** Second level: what you can do to one server. */
1428+ async function mcpServerActions ( s , store ) {
1429+ const items = [ { label : '$(clippy) Copy command line' , description : s . commandLine , _action : 'copy' } ] ;
1430+
1431+ if ( s . source === 'settings' ) {
1432+ items . push ( { label : '$(edit) Edit in settings JSON' , _action : 'settings' } ) ;
1433+ items . push ( { label : '$(trash) Remove from settings' , _action : 'remove' } ) ;
1434+ } else if ( mcpTrustIsStale ( s , store ) ) {
1435+ items . push ( {
1436+ label : '$(warning) This server\'s command line changed since you approved it' ,
1437+ description : 'Approve the new one on the consent card, or forget the old approval' ,
1438+ _action : 'noop'
1439+ } ) ;
1440+ items . push ( { label : '$(discard) Forget the old approval' , _action : 'revoke' } ) ;
1441+ } else if ( s . trusted ) {
1442+ items . push ( { label : '$(discard) Revoke trust for this workspace' , description : 'It will ask again before starting' , _action : 'revoke' } ) ;
1443+ } else {
1444+ items . push ( { label : '$(shield) Not yet approved' , description : 'The consent card appears when the agent first needs it' , _action : 'noop' } ) ;
1445+ }
1446+
1447+ const pick = await vscode . window . showQuickPick ( items , { placeHolder : s . name + ' · ' + ( s . source === 'settings' ? 'your settings' : s . origin ) } ) ;
1448+ if ( ! pick || pick . _action === 'noop' ) { return ; }
1449+ if ( pick . _action === 'copy' ) {
1450+ try { await vscode . env . clipboard . writeText ( s . commandLine || '' ) ; } catch ( e ) { dbg ( 'mcp.manage.copyFailed' , { error : String ( ( e && e . message ) || e ) } ) ; }
1451+ return ;
1452+ }
1453+ if ( pick . _action === 'settings' ) { return openMcpSettings ( ) ; }
1454+ if ( pick . _action === 'revoke' ) { return mcpRevokeTrust ( s . name ) ; }
1455+ if ( pick . _action === 'remove' ) { return mcpRemoveServer ( s . name ) ; }
1456+ }
1457+
1458+ function openMcpSettings ( ) {
1459+ return vscode . commands . executeCommand ( 'workbench.action.openSettingsJson' , { revealSetting : { key : 'levelcode.ai.mcp.servers' } } ) ;
1460+ }
1461+
1462+ /**
1463+ * Add a server to the USER settings tier.
1464+ *
1465+ * Global on purpose, and not offered for the workspace tier: `levelcode.ai.mcp.servers` is declared
1466+ * `application` scope precisely so a repo cannot introduce a server that starts without consent
1467+ * (docs/MCP.md G1). A UI that wrote it anywhere else would quietly undo that.
1468+ */
1469+ async function mcpAddServer ( ) {
1470+ const cfg = aiConfig ( ) ;
1471+ const existing = safeCopy ( userScopedSetting ( cfg . inspect ( 'mcp.servers' ) , { } ) || { } ) ;
1472+
1473+ const name = ( await vscode . window . showInputBox ( {
1474+ title : 'Add an MCP server — name' ,
1475+ prompt : 'Short id, used to namespace its tools as name__tool' ,
1476+ placeHolder : 'github' ,
1477+ ignoreFocusOut : true ,
1478+ validateInput : ( v ) => {
1479+ const t = String ( v || '' ) . trim ( ) ;
1480+ if ( ! t ) { return 'A name is required.' ; }
1481+ if ( Object . prototype . hasOwnProperty . call ( existing , t ) ) { return 'A server called "' + t + '" already exists.' ; }
1482+ // Not a hard rule — namespaceToolName sanitizes anyway — but a name that survives verbatim
1483+ // makes the tool names in the transcript readable.
1484+ if ( ! / ^ [ A - Z a - z 0 - 9 _ - ] + $ / . test ( t ) ) { return 'Use letters, digits, dashes or underscores so the tool names stay readable.' ; }
1485+ return null ;
1486+ }
1487+ } ) || '' ) . trim ( ) ;
1488+ if ( ! name ) { return ; }
1489+
1490+ const command = ( await vscode . window . showInputBox ( {
1491+ title : 'Add an MCP server — command' ,
1492+ prompt : 'The executable to run (it is spawned directly, not through a shell)' ,
1493+ placeHolder : 'npx' ,
1494+ ignoreFocusOut : true ,
1495+ validateInput : ( v ) => ( String ( v || '' ) . trim ( ) ? null : 'A command is required.' )
1496+ } ) || '' ) . trim ( ) ;
1497+ if ( ! command ) { return ; }
1498+
1499+ const argsLine = await vscode . window . showInputBox ( {
1500+ title : 'Add an MCP server — arguments' ,
1501+ prompt : 'Space-separated. Quote anything containing spaces, e.g. "/Users/me/My Documents"' ,
1502+ placeHolder : '-y @modelcontextprotocol/server-filesystem /path/to/dir' ,
1503+ ignoreFocusOut : true
1504+ } ) ;
1505+ if ( argsLine === undefined ) { return ; } // escaped — an empty string is a valid "no arguments"
1506+
1507+ const entry = { command : command } ;
1508+ const args = parseArgv ( argsLine ) ;
1509+ if ( args . length ) { entry . args = args ; }
1510+
1511+ existing [ name ] = entry ;
1512+ try {
1513+ await cfg . update ( 'mcp.servers' , existing , vscode . ConfigurationTarget . Global ) ;
1514+ dbg ( 'mcp.manage.added' , { name : name } ) ;
1515+ } catch ( e ) {
1516+ vscode . window . showErrorMessage ( 'Could not save the MCP server: ' + String ( ( e && e . message ) || e ) ) ;
1517+ return ;
1518+ }
1519+
1520+ // The wizard deliberately does not ask for `env`: the servers that need one need an API TOKEN, and a
1521+ // prompt for it would end with a live credential in plaintext settings.json — worse, it would look
1522+ // like the recommended way to do it. mcpClient spawns with process.env inherited, so exporting the
1523+ // variable and launching the editor from that shell keeps the secret out of any file. Anyone who
1524+ // wants it in settings anyway can put it there; this just points at the file instead of pretending
1525+ // the wizard covered everything.
1526+ const next = await vscode . window . showInformationMessage (
1527+ 'Added MCP server "' + name + '". It starts on the next agent run.' ,
1528+ 'Open settings JSON' ) ;
1529+ if ( next === 'Open settings JSON' ) { return openMcpSettings ( ) ; }
1530+ }
1531+
1532+ async function mcpRemoveServer ( name ) {
1533+ const ok = await vscode . window . showWarningMessage (
1534+ 'Remove the MCP server "' + name + '" from your settings?' , { modal : true } , 'Remove' ) ;
1535+ if ( ok !== 'Remove' ) { return ; }
1536+
1537+ const cfg = aiConfig ( ) ;
1538+ const existing = safeCopy ( userScopedSetting ( cfg . inspect ( 'mcp.servers' ) , { } ) || { } ) ;
1539+ delete existing [ name ] ;
1540+ try {
1541+ await cfg . update ( 'mcp.servers' , existing , vscode . ConfigurationTarget . Global ) ;
1542+ dbg ( 'mcp.manage.removed' , { name : name } ) ;
1543+ } catch ( e ) {
1544+ vscode . window . showErrorMessage ( 'Could not remove the MCP server: ' + String ( ( e && e . message ) || e ) ) ;
1545+ }
1546+ }
1547+
1548+ /**
1549+ * Forget a G1 launch approval — for one server, or all of them in this workspace.
1550+ *
1551+ * The missing half of trust-on-first-use: approving was permanent with no way back, which makes the
1552+ * prompt harder to say yes to than it should be. Revoking does not stop a server already running in
1553+ * this session; it means the next run asks again, so the message says exactly that rather than
1554+ * implying the process was killed.
1555+ */
1556+ async function mcpRevokeTrust ( name , count ) {
1557+ const target = name ? ( 'trust for "' + name + '"' ) : ( 'trust for all ' + count + ' repo-defined server(s)' ) ;
1558+ const ok = await vscode . window . showWarningMessage (
1559+ 'Revoke ' + target + ' in this workspace? They will ask for approval again before starting.' ,
1560+ { modal : true } , 'Revoke' ) ;
1561+ if ( ok !== 'Revoke' ) { return ; }
1562+
1563+ // Re-read rather than reuse the snapshot the menu was built from: a modal was just open, and
1564+ // approving a different server in the meantime must not be silently rolled back by this write.
1565+ let next ;
1566+ if ( name ) { next = mcpLaunchTrust ( ) ; delete next [ name ] ; } else { next = { } ; }
1567+ await saveMcpLaunchTrust ( next ) ;
1568+ dbg ( 'mcp.manage.trustRevoked' , { server : name || '*' } ) ;
1569+ vscode . window . showInformationMessage (
1570+ name ? '"' + name + '" will ask before starting again.' : 'Repo-defined servers will ask before starting again.' ) ;
1571+ }
1572+
13531573async function pickModel ( ) {
13541574 const cfg = aiConfig ( ) ;
13551575 // Gateway mode + signed in → a plan-scoped LevelCode Cloud menu (free engine + flagship, the latter
@@ -1521,6 +1741,7 @@ class ChatViewProvider {
15211741 }
15221742 case 'setKey' : await promptForKey ( ) ; break ;
15231743 case 'pickModel' : await pickModel ( ) ; break ;
1744+ case 'manageMcp' : await manageMcpServers ( ) ; break ;
15241745 case 'openSettings' : vscode . commands . executeCommand ( 'workbench.action.openSettings' , '@ext:levelcode.levelcode-ai' ) ; break ;
15251746 }
15261747 } ) ;
@@ -1795,6 +2016,7 @@ function activate(context) {
17952016 vscode . commands . registerCommand ( 'levelcode.import.vscode' , ( ) => importFromVscode ( context ) ) ,
17962017 vscode . commands . registerCommand ( 'levelcode.ai.newChat' , newChat ) ,
17972018 vscode . commands . registerCommand ( 'levelcode.ai.pickModel' , pickModel ) ,
2019+ vscode . commands . registerCommand ( 'levelcode.ai.manageMcp' , manageMcpServers ) ,
17982020 vscode . commands . registerCommand ( 'levelcode.ai.addSelection' , addSelection ) ,
17992021 vscode . commands . registerCommand ( 'levelcode.ai.addFileContext' , addContext ) ,
18002022 vscode . commands . registerCommand ( 'levelcode.ai.setApiKey' , ( ) => promptForKey ( ) ) ,
0 commit comments