MCP checkbox for always allow

This commit is contained in:
Matt Rubens
2024-12-13 14:23:31 -05:00
parent 6ee118e0a2
commit 1346f1280c
26 changed files with 744 additions and 22 deletions

View File

@@ -0,0 +1,17 @@
class Client {
constructor() {
this.request = jest.fn()
}
connect() {
return Promise.resolve()
}
close() {
return Promise.resolve()
}
}
module.exports = {
Client
}

View File

@@ -0,0 +1,22 @@
class StdioClientTransport {
constructor() {
this.start = jest.fn().mockResolvedValue(undefined)
this.close = jest.fn().mockResolvedValue(undefined)
this.stderr = {
on: jest.fn()
}
}
}
class StdioServerParameters {
constructor() {
this.command = ''
this.args = []
this.env = {}
}
}
module.exports = {
StdioClientTransport,
StdioServerParameters
}

View File

@@ -0,0 +1,24 @@
const { Client } = require('./client/index.js')
const { StdioClientTransport, StdioServerParameters } = require('./client/stdio.js')
const {
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ErrorCode,
McpError
} = require('./types.js')
module.exports = {
Client,
StdioClientTransport,
StdioServerParameters,
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ErrorCode,
McpError
}

View File

@@ -0,0 +1,51 @@
const CallToolResultSchema = {
parse: jest.fn().mockReturnValue({})
}
const ListToolsResultSchema = {
parse: jest.fn().mockReturnValue({
tools: []
})
}
const ListResourcesResultSchema = {
parse: jest.fn().mockReturnValue({
resources: []
})
}
const ListResourceTemplatesResultSchema = {
parse: jest.fn().mockReturnValue({
resourceTemplates: []
})
}
const ReadResourceResultSchema = {
parse: jest.fn().mockReturnValue({
contents: []
})
}
const ErrorCode = {
InvalidRequest: 'InvalidRequest',
MethodNotFound: 'MethodNotFound',
InvalidParams: 'InvalidParams',
InternalError: 'InternalError'
}
class McpError extends Error {
constructor(code, message) {
super(message)
this.code = code
}
}
module.exports = {
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ErrorCode,
McpError
}

17
src/__mocks__/McpHub.ts Normal file
View File

@@ -0,0 +1,17 @@
export class McpHub {
connections = []
isConnecting = false
constructor() {
this.toggleToolAlwaysAllow = jest.fn()
this.callTool = jest.fn()
}
async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
return Promise.resolve()
}
async callTool(serverName: string, toolName: string, toolArguments?: Record<string, unknown>): Promise<any> {
return Promise.resolve({ result: 'success' })
}
}

View File

@@ -0,0 +1,12 @@
// Mock default shell based on platform
const os = require('os');
let defaultShell;
if (os.platform() === 'win32') {
defaultShell = 'cmd.exe';
} else {
defaultShell = '/bin/bash';
}
module.exports = defaultShell;
module.exports.default = defaultShell;

6
src/__mocks__/delay.js Normal file
View File

@@ -0,0 +1,6 @@
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = delay;
module.exports.default = delay;

10
src/__mocks__/globby.js Normal file
View File

@@ -0,0 +1,10 @@
function globby(patterns, options) {
return Promise.resolve([]);
}
globby.sync = function(patterns, options) {
return [];
};
module.exports = globby;
module.exports.default = globby;

6
src/__mocks__/os-name.js Normal file
View File

@@ -0,0 +1,6 @@
function osName() {
return 'macOS';
}
module.exports = osName;
module.exports.default = osName;

View File

@@ -0,0 +1,20 @@
function pWaitFor(condition, options = {}) {
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval);
resolve();
}
}, options.interval || 20);
if (options.timeout) {
setTimeout(() => {
clearInterval(interval);
reject(new Error('Timed out'));
}, options.timeout);
}
});
}
module.exports = pWaitFor;
module.exports.default = pWaitFor;

View File

@@ -0,0 +1,25 @@
function serializeError(error) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack
};
}
return error;
}
function deserializeError(errorData) {
if (errorData && typeof errorData === 'object') {
const error = new Error(errorData.message);
error.name = errorData.name;
error.stack = errorData.stack;
return error;
}
return errorData;
}
module.exports = {
serializeError,
deserializeError
};

View File

@@ -0,0 +1,7 @@
function stripAnsi(string) {
// Simple mock that just returns the input string
return string;
}
module.exports = stripAnsi;
module.exports.default = stripAnsi;

57
src/__mocks__/vscode.js Normal file
View File

@@ -0,0 +1,57 @@
const vscode = {
window: {
showInformationMessage: jest.fn(),
showErrorMessage: jest.fn(),
createTextEditorDecorationType: jest.fn().mockReturnValue({
dispose: jest.fn()
})
},
workspace: {
onDidSaveTextDocument: jest.fn()
},
Disposable: class {
dispose() {}
},
Uri: {
file: (path) => ({
fsPath: path,
scheme: 'file',
authority: '',
path: path,
query: '',
fragment: '',
with: jest.fn(),
toJSON: jest.fn()
})
},
EventEmitter: class {
constructor() {
this.event = jest.fn();
this.fire = jest.fn();
}
},
ConfigurationTarget: {
Global: 1,
Workspace: 2,
WorkspaceFolder: 3
},
Position: class {
constructor(line, character) {
this.line = line;
this.character = character;
}
},
Range: class {
constructor(startLine, startCharacter, endLine, endCharacter) {
this.start = new vscode.Position(startLine, startCharacter);
this.end = new vscode.Position(endLine, endCharacter);
}
},
ThemeColor: class {
constructor(id) {
this.id = id;
}
}
};
module.exports = vscode;

View File

@@ -550,6 +550,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "toggleToolAlwaysAllow": {
try {
await this.mcpHub?.toggleToolAlwaysAllow(
message.serverName!,
message.toolName!,
message.alwaysAllow!
)
} catch (error) {
console.error(`Failed to toggle auto-approve for tool ${message.toolName}:`, error)
}
break
}
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
case "playSound":

View File

@@ -33,14 +33,17 @@ export type McpConnection = {
}
// StdioServerParameters
const AlwaysAllowSchema = z.array(z.string()).default([])
const StdioConfigSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
alwaysAllow: AlwaysAllowSchema.optional()
})
const McpSettingsSchema = z.object({
mcpServers: z.record(StdioConfigSchema),
mcpServers: z.record(StdioConfigSchema)
})
export class McpHub {
@@ -285,7 +288,21 @@ export class McpHub {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
return response?.tools || []
// Get always allow settings
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const alwaysAllowConfig = config.mcpServers[serverName]?.alwaysAllow || []
// Mark tools as always allowed based on settings
const tools = (response?.tools || []).map(tool => ({
...tool,
alwaysAllow: alwaysAllowConfig.includes(tool.name)
}))
console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
return tools
} catch (error) {
// console.error(`Failed to fetch tools for ${serverName}:`, error)
return []
@@ -478,6 +495,7 @@ export class McpHub {
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
)
}
return await connection.client.request(
{
method: "tools/call",
@@ -490,6 +508,45 @@ export class McpHub {
)
}
async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Initialize alwaysAllow if it doesn't exist
if (!config.mcpServers[serverName].alwaysAllow) {
config.mcpServers[serverName].alwaysAllow = []
}
const alwaysAllow = config.mcpServers[serverName].alwaysAllow
const toolIndex = alwaysAllow.indexOf(toolName)
if (shouldAllow && toolIndex === -1) {
// Add tool to always allow list
alwaysAllow.push(toolName)
} else if (!shouldAllow && toolIndex !== -1) {
// Remove tool from always allow list
alwaysAllow.splice(toolIndex, 1)
}
// Write updated config back to file
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Update the tools list to reflect the change
const connection = this.connections.find(conn => conn.server.name === serverName)
if (connection) {
connection.server.tools = await this.fetchToolsList(serverName)
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update always allow settings:", error)
vscode.window.showErrorMessage("Failed to update always allow settings")
throw error // Re-throw to ensure the error is properly handled
}
}
async dispose(): Promise<void> {
this.removeAllFileWatchers()
for (const connection of this.connections) {

View File

@@ -0,0 +1,193 @@
import type { McpHub as McpHubType } from '../McpHub'
import type { ClineProvider } from '../../../core/webview/ClineProvider'
import type { ExtensionContext, Uri } from 'vscode'
import type { McpConnection } from '../McpHub'
const vscode = require('vscode')
const fs = require('fs/promises')
const { McpHub } = require('../McpHub')
jest.mock('vscode')
jest.mock('fs/promises')
jest.mock('../../../core/webview/ClineProvider')
describe('McpHub', () => {
let mcpHub: McpHubType
let mockProvider: Partial<ClineProvider>
const mockSettingsPath = '/mock/settings/path/cline_mcp_settings.json'
beforeEach(() => {
jest.clearAllMocks()
const mockUri: Uri = {
scheme: 'file',
authority: '',
path: '/test/path',
query: '',
fragment: '',
fsPath: '/test/path',
with: jest.fn(),
toJSON: jest.fn()
}
mockProvider = {
ensureSettingsDirectoryExists: jest.fn().mockResolvedValue('/mock/settings/path'),
ensureMcpServersDirectoryExists: jest.fn().mockResolvedValue('/mock/settings/path'),
postMessageToWebview: jest.fn(),
context: {
subscriptions: [],
workspaceState: {} as any,
globalState: {} as any,
secrets: {} as any,
extensionUri: mockUri,
extensionPath: '/test/path',
storagePath: '/test/storage',
globalStoragePath: '/test/global-storage',
environmentVariableCollection: {} as any,
extension: {
id: 'test-extension',
extensionUri: mockUri,
extensionPath: '/test/path',
extensionKind: 1,
isActive: true,
packageJSON: {
version: '1.0.0'
},
activate: jest.fn(),
exports: undefined
} as any,
asAbsolutePath: (path: string) => path,
storageUri: mockUri,
globalStorageUri: mockUri,
logUri: mockUri,
extensionMode: 1,
logPath: '/test/path',
languageModelAccessInformation: {} as any
} as ExtensionContext
}
// Mock fs.readFile for initial settings
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
alwaysAllow: ['allowed-tool']
}
}
}))
mcpHub = new McpHub(mockProvider as ClineProvider)
})
describe('toggleToolAlwaysAllow', () => {
it('should add tool to always allow list when enabling', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
alwaysAllow: []
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleToolAlwaysAllow('test-server', 'new-tool', true)
// Verify the config was updated correctly
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toContain('new-tool')
})
it('should remove tool from always allow list when disabling', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
alwaysAllow: ['existing-tool']
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleToolAlwaysAllow('test-server', 'existing-tool', false)
// Verify the config was updated correctly
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).not.toContain('existing-tool')
})
it('should initialize alwaysAllow if it does not exist', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js']
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleToolAlwaysAllow('test-server', 'new-tool', true)
// Verify the config was updated with initialized alwaysAllow
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toBeDefined()
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toContain('new-tool')
})
})
describe('callTool', () => {
it('should execute tool successfully', async () => {
// Mock the connection with a minimal client implementation
const mockConnection: McpConnection = {
server: {
name: 'test-server',
config: JSON.stringify({}),
status: 'connected' as const
},
client: {
request: jest.fn().mockResolvedValue({ result: 'success' })
} as any,
transport: {
start: jest.fn(),
close: jest.fn(),
stderr: { on: jest.fn() }
} as any
}
mcpHub.connections = [mockConnection]
await mcpHub.callTool('test-server', 'some-tool', {})
// Verify the request was made with correct parameters
expect(mockConnection.client.request).toHaveBeenCalledWith(
{
method: 'tools/call',
params: {
name: 'some-tool',
arguments: {}
}
},
expect.any(Object)
)
})
it('should throw error if server not found', async () => {
await expect(mcpHub.callTool('non-existent-server', 'some-tool', {}))
.rejects
.toThrow('No connection found for server: non-existent-server')
})
})
})

View File

@@ -34,6 +34,7 @@ export interface WebviewMessage {
| "diffEnabled"
| "openMcpSettings"
| "restartMcpServer"
| "toggleToolAlwaysAllow"
text?: string
askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration
@@ -41,6 +42,10 @@ export interface WebviewMessage {
bool?: boolean
commands?: string[]
audioType?: AudioType
// For toggleToolAutoApprove
serverName?: string
toolName?: string
alwaysAllow?: boolean
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"

View File

@@ -12,6 +12,7 @@ export type McpTool = {
name: string
description?: string
inputSchema?: object
alwaysAllow?: boolean
}
export type McpResource = {